Production incident
The limit that multiplied by three
A real incident: symptom first, cause hidden, tradeoff at the end.
The incident
What this teaches
- Per-instance limiter state means the real limit is limit times replica count
- Dividing the limit by replica count assumes even routing, which nothing enforces
- A fixed window permits double the limit across an aligned boundary
- The key must be the thing the limit is sold against, not whatever is easiest to read
- Key state needs an expiry longer than the window, or eviction hands out a free burst
Starter
import java.util.*;
/**
* Production: the limit that multiplied by three.
*
* A rate limiter that passed code review and works perfectly on one machine.
* Run this. Four checks fail. Fix the limiter so all four pass, without
* weakening the checks.
*/
public class Starter {
/* ── infrastructure you are given ───────────────────────────────────── */
static final class Ticker {
private long millis;
Ticker(long start) { this.millis = start; }
long now() { return millis; }
void advance(long ms) { millis += ms; }
}
/** A request as it reaches the service. */
record Request(String apiKey, String sourceIp) {}
/**
* Stands in for Redis: one object every instance can reach, whose
* operations are indivisible. Model your shared counter on top of this
* rather than adding fields to the instance.
*/
static final class SharedStore {
private final Map<String, double[]> state = new HashMap<>(); // key -> {tokens, lastMillis}
/** Atomic read-modify-write. In Redis this would be one Lua script. */
synchronized boolean consume(String key, double capacity, double perMs, long now) {
double[] s = state.computeIfAbsent(key, k -> new double[] { capacity, now });
s[0] = Math.min(capacity, s[0] + (now - s[1]) * perMs);
s[1] = now;
if (s[0] >= 1.0) { s[0] -= 1.0; return true; }
return false;
}
/** Drops keys untouched for longer than maxIdleMs. Redis does this with PEXPIRE. */
synchronized void expire(long now, long maxIdleMs) {
state.entrySet().removeIf(e -> now - (long) e.getValue()[1] > maxIdleMs);
}
synchronized int trackedKeys() { return state.size(); }
}
/* ── the limiter under review ───────────────────────────────────────── */
static final int LIMIT_PER_MINUTE = 100;
static final long WINDOW_MS = 60_000;
static final class RateLimiter {
private final Ticker clock;
private final Map<String, int[]> counters = new HashMap<>(); // key -> {window, count}
RateLimiter(Ticker clock, SharedStore store) {
this.clock = clock; // store is available and unused
}
boolean allow(Request request) {
String key = request.sourceIp();
long window = Math.floorDiv(clock.now(), WINDOW_MS);
int[] c = counters.computeIfAbsent(key, k -> new int[] { (int) window, 0 });
if (c[0] != (int) window) { c[0] = (int) window; c[1] = 0; }
if (c[1] < LIMIT_PER_MINUTE) { c[1]++; return true; }
return false;
}
int trackedKeys() { return counters.size(); }
}
/** One service instance behind the load balancer. */
static final class Instance {
final RateLimiter limiter;
Instance(Ticker clock, SharedStore store) { this.limiter = new RateLimiter(clock, store); }
}
static List<Instance> cluster(int n, Ticker clock, SharedStore store) {
List<Instance> out = new ArrayList<>();
for (int i = 0; i < n; i++) out.add(new Instance(clock, store));
return out;
}
/* ── checks ─────────────────────────────────────────────────────────── */
public static void main(String[] args) {
List<String> failures = new ArrayList<>();
final int CEILING = LIMIT_PER_MINUTE + 10; // burst tolerance
// 1. The documented limit is global, not per instance.
{
var clock = new Ticker(0);
var store = new SharedStore();
var nodes = cluster(3, clock, store);
var req = new Request("key-a", "10.0.0.1");
int allowed = 0;
for (int i = 0; i < 300; i++) if (nodes.get(i % 3).limiter.allow(req)) allowed++;
if (allowed > CEILING)
failures.add("1. three instances allowed " + allowed + " for a limit of "
+ LIMIT_PER_MINUTE + " — the limit scales with the replica count");
}
// 2. The limit must hold across a window boundary, not just inside one.
{
var clock = new Ticker(0);
var store = new SharedStore();
var nodes = cluster(3, clock, store);
var req = new Request("key-b", "10.0.0.2");
clock.advance(59_000);
int allowed = 0;
for (int i = 0; i < 100; i++) if (nodes.get(i % 3).limiter.allow(req)) allowed++;
clock.advance(1_000);
for (int i = 0; i < 100; i++) if (nodes.get(i % 3).limiter.allow(req)) allowed++;
if (allowed > CEILING)
failures.add("2. " + allowed + " requests allowed inside one second across a "
+ "window boundary, for a limit of " + LIMIT_PER_MINUTE + "/min");
}
// 3. Two customers must not share a quota because they share an IP.
// One instance, so this measures the choice of key and nothing else.
{
var clock = new Ticker(0);
var store = new SharedStore();
var nodes = cluster(1, clock, store);
var one = new Request("key-c", "203.0.113.9"); // same NAT gateway
var two = new Request("key-d", "203.0.113.9");
int allowedOne = 0, allowedTwo = 0;
for (int i = 0; i < 100; i++) {
if (nodes.get(0).limiter.allow(one)) allowedOne++;
if (nodes.get(0).limiter.allow(two)) allowedTwo++;
}
if (allowedOne < 90 || allowedTwo < 90)
failures.add("3. two API keys behind one IP got " + allowedOne + " and "
+ allowedTwo + " of 100 — they are sharing a quota");
}
// 4. Key state must not grow without bound.
{
var clock = new Ticker(0);
var store = new SharedStore();
var nodes = cluster(1, clock, store);
for (int i = 0; i < 50_000; i++)
nodes.get(0).limiter.allow(new Request("key-" + i, "10.1." + (i / 256) + "." + (i % 256)));
clock.advance(WINDOW_MS * 10); // ten windows of silence
store.expire(clock.now(), WINDOW_MS * 2);
int tracked = nodes.get(0).limiter.trackedKeys() + store.trackedKeys();
if (tracked > 1_000)
failures.add("4. still holding state for " + tracked + " keys ten windows after "
+ "they went idle — this grows with every key ever seen");
}
/* ── report ─────────────────────────────────────────────────────── */
if (failures.isEmpty()) {
System.out.println("PASS");
} else {
failures.forEach(f -> System.out.println(" " + f));
System.out.println("FAIL");
}
}
}Run it locally:
cd exercises/java/traffic-management/rate-limiting/03-production
javac Starter.java -d /tmp/out && java -cp /tmp/out StarterHints
Hint 1
Check 1 fails at exactly three times the limit. Try imagining six instances before you reach for a fix that mentions the number three.
Hint 2
The SharedStore is already there and unused. Its consume() is one indivisible read-modify-write — that is the property that matters, and the reason it cannot be split into a read then a write.
Hint 3
For the boundary, pick an algorithm with no boundary rather than a smaller window. A smaller window moves the problem, it does not remove it.
Hint 4
Check 3 runs on ONE instance on purpose, so it measures the choice of key and nothing else. Ask what the limit is sold against.
Hint 5
For check 4, ask what happens when a key is evicted and then used again. That answer decides whether the expiry can equal the window.
Done when
- Three instances together allow no more than the documented limit
- No more than the limit is allowed across a window boundary
- Two API keys sharing a source IP each receive their full quota
- Idle key state is released, and the expiry is longer than the window
- A comment says which of the four defects would have been visible in staging
Solution
Show the solution — try it yourself first
import java.util.*;
/**
* Solution: the limit that multiplied by three.
*
* Four defects, and only one of them is about the algorithm. The other three
* are about where the state lives, what it is keyed by, and when it is thrown
* away — which is the pattern for rate limiters generally. None of the four
* produced an error in staging, because staging runs one instance with a
* handful of keys.
*/
public class Solution {
/* ── infrastructure (unchanged) ─────────────────────────────────────── */
static final class Ticker {
private long millis;
Ticker(long start) { this.millis = start; }
long now() { return millis; }
void advance(long ms) { millis += ms; }
}
record Request(String apiKey, String sourceIp) {}
static final class SharedStore {
private final Map<String, double[]> state = new HashMap<>();
synchronized boolean consume(String key, double capacity, double perMs, long now) {
double[] s = state.computeIfAbsent(key, k -> new double[] { capacity, now });
s[0] = Math.min(capacity, s[0] + (now - s[1]) * perMs);
s[1] = now;
if (s[0] >= 1.0) { s[0] -= 1.0; return true; }
return false;
}
synchronized void expire(long now, long maxIdleMs) {
state.entrySet().removeIf(e -> now - (long) e.getValue()[1] > maxIdleMs);
}
synchronized int trackedKeys() { return state.size(); }
}
/* ── the limiter, fixed ─────────────────────────────────────────────── */
static final int LIMIT_PER_MINUTE = 100;
static final long WINDOW_MS = 60_000;
static final class RateLimiter {
private final Ticker clock;
private final SharedStore store;
/*
* Defect 1. The counter lived in a HashMap on the instance, so each of
* the three replicas enforced the full limit independently and the
* documented 100/min was really 300/min. Worse, it loosened on every
* scale-out — the protection weakened exactly when load was highest.
*
* Dividing the limit by the replica count is the tempting fix and is
* wrong: it holds only while routing is even, and it has to be
* re-tuned on every scale event. The state has to be shared.
*/
RateLimiter(Ticker clock, SharedStore store) {
this.clock = clock;
this.store = store;
}
/*
* Defect 2. A fixed window resets on an aligned boundary, so a client
* could spend the whole limit at 59.999s and the whole limit again at
* 60.000s. Clock-aligned traffic — cron, rounded retry backoffs,
* dashboards — lands exactly there, so it was not a rare case.
*
* A token bucket has no boundary to exploit: the refill is continuous,
* computed from elapsed time, and capacity bounds the burst explicitly.
*
* Defect 3. The key was the source IP, so every customer behind one NAT
* gateway or corporate proxy shared a single quota. The limit is sold
* per API key, so the API key is what it must be counted against.
* (An IP limit is still useful as a separate, coarser layer against
* volumetric abuse — but it is not this limit.)
*/
boolean allow(Request request) {
double capacity = LIMIT_PER_MINUTE;
double perMs = LIMIT_PER_MINUTE / (double) WINDOW_MS;
return store.consume(request.apiKey(), capacity, perMs, clock.now());
}
/*
* Defect 4. The per-instance map was never pruned, so it retained an
* entry for every key the process had ever seen — attacker-controlled
* growth on a public endpoint. State now lives in the shared store,
* which expires idle keys; nothing is retained here.
*
* The expiry has to be LONGER than the window. An evicted key comes
* back with a full bucket, so expiring at exactly the window boundary
* would hand out a free burst to anyone who paused briefly.
*/
int trackedKeys() { return 0; }
}
static final class Instance {
final RateLimiter limiter;
Instance(Ticker clock, SharedStore store) { this.limiter = new RateLimiter(clock, store); }
}
static List<Instance> cluster(int n, Ticker clock, SharedStore store) {
List<Instance> out = new ArrayList<>();
for (int i = 0; i < n; i++) out.add(new Instance(clock, store));
return out;
}
/* ── checks (unchanged from the starter) ────────────────────────────── */
public static void main(String[] args) {
List<String> failures = new ArrayList<>();
final int CEILING = LIMIT_PER_MINUTE + 10;
{
var clock = new Ticker(0);
var store = new SharedStore();
var nodes = cluster(3, clock, store);
var req = new Request("key-a", "10.0.0.1");
int allowed = 0;
for (int i = 0; i < 300; i++) if (nodes.get(i % 3).limiter.allow(req)) allowed++;
if (allowed > CEILING)
failures.add("1. three instances allowed " + allowed + " for a limit of "
+ LIMIT_PER_MINUTE + " — the limit scales with the replica count");
}
{
var clock = new Ticker(0);
var store = new SharedStore();
var nodes = cluster(3, clock, store);
var req = new Request("key-b", "10.0.0.2");
clock.advance(59_000);
int allowed = 0;
for (int i = 0; i < 100; i++) if (nodes.get(i % 3).limiter.allow(req)) allowed++;
clock.advance(1_000);
for (int i = 0; i < 100; i++) if (nodes.get(i % 3).limiter.allow(req)) allowed++;
if (allowed > CEILING)
failures.add("2. " + allowed + " requests allowed inside one second across a "
+ "window boundary, for a limit of " + LIMIT_PER_MINUTE + "/min");
}
{
var clock = new Ticker(0);
var store = new SharedStore();
var nodes = cluster(1, clock, store);
var one = new Request("key-c", "203.0.113.9");
var two = new Request("key-d", "203.0.113.9");
int allowedOne = 0, allowedTwo = 0;
for (int i = 0; i < 100; i++) {
if (nodes.get(0).limiter.allow(one)) allowedOne++;
if (nodes.get(0).limiter.allow(two)) allowedTwo++;
}
if (allowedOne < 90 || allowedTwo < 90)
failures.add("3. two API keys behind one IP got " + allowedOne + " and "
+ allowedTwo + " of 100 — they are sharing a quota");
}
{
var clock = new Ticker(0);
var store = new SharedStore();
var nodes = cluster(1, clock, store);
for (int i = 0; i < 50_000; i++)
nodes.get(0).limiter.allow(new Request("key-" + i, "10.1." + (i / 256) + "." + (i % 256)));
clock.advance(WINDOW_MS * 10);
store.expire(clock.now(), WINDOW_MS * 2);
int tracked = nodes.get(0).limiter.trackedKeys() + store.trackedKeys();
if (tracked > 1_000)
failures.add("4. still holding state for " + tracked + " keys ten windows after "
+ "they went idle — this grows with every key ever seen");
}
if (failures.isEmpty()) {
System.out.println("PASS");
} else {
failures.forEach(f -> System.out.println(" " + f));
System.out.println("FAIL");
}
}
}