// Package ratelimit provides small process-local rate-limit primitives used by // the gateway edge policy layers. package ratelimit import ( "sync" "time" "golang.org/x/time/rate" ) // Policy describes one token-bucket budget enforced for a concrete key. type Policy struct { // Requests is the number of accepted requests replenished per Window. Requests int // Window is the interval over which Requests are replenished. Window time.Duration // Burst is the maximum number of immediately available tokens. Burst int } // Decision describes the result of one limiter reservation attempt. type Decision struct { // Allowed reports whether the request may proceed immediately. Allowed bool // RetryAfter is the minimum delay the caller should wait before retrying // when Allowed is false. RetryAfter time.Duration } // Limiter applies a policy to one concrete key. type Limiter interface { // Reserve evaluates key under policy and reports whether the request may // proceed immediately. Reserve(key string, policy Policy) Decision } // InMemory is a process-local Limiter backed by x/time/rate token buckets. type InMemory struct { now func() time.Time cleanupInterval time.Duration mu sync.Mutex entries map[string]*entry nextCleanup time.Time } type entry struct { limiter *rate.Limiter limit rate.Limit burst int expiresAt time.Time } // NewInMemory constructs a process-local limiter suitable for one gateway // process instance. func NewInMemory() *InMemory { return &InMemory{ now: time.Now, cleanupInterval: time.Minute, entries: make(map[string]*entry), } } // Reserve evaluates key against policy and reports whether the request may // proceed immediately. func (l *InMemory) Reserve(key string, policy Policy) Decision { if policy.Requests <= 0 || policy.Window <= 0 || policy.Burst <= 0 { return Decision{} } now := l.now() limit := rate.Limit(float64(policy.Requests) / policy.Window.Seconds()) l.mu.Lock() defer l.mu.Unlock() l.cleanupExpiredBucketsLocked(now) current, ok := l.entries[key] if !ok || current.limit != limit || current.burst != policy.Burst { current = &entry{ limiter: rate.NewLimiter(limit, policy.Burst), limit: limit, burst: policy.Burst, } l.entries[key] = current } current.expiresAt = now.Add(entryTTL(policy.Window)) reservation := current.limiter.ReserveN(now, 1) if !reservation.OK() { return Decision{ Allowed: false, RetryAfter: policy.Window, } } retryAfter := reservation.DelayFrom(now) if retryAfter > 0 { return Decision{ Allowed: false, RetryAfter: retryAfter, } } return Decision{Allowed: true} } func (l *InMemory) cleanupExpiredBucketsLocked(now time.Time) { if !l.nextCleanup.IsZero() && now.Before(l.nextCleanup) { return } for key, current := range l.entries { if !current.expiresAt.After(now) { delete(l.entries, key) } } l.nextCleanup = now.Add(l.cleanupInterval) } func entryTTL(window time.Duration) time.Duration { if window < time.Minute { return time.Minute } return 2 * window } var _ Limiter = (*InMemory)(nil)