Files
zeekay dbc63e80a9 feat: initial world-zap ZAP gateway
Real-time Hanzo World feeds over ZAP binary protocol (9-byte header +
JSON payload) fronted by WebSockets at wss://zap.world.hanzo.ai/zap.

IAM bearer auth via Authorization header or ?token= query param.
Validator caches /oauth/userinfo for 5 minutes.

Topics (10): world.events.{all,conflicts,earthquakes,fires},
world.markets.{quotes,crypto}, world.ships.ais, world.aviation.opensky,
world.news.live, world.weather.alerts. Minimum plan enforced per topic.

Tools: subscribe, unsubscribe, list_topics. Admin-only publish for
internal service accounts.

Ingest: streams events from worldmonitor GET /v1/world/events?stream=1,
fans out via bounded-channel hub with drop-on-overflow semantics.

Per-user token bucket rate limits: free 30/min, pro 3000/min, team
15000/min, enterprise 60000/min.

Tests: all packages pass -race (proto, auth, hub, ratelimit, ingest,
handlers). Multi-arch image build wired to hanzoai/.github docker-build
workflow.
2026-04-18 12:07:47 -07:00

89 lines
1.9 KiB
Go

// Package ratelimit implements a per-user token bucket rate limiter with
// plan-based capacities.
//
// Free: 30/min. Pro: 3000/min. Team: 15000/min. Enterprise: 60000/min.
package ratelimit
import (
"sync"
"time"
)
// PlanCapacity returns the per-minute capacity for a plan. Unknown plans
// fall back to "free".
func PlanCapacity(plan string) int {
switch plan {
case "pro":
return 3000
case "team":
return 15000
case "enterprise":
return 60000
default:
return 30
}
}
type bucket struct {
tokens float64
updated time.Time
plan string
}
// Limiter is a concurrency-safe token bucket keyed by user ID.
type Limiter struct {
mu sync.Mutex
buckets map[string]*bucket
clock func() time.Time
}
// New constructs an empty Limiter.
func New() *Limiter {
return &Limiter{
buckets: make(map[string]*bucket),
clock: time.Now,
}
}
// Decision is the result of a rate-limit check.
type Decision struct {
Allowed bool
Remaining int
RetryIn time.Duration
}
// TryAcquire attempts to consume 1 token. Returns a Decision describing the
// outcome. If plan changes, capacity resizes at the next request.
func (l *Limiter) TryAcquire(userID, plan string) Decision {
cap := PlanCapacity(plan)
refillPerSec := float64(cap) / 60.0
l.mu.Lock()
defer l.mu.Unlock()
now := l.clock()
b, ok := l.buckets[userID]
if !ok || b.plan != plan {
b = &bucket{tokens: float64(cap), updated: now, plan: plan}
l.buckets[userID] = b
}
elapsed := now.Sub(b.updated).Seconds()
if elapsed > 0 {
b.tokens += elapsed * refillPerSec
if b.tokens > float64(cap) {
b.tokens = float64(cap)
}
b.updated = now
}
if b.tokens < 1.0 {
missing := 1.0 - b.tokens
retry := time.Duration(missing/refillPerSec*float64(time.Second)) + time.Millisecond
return Decision{Allowed: false, Remaining: 0, RetryIn: retry}
}
b.tokens -= 1.0
return Decision{Allowed: true, Remaining: int(b.tokens), RetryIn: 0}
}