A good limiter tells a denied client when to come back rather than leaving it to guess. Today you extend the decision with a retry-after hint and compute it for the token bucket - how many ticks until one whole token has refilled.
On a denied request, report the ticks until a token is available.
Denying a request is more useful when it comes with advice: come back in N ticks.
This is the Retry-After header a real API returns with a 429, and it turns a blind
retry loop into a paced one. We add a RetryAfter field to the Decision
(zero whenever a request is allowed). For the token bucket the wait is the time
until the bucket holds enough tokens: the shortfall cost - tokens divided by the
refill rate, rounded up to a whole tick, because a partial tick has not
delivered its token yet.
Rounding up is the detail to pin. A capacity-2 bucket at rate 0.5, freshly drained,
needs a full token; at 0.5 per tick that is ceil(1 / 0.5) = 2 ticks. One tick
later it has banked 0.5, so it needs only 0.5 more - ceil(0.5 / 0.5) = 1 tick. The
countdown is honest: at each denied call the hint reflects exactly how much longer
this bucket will make the client wait. Tomorrow you compute the same hint for the
fixed window, where the answer comes from the window boundary instead of a refill
rate.
// Decision now carries a hint: type Decision struct { Allowed bool; RetryAfter int64 }func (b *TokenBucket) AllowN(now int64, cost float64) Decision {b.refill(now)if b.tokens >= cost { b.tokens -= cost; return Decision{Allowed: true} }need := cost - b.tokenswait := int64(math.Ceil(need / b.rate))return Decision{Allowed: false, RetryAfter: wait}}