29. Sliding-Window Token-Rate Limiter
Problem
A lab's public API bills and throttles by tokens, not raw request count — a customer sending one huge prompt should count more against their quota than ten tiny ones. Implement a per-API-key sliding-window token-rate limiter.
Unlike most problems in this set, this one is stateful: you're designing a class, TokenRateLimiter, that holds state across many calls to allow(). Each call to allow(key, timestamp, tokens) admits the request only if the sum of tokens already logged for that key within the trailing window_seconds does not exceed max_tokens once this request's tokens are added, and must decide this using only that key's own history.
Source: src/29_token_rate_limiter.py
class TokenRateLimiter:
def __init__(self, max_tokens: int, window_seconds: float): ...
def allow(self, key: str, timestamp: float, tokens: int) -> bool: ...
>>> rl = TokenRateLimiter(max_tokens=100, window_seconds=10)
>>> rl.allow("k", 0, 40)
True
>>> rl.allow("k", 1, 50)
True
>>> rl.allow("k", 2, 20)
False
>>> rl.allow("k", 12, 20)
True
Step-by-Step Approach
- Store, per key, a log of (timestamp, tokens) pairs for admitted requests — a
dict[str, deque[tuple[float, int]]]works well because you need cheap eviction from the front and cheap appends at the back. - Also track a running token sum per key, so you never have to re-sum the whole log on every call — that's what turns this from an O(window size) check into an amortized O(1) one.
- On each
allow(key, timestamp, tokens)call, compute the window cutoff:cutoff = timestamp - window_seconds. Any logged entry with timestamp<= cutoffis now outside the rolling window. - Evict stale entries from the front of that key's deque while the oldest entry is
<= cutoff, subtracting each evicted entry's tokens from the running sum as you go. - After eviction, check whether
running_sum + tokens <= max_tokens. If so, admit: append the new entry, add its tokens to the sum, return True. Otherwise return False without recording anything (a rejected request shouldn't count against future decisions). - Handle edge cases: a single request whose own token count exceeds
max_tokensis always rejected regardless of history, and a request landing exactly at the cutoff timestamp is treated as already stale (the window is a trailing, half-open interval).
The key insight is the same as a plain sliding-window-log rate limiter, plus one more piece of running state: because entries are evicted from the front in append order and never re-scanned, maintaining a running sum alongside the deque keeps each call amortized O(1) instead of re-summing the window every time.
Reference solution
from collections import deque
class TokenRateLimiter:
def __init__(self, max_tokens: int, window_seconds: float):
self.max_tokens = max_tokens
self.window_seconds = window_seconds
# per key: deque of (timestamp, tokens), and a running sum of tokens currently in-window
self._log: dict[str, deque[tuple[float, int]]] = {}
self._sum: dict[str, int] = {}
def allow(self, key: str, timestamp: float, tokens: int) -> bool:
log = self._log.setdefault(key, deque())
cutoff = timestamp - self.window_seconds
cur_sum = self._sum.get(key, 0)
# evict stale entries from the front; deque stays sorted by append order
while log and log[0][0] <= cutoff:
_, stale_tokens = log.popleft()
cur_sum -= stale_tokens
if cur_sum + tokens <= self.max_tokens:
log.append((timestamp, tokens))
cur_sum += tokens
self._sum[key] = cur_sum
return True
# keep the running sum in sync even on rejection (eviction still happened)
self._sum[key] = cur_sum
return False
Key Functions & Tricks
collections.deque— O(1) append/pop at both ends, vs. O(n) forlist.pop(0)dict.setdefault(key, deque())— lazily initializes a new key's log in one expression- Running token sum alongside the deque — avoids re-summing the window on every call, the difference between amortized O(1) and O(window size) per call
while log and log[0][0] <= cutoff: ...— evicts stale entries; sorted-by-append-order means only the front ever needs checking- Persisting the updated sum on rejection too — eviction still happened even when the request itself is denied, so state must stay correct either way
How to Recognize This Pattern
Reach for a sliding-window-log design whenever a problem enforces "at most N of [some measured quantity] in any rolling T-second window" per key — the interviewer emphasizing "design" or "state" over a pure computation is a signal they want a class with persistent state, not a stateless function. When the measured quantity is a running sum (tokens, bytes, cost) rather than a raw count, pairing the deque with a running total is what keeps the per-call cost amortized O(1) instead of degrading to O(window size). Common variations include token-bucket designs (O(1) memory per key but allow small bursts at window boundaries, trading exactness for space) or fixed-window counters (cheaper still, but allow a burst of up to 2x at window boundaries). A common pitfall is re-summing the whole window on every call instead of maintaining a running total, which silently reintroduces O(n) per-call cost; another is forgetting to keep the running sum in sync when a request is rejected but eviction still occurred.