Token bucket rate limiter
Problem statement
Implement a token bucket, the rate limiter used by API gateways, cloud SDK clients and traffic shapers. The bucket holds up to capacity tokens and refills continuously at refill_rate tokens per second. Each request spends tokens. If there are not enough, the request is rejected. The result: short bursts up to capacity are allowed, and the long-run rate is capped at refill_rate.
API (Java: same names in camelCase, DoubleSupplier clock, allow() / allow(cost) overloads)
TokenBucket(capacity: float, refill_rate: float, clock=time.monotonic)allow(cost=1) -> bool # spend `cost` tokens if availablewait_time(cost=1) -> float # seconds until allow(cost) would succeed; 0.0 if it would nowtokens() -> float # tokens available right nowRules
- The bucket starts full.
- Refill is continuous: after
sseconds,s * refill_ratetokens have been added, never abovecapacity. Fractional tokens are allowed. - A rejected request spends nothing.
cost <= 0orcost > capacityraisesValueError(JavaIllegalArgumentException). A request bigger than the bucket could never succeed, so rejecting it forever would hide a bug.- Do not use a background thread or timer. Everything is computed when a method is called.
- If the clock ever goes backwards, treat the elapsed time as zero.
Outputs are the return values of each call, in order, with the fake clock set to t.
Examples
Example 1
Input: t=0 b = TokenBucket(capacity=3, refill_rate=1)
t=0 b.allow() x4
t=1.5 b.allow()
t=1.5 b.wait_time()
t=1.5 b.tokens()
t=3 b.allow(2)
t=3 b.allow()
Output: true
true
true
false
true
0.5
0.5
true
false
Explanation: Three tokens allow a burst of three. After 1.5 s there are 1.5 tokens: one request spends 1, leaving 0.5, so another needs 0.5 s more. At t=3 the bucket has 2.0 tokens, exactly enough for a cost of 2.
Example 2
Input: t=0 b = TokenBucket(capacity=3, refill_rate=1)
t=0 b.allow(3)
t=100 b.tokens()
t=100 b.allow(4)
Output: true
3.0
error: cost exceeds capacity
Explanation: Idle time refills the bucket only up to capacity, not to 100 tokens.
Hints
Approach
Optimal
Store two numbers: tokens and last, the clock reading when tokens was last brought up to date. This is lazy refill.
_refill: computeelapsed = now - last(clamped at 0), settokens = min(capacity, tokens + elapsed * rate), and movelasttonow.allow(cost): refill, then iftokens >= costsubtract and returnTrue. Otherwise returnFalseand leavetokensunchanged.wait_time(cost): refill, then the shortfallcost - tokensdivided byrateis how long until enough tokens exist. This is the number a server puts in aRetry-Afterheader on a 429 response.
State is two floats per bucket, no matter how many requests go through it, so you can afford one bucket per client.
O(1) per callSpace O(1) per bucketimport time class TokenBucket: def __init__(self, capacity, refill_rate, clock=time.monotonic): if capacity <= 0 or refill_rate <= 0: raise ValueError("capacity and refill_rate must be positive") self.capacity = float(capacity) self.rate = float(refill_rate) # tokens per second self.clock = clock self._tokens = self.capacity # start full self._last = clock() def _refill(self): now = self.clock() elapsed = max(0.0, now - self._last) # never refill backwards self._tokens = min(self.capacity, self._tokens + elapsed * self.rate) self._last = now def _check(self, cost): if cost <= 0: raise ValueError("cost must be positive") if cost > self.capacity: raise ValueError("cost exceeds capacity") def allow(self, cost=1): self._check(cost) self._refill() if self._tokens >= cost: self._tokens -= cost return True return False def wait_time(self, cost=1): """Seconds until allow(cost) would succeed (0.0 if it would succeed now).""" self._check(cost) self._refill() return max(0.0, (cost - self._tokens) / self.rate) def tokens(self): self._refill() return self._tokensFollow-up questions
- Limit per client: keep a map of buckets. How do you stop idle clients from filling memory?
- Make
allowthread-safe. Which lines have to be inside the lock? - Add a blocking
acquire(cost)that sleeps forwait_timeand retries.
Frequently asked questions
A token bucket allows bursts up to capacity and then enforces the average rate. A leaky bucket, in its queue form, releases requests at a fixed pace and smooths bursts out instead of allowing them. Most API limits are token buckets because clients naturally send bursts. For outgoing traffic you want to pace, such as calls to a fragile downstream, a leaky bucket is the better fit.
Go's extended library ships this as golang.org/x/time/rate (rate.NewLimiter(r, burst), with Allow, Wait(ctx) and Reserve), and it uses the same lazy-refill idea. Your own version would be a struct with a sync.Mutex, tokens float64 and last time.Time, taking a clock interface for tests. Across many servers an in-process bucket only limits each instance, so shared limits keep the two numbers in Redis and update them atomically in a Lua script.
Rate limits show up in every layer infra engineers own: ingress, API gateways, cloud API quotas, and client SDKs that must stay under them. The question checks whether you can turn a rule ("10 per second, bursts of 20") into correct O(1) code without timers or threads.