The second half of phase 5. A key now has a cost, a quota and a bill. Rate limits use a sliding window rather than a fixed one. A fixed window lets a caller send its whole minute's allowance in the last second of one window and again in the first second of the next, so the observed rate is twice the configured limit exactly when the system is least able to take it. Checking and charging are one operation, or two concurrent requests both see room and both take it. Token limits are checked against an estimate, because the real count only exists once the request has run; the difference is charged afterwards, so a caller who consistently underestimates is still bounded, one window late. A request with no max_tokens gets a modest assumption rather than zero — assuming zero would let an unbounded request through every token limit. Limits are checked before admission, not after: a request over its quota should be refused rather than queued, where it would occupy a band's capacity to no purpose. All refusals are 429, but a spent budget carries no Retry-After because waiting does not help until the period rolls over, and saying otherwise turns one client into a retry loop. Usage goes to a buffered channel and is written in batches. Record never blocks: if the buffer fills — a database stalled long enough to lose eight thousand records — records are dropped and counted, because losing accounting is bad and turning a stalled database into an outage is worse. A clean shutdown drains what is queued; those are requests that were already served. Failures are logged at error, since silently losing billing data is the kind of thing discovered at the end of a month. Requests are accounted whatever their outcome. A request that used a GPU and then failed still used it, and a key that could spend nothing by failing would be a way around a budget. Budgets are counted in memory and restored from the store at startup, so a restart does not hand every key a fresh allowance. The budget period travels with the reservation rather than being re-derived, because a monthly budget credited to a daily counter is a budget that never binds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
131 lines
3.4 KiB
Go
131 lines
3.4 KiB
Go
package accounting
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Limiter enforces per-key rate limits.
|
|
//
|
|
// A sliding window rather than a fixed one. A fixed window lets a caller send
|
|
// its whole minute's allowance in the last second of one window and again in
|
|
// the first second of the next, so the observed rate is twice the configured
|
|
// limit exactly when the system is least able to take it.
|
|
//
|
|
// The window is approximated from two buckets, which is the usual compromise:
|
|
// exact enough that the doubling cannot happen, and bounded memory per key
|
|
// rather than a timestamp per request.
|
|
type Limiter struct {
|
|
now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
keys map[string]*window
|
|
}
|
|
|
|
type window struct {
|
|
// current and previous are counts in this minute and the one before.
|
|
current, previous int64
|
|
start time.Time
|
|
}
|
|
|
|
// NewLimiter builds an empty limiter.
|
|
func NewLimiter() *Limiter {
|
|
return &Limiter{now: time.Now, keys: map[string]*window{}}
|
|
}
|
|
|
|
const windowLen = time.Minute
|
|
|
|
// Allow charges n against a key's limit and reports whether it fits.
|
|
//
|
|
// Charging and checking are one operation on purpose: doing them separately
|
|
// lets two concurrent requests both see room and both take it.
|
|
func (l *Limiter) Allow(key string, limit int, n int64) bool {
|
|
if limit <= 0 {
|
|
// No limit configured. Zero would be a limit of zero, which is why
|
|
// callers pass a nil pointer rather than 0 for "unset".
|
|
return true
|
|
}
|
|
now := l.now()
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
w := l.windowLocked(key, now)
|
|
if w.rate(now)+float64(n) > float64(limit) {
|
|
return false
|
|
}
|
|
w.current += n
|
|
return true
|
|
}
|
|
|
|
// Charge records usage that was already allowed, for reconciliation.
|
|
//
|
|
// Token limits are checked against an estimate before the request runs, because
|
|
// the real figure only exists once it has. The difference is charged here, so a
|
|
// caller whose requests are consistently larger than estimated is still bounded
|
|
// — one window later than it would be with a perfect estimate.
|
|
func (l *Limiter) Charge(key string, n int64) {
|
|
if n == 0 {
|
|
return
|
|
}
|
|
now := l.now()
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
w := l.windowLocked(key, now)
|
|
w.current += n
|
|
if w.current < 0 {
|
|
w.current = 0
|
|
}
|
|
}
|
|
|
|
// Rate returns a key's current observed rate.
|
|
func (l *Limiter) Rate(key string) float64 {
|
|
now := l.now()
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.windowLocked(key, now).rate(now)
|
|
}
|
|
|
|
func (l *Limiter) windowLocked(key string, now time.Time) *window {
|
|
w, ok := l.keys[key]
|
|
if !ok {
|
|
w = &window{start: now.Truncate(windowLen)}
|
|
l.keys[key] = w
|
|
}
|
|
w.roll(now)
|
|
return w
|
|
}
|
|
|
|
// roll advances the window to now.
|
|
func (w *window) roll(now time.Time) {
|
|
start := now.Truncate(windowLen)
|
|
switch {
|
|
case start.Equal(w.start):
|
|
return
|
|
case start.Sub(w.start) == windowLen:
|
|
w.previous, w.current = w.current, 0
|
|
default:
|
|
// More than one window has passed with no traffic.
|
|
w.previous, w.current = 0, 0
|
|
}
|
|
w.start = start
|
|
}
|
|
|
|
// rate weights the previous window by how much of it is still in view.
|
|
func (w *window) rate(now time.Time) float64 {
|
|
elapsed := now.Sub(w.start)
|
|
if elapsed >= windowLen {
|
|
return float64(w.current)
|
|
}
|
|
remaining := 1 - float64(elapsed)/float64(windowLen)
|
|
return float64(w.previous)*remaining + float64(w.current)
|
|
}
|
|
|
|
// Forget drops a key's window. Used when a key is revoked; keeping it would
|
|
// leak memory for every key that ever called.
|
|
func (l *Limiter) Forget(key string) {
|
|
l.mu.Lock()
|
|
delete(l.keys, key)
|
|
l.mu.Unlock()
|
|
}
|