Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Rate Limiter, Constraint by Constraint
A third incremental round, where stage two invalidates stage one outright: a fixed-window counter becomes a sliding window, then per-key, then gains a burst allowance. Tests whether you can say "that counter has to go" calmly and refactor, rather than patching around it.
The problem
Implement a class Limiter with allow(key, now) returning true or false, growing through four stated stages. (1) At most limit calls per window seconds. (2) The window must slide: at any moment, count only the calls in the last window seconds — not calls since a fixed reset point. (3) Limits are per key, so one caller cannot consume another’s allowance. (4) A burst rule applies as well: no more than burst calls in any 1 second. A call is only recorded when it is allowed; denied calls leave no trace.
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement Limiter(limit, window, burst) with allow(key, now) meeting all four stages at once.
Examples and constraints 4 examples
L = Limiter(limit=3, window=60, burst=9); [L.allow("a", t) for t in (0, 0, 0, 30, 61)][True, True, True, False, True]L = Limiter(3, 60, 9); three calls L.allow("a", 0), then L.allow("b", 0)TrueL = Limiter(10, 60, 2); [L.allow("a", t) for t in (0, 0, 0, 2)][True, True, False, True]L = Limiter(2, 60, 9); [L.allow("a", t) for t in (0, 0, 30, 31, 32, 61)][True, True, False, False, False, True]- Only allowed calls are recorded — a denied call must not count against the caller later.
- The window slides continuously; there is no fixed reset boundary.
- Each key has its own independent allowance.
- Both the window limit and the 1-second burst limit must pass for a call to be allowed.
Hints 4 ideas
- Stage 2 cannot be built from a counter. Keep the timestamps of allowed calls instead.
- Prune first, then check: drop everything older than
now - windowbefore you compare against the limit. - The burst rule is a second, shorter window over the same timestamps — count how many are within 1 second of now.
- Record the timestamp only on the allowed path, or a client that hammers a denied endpoint locks itself out forever.
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious stage-1 answer is a fixed-window counter: per key, store the current window number (now // window) and a count, reset the count when the number changes, and allow while count < limit. It is O(1) time per call and O(1) space per key, and it satisfies stage 1. It fails stage 2, because it counts calls since a reset boundary rather than in the last window seconds. With a limit of 3 per 60 seconds, calls at t = 59, 59, 59, 60, 60, 60 are all allowed, six calls in one second, while a sliding window allows three. No counter can fix that, which is why stage 2 forces the rewrite.
The approach
Keep, per key, the timestamps of allowed calls, and answer every check from them. The reference stores them in self.hits, a dict from key to list, so keys never share an allowance. Each allow(key, now) prunes first, with cutoff = now - self.window and ts[:] = [t for t in ts if t > cutoff], so the list holds exactly the calls in the half-open window (now − window, now].
Then come two independent checks, and both must pass: len(ts) >= self.limit denies on the window, and recent = sum(1 for t in ts if t > now - 1) denies when recent >= self.burst. Only then does it run ts.append(now), so a denied call leaves no trace. The invariant: after any call, ts holds at most limit timestamps, all allowed and all inside the window. Because now never decreases, ts stays sorted, so a deque pruned with popleft plus a binary search for the burst count avoids the scans.
Walk through an example
Replaying the reference’s own demo, Limiter(limit=3, window=60, burst=2) with key "a", gives [True, True, True, False, True]: the burst never trips, because t = 1 is a full second after the t = 0 calls.
| step | now | ts after pruning | calls in the last second | decision | ts after |
|---|---|---|---|---|---|
| 1 | 0 | [] | 0 | allow | [0] |
| 2 | 0 | [0] | 1, under the burst of 2 | allow | [0, 0] |
| 3 | 1 | [0, 0] | 0, since 0 > 1 − 1 is false | allow | [0, 0, 1] |
| 4 | 30 | [0, 0, 1] | not checked | deny: 3 ≥ limit | [0, 0, 1] |
| 5 | 61 | [], cutoff is 1 and every t ≤ 1 | 0 | allow | [61] |
Complexity
The reference costs O(limit) time per call, since pruning and the burst count scan a list that never exceeds limit entries, and O(keys × limit) space. A deque per key prunes in amortized O(1), and a binary search counts the burst in O(log limit). The fixed-window counter is O(1) time and space per key, but it answers the wrong question.
Edge cases to test
- The window is half-open:
Limiter(1, 60, 9)allows calls at t = 0 and t = 60, because a call exactlywindowseconds old failst > cutoffand is pruned; at t = 59.9 the second call would be denied. - The burst window is half-open too:
Limiter(10, 60, 1)with calls at t = 0, 0.5 and 1 returns[True, False, True]. - Denied calls leave no trace:
Limiter(2, 60, 9)with calls at t = 0, 0, 30, 31, 32, 61 returns[True, True, False, False, False, True]. Recording the denials would refuse t = 61 as well. - A limit or a burst of 0 denies everything:
Limiter(0, 60, 9).allow("a", 0)andLimiter(3, 60, 0).allow("a", 0)are bothFalse. - The clock must not run backwards: after an allowed call at t = 100, calls at t = 10 and t = 50 still see that future timestamp inside the window, so
Limiter(2, 60, 9)returns[True, True, False]. Use a monotonic clock.
Common mistakes
- Extending the fixed-window counter instead of switching to timestamps — a counter cannot answer "how many in the last 60 seconds" at an arbitrary moment.
- Recording the timestamp of a call that was denied, so a client hammering the endpoint permanently locks itself out.
- Pruning after the limit check rather than before it, which counts long-expired calls against the caller.
- Sharing one timestamp list across keys, so one noisy client throttles everyone.
Follow-ups an interviewer asks next
- Memory is O(limit) per key. What if the limit is 10,000 per hour?
- Approximate the sliding window with two counters: the current fixed window’s count plus the previous window’s count weighted by how much of it still overlaps,
prev · (1 − elapsed/window) + curr. That is O(1) memory per key and close to exact when traffic is steady. A token bucket is the other O(1) option: two numbers enforce an average rate and a burst size. - How do you rate-limit across 20 servers?
- Move the state to a shared store. In Redis, a sorted set per key can hold the timestamps: remove the expired ones, count, and add the new one only if allowed, all inside one Lua script so two servers cannot both take the last slot. Giving each server a local limit of limit/N is a cheaper approximation when the load balancer spreads traffic evenly.
- What should the caller see when a call is denied?
- An HTTP API returns 429 Too Many Requests, ideally with a
Retry-Afterheader, and the stored timestamps give its value exactly. For a window denial the oldest call expires atts[0] + window; for a burst denial, the oldest call inside the last second expires one second after it was made. That tells a well-behaved client when to try again. - Should the limiter count calls or cost?
- For LLM APIs, usually cost: providers limit tokens per minute as well as requests per minute. Store
(timestamp, tokens)pairs and compare the sum inside the window with the budget. The structure is the same, only the quantity summed changes, and one large request can now be denied on its own.
Where this shows up
Every deployment against a rate-limited customer or vendor API needs this, usually in a hurry, usually after an agent loop discovered the limit in production on the customer’s shared quota. The burst rule in stage 4 is the one people skip and then need: an endpoint that allows 100 calls per minute but no more than 10 per second is extremely common, and a single-window limiter passes the first constraint while violating the second.
Reference solution
Reference solution — Python
class Limiter:
def __init__(self, limit, window, burst):
self.limit = limit
self.window = window
self.burst = burst
self.hits = {} # key -> list of allowed timestamps
def allow(self, key, now):
ts = self.hits.setdefault(key, [])
# prune BEFORE checking, so expired calls never count against the caller
cutoff = now - self.window
ts[:] = [t for t in ts if t > cutoff]
if len(ts) >= self.limit:
return False
recent = sum(1 for t in ts if t > now - 1)
if recent >= self.burst:
return False
ts.append(now) # only allowed calls are recorded
return True
L = Limiter(limit=3, window=60, burst=2)
print([L.allow("a", t) for t in (0, 0, 1, 30, 61)])
# -> [True, True, True, False, True]Reference solution — TypeScript
class Limiter {
limit: number; window: number; burst: number;
hits: Map<string, number[]> = new Map();
constructor(limit: number, window: number, burst: number) {
this.limit = limit; this.window = window; this.burst = burst;
}
allow(key: string, now: number): boolean {
let ts = this.hits.get(key);
if (!ts) { ts = []; this.hits.set(key, ts); }
// prune BEFORE checking
const cutoff = now - this.window;
const kept = ts.filter(function (t) { return t > cutoff; });
ts.length = 0;
for (const t of kept) ts.push(t);
if (ts.length >= this.limit) return false;
const recent = ts.filter(function (t) { return t > now - 1; }).length;
if (recent >= this.burst) return false;
ts.push(now); // only allowed calls are recorded
return true;
}
}
const L = new Limiter(3, 60, 2);
console.log([0, 0, 1, 30, 61].map(function (t) { return L.allow("a", t); }));
// -> [true, true, true, false, true]Questions about Rate Limiter, Constraint by Constraint
What is the difference between a fixed window and a sliding window rate limiter?
A fixed window counts calls since the last reset boundary, such as the start of the current minute, and sets the count back to zero when the boundary passes. It is cheap, one counter per key, but a client can spend its whole allowance at the end of one window and again at the start of the next, doubling the rate across the boundary. A sliding window counts the calls in the last window seconds at every moment, so there is no boundary to exploit. With a limit of 3 per minute, a fixed window allows six calls across seconds 59 and 60; a sliding window allows three.
Why should a rate limiter not record denied calls?
Because the limit is on calls that were served, not on attempts. If denied calls are recorded, a client that keeps retrying while over the limit keeps pushing fresh timestamps into its own window, and the window never empties: it stays locked out for as long as it keeps trying. In this challenge, a limiter that records denials refuses the call at t = 61 in the denied-calls test, while the correct one allows it. Retry storms are a real problem, but they call for a separate limit or a block, not for corrupting this one.
Why prune old timestamps before checking the limit?
The limit is about the last window seconds as of now, so anything older must not count. If you check first and prune afterward, long-expired calls are still in the list when the decision is made, and a client that was quiet for an hour can be denied for calls it made an hour ago. Pruning first also keeps the list short, because after any call it never holds more than limit timestamps, so the checks that follow are cheap.
What is a burst limit and why add one?
A burst limit caps how many calls can arrive in a short span, here 1 second, on top of the longer window. A limit of 100 per minute on its own allows all 100 calls in the same instant, which can overload a backend that copes with the average rate but not with the spike. Many real APIs state both kinds of limit. The reference reuses the same timestamps for the burst check: it counts those newer than one second ago and denies when that count reaches the burst value. Both checks must pass before a call is allowed.
This idea in other formats
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- SoftmaxThe function at the end of every classifier and language model: turn raw scores (logits) into a probability distribution. Implement the numerically stable version so big logits do not overflow. Solve it in Python or TypeScript.Read →
- Cosine SimilarityThe measure behind every embedding search and RAG system: how aligned are two vectors, ignoring their length? Dot product over the product of magnitudes — 1 identical, 0 orthogonal, -1 opposite. Solve it in Python or TypeScript.Read →
- Top-K RetrievalThe core of the "R" in RAG: given a query embedding and a set of document embeddings, return the indices of the k most similar docs by cosine similarity, with a stable tie-break. Solve it in Python or TypeScript.Read →