Rate Limiting & Fairness
Token buckets, throttles, and protecting the matching engine.
Production · Gateway, Protocols, Risk & Resilience
Why this week
Your engine processes commands on one thread, in order. Its capacity is finite, a few million commands per second if you've done the work, and every command waiting in its inbound queue adds latency for every participant behind it. A single client with a bug in its quoting loop, a strategy fighting another strategy, or a deliberate flood can fill that queue. Pre-trade risk (week 15) stops dangerous orders. Throttles stop too many orders: they bound each session's and each account's share of the engine, so one participant's behaviour can't become everyone's latency.
Throttles sit on the hot path of every message, so they inherit every constraint from week 15 (deterministic, allocation-free, cache-resident) plus one more: they're about time. That makes them the place where hidden clock reads, floating point and integer rounding quietly break correctness. You'll build the two standard algorithms with time injected as a plain integer, then wire per-session and per-account throttles into the gateway.
Day 1 — Why venues throttle
Capacity, fairness and incentives
Three reasons, three kinds of limit
Short and precise. It defines token-bucket metering with a committed rate and two burst sizes, written as exact update rules. Read it for the shape of a rigorous rate-limiter spec: parameters, state, and what happens on each arrival. (RFC 2698 is the two-rate variant.)
The EU rule requiring trading venues to cap the ratio of unexecuted orders to transactions per member. Skim how the ratio is defined (which messages count, over what period). It's the regulatory version of the incentive-style limits below.
Venues limit message flow for three different reasons, and each one leads to a different mechanism:
- Capacity protection: a hard cap on messages per second per session, enforced at the gateway. Excess messages are rejected immediately, so the engine never sees them.
- Fairness: no participant's burst should delay everyone else's orders. That takes per-session limits and a fair way of serving sessions when the engine is busy (Day 5).
- Incentives: order-to-trade ratio programs penalize flow that doesn't trade. CME's Messaging Efficiency Program and MiFID II RTS 9 are the well-known examples. These are measured over hours or days and enforced with fees or reviews, not per message.
This week is about the first two. They act in nanoseconds on every message.
When a session exceeds its throttle, should the gateway reject the excess messages or queue them and send them on when the budget refills?
Day 2 — The token bucket
Rate plus burst, in integers
Drill 1: a bucket with time as an argument
A token bucket has two parameters: a rate r (tokens per second) and a capacity b (the burst). It starts full. Each message takes a token if one is available and is rejected otherwise. Tokens refill continuously at rate r, never beyond b. Over any interval of length T, at most b + r·T messages pass. That's the whole guarantee, and it's why venues describe throttles as "N per second with a burst of M".
The implementation is "refill for elapsed time, then try to take", and nearly everyone gets it wrong the first time in one of four ways:
- Losing fractional tokens. At 1,000 tokens/s, a call 100 µs after the last one earns 0.1 tokens. If you compute
elapsed_ns * rate / 1e9in integers and then setlast = now, that 0.1 is rounded to 0 and thrown away. A client calling every 100 µs never gets a token back. Fix it by keeping tokens in scaled units (1 token = 10⁹ units, soelapsed_ns × rateis exact), or by advancinglastonly by the time you actually converted into whole tokens. - Overflow after idle.
elapsed_ns × ratewith an hour of idle at a million tokens per second is 3.6 × 10¹⁸, close to the u64 limit, and a day overflows. Useu128for the product, or cap elapsed time at the time-to-full before multiplying. - Time going backwards. An injected clock can step back: a different core's timestamp, a test, or a replayed log.
now − lastthen underflows. Treat any time at or before the latest seen as "no time passed", and never rewindlast. - Partial takes. A request for n tokens with fewer than n available must take none.
Token bucket with an injected clock
pub struct TokenBucket {
capacity: u64,
rate_per_sec: u64,
tokens: u64,
last_ns: u64,
// These fields are a starting point, and they hide a precision bug
// (see Day 2). Change them as you see fit.
}
impl TokenBucket {
/// Starts full.
pub fn new(capacity: u64, rate_per_sec: u64, now_ns: u64) -> Self {
TokenBucket { capacity, rate_per_sec, tokens: capacity, last_ns: now_ns }
}
/// Refill for the time since the latest time seen, then take `n` tokens
/// if all n are available (otherwise take none and return false).
/// Never panics or overflows. A now_ns earlier than one already seen adds nothing.
pub fn try_acquire(&mut self, now_ns: u64, n: u64) -> bool {
todo!()
}
/// Whole tokens available at now_ns (refills, does not consume).
pub fn available(&mut self, now_ns: u64) -> u64 {
todo!()
}
}A session has a token bucket of 100 messages/s with capacity 20, idle for a minute. What's the most it can send in the next second, and in the next 10 ms?
Day 3 — GCRA and where time comes from
One integer of state, and a clock you pass in
The same limiter, rewritten, and why it must be deterministic
The Generic Cell Rate Algorithm (GCRA), from ATM networking (ITU-T I.371), is a token bucket rewritten as a single timestamp. Let T = 1/r be the emission interval and τ = (b − 1)·T the burst tolerance. The state is the theoretical arrival time (TAT): when the next message would be due if the client sent at exactly rate r.
// GCRA: equivalent to a token bucket with rate 1/t_ns and capacity b.
fn try_acquire(tat: &mut u64, now: u64, t_ns: u64, tau_ns: u64) -> bool {
let tat_now = (*tat).max(now);
if tat_now - now > tau_ns {
return false; // arrived too early: more than the burst ahead of schedule
}
*tat = tat_now + t_ns; // conforming: push the schedule forward one interval
true
}It has one u64 of state, no refill loop and no multiplication on the hot path, and it handles long idle naturally because max(tat, now) forgets old history. The catch is that T must be an integer number of nanoseconds. For rates that don't divide 10⁹, the rounding error in the rate is tiny but nonzero. Document it, or keep T in finer units.
Where now comes from matters as much as the algorithm:
- Don't read the clock inside the check.
Instant::now()costs on the order of 20 ns on typical Linux x86 hardware, half the week's 40 ns budget. Read it once per message (or once per batch of messages from one socket read) at the gateway and pass it down. - Make decisions reproducible. If throttling happens at the gateway, before sequencing, the journal holds only accepted commands, and replaying the journal reproduces the engine exactly. Log rejects with their timestamps if you need to audit them. If a throttle runs inside the engine, drive it from the sequencer's timestamp recorded in the journal, never from a fresh clock read, or replay will disagree with production.
Give two reasons the throttle check takes `now_ns` as a parameter instead of reading the clock itself.
Day 4 — Windows
Counting messages in the last second
Drill 2: a bucketed sliding window
Many venues specify limits as "at most N messages in any one second". There are three ways to count:
- Fixed window: one counter per calendar second, reset at the boundary. It's simple and badly wrong at the edges. N messages at 0.999 s and N more at 1.000 s are 2N within a millisecond, and the limit is violated by every useful definition.
- Sliding log: keep the timestamps of the last N accepted messages in a ring. A new message is allowed if fewer than N timestamps fall within the window, which means the oldest of the N is at least W old. It's exact for every window and O(1) per check, but costs N timestamps of memory per session, which matters at N = 10,000 and thousands of sessions.
- Bucketed sliding window: split W into K buckets of W/K and keep a counter per bucket in a ring. The count is the sum over the current bucket and the K − 1 before it. Memory is K counters. Any interval shorter than W − W/K holds at most N messages. A burst of 2N is still possible, but only spread over nearly a full window, not packed into a microsecond at a boundary. More buckets give a tighter guarantee and cost more counters.
Some systems interpolate instead: count the current fixed window plus the previous window's count weighted by how much of it still overlaps. That's two counters and approximately right, which is fine for abuse protection and not for a venue rule that must be enforced exactly.
Below, one bursty client hits three limiters with the same long-run rate: a fixed window, the bucketed sliding window and Day 2's token bucket. Switch to Edge attack and watch the fixed window pass almost 2N within 100 ms while the other two hold, then set the bucket's refill to Naive and watch Day 2's rounding bug throw its credit away.
Sliding-window counter
pub struct SlidingWindow {
limit: u32,
bucket_ns: u64,
counts: Vec<u32>, // ring of K counters; slot = bucket_id % K
ids: Vec<u64>, // absolute bucket id each slot currently counts (u64::MAX = none)
last_bucket: u64, // highest bucket id seen; time never rewinds
}
impl SlidingWindow {
/// The window is `buckets` buckets of window_ns / buckets each.
pub fn new(limit: u32, window_ns: u64, buckets: usize) -> Self {
SlidingWindow {
limit,
bucket_ns: window_ns / buckets as u64,
counts: vec![0; buckets],
ids: vec![u64::MAX; buckets],
last_bucket: 0,
}
}
/// Accepted messages in the current bucket plus the K-1 buckets before it.
/// Current bucket = max(now_ns / bucket_ns, last bucket seen).
pub fn count(&mut self, now_ns: u64) -> u32 {
todo!()
}
/// If count(now) < limit, count this message in the current bucket and
/// return true. Rejected messages are not counted.
pub fn try_acquire(&mut self, now_ns: u64) -> bool {
todo!()
}
}
A limit of 100 per second uses 10 buckets of 100 ms. What's the largest number of messages that can be accepted within some 1-second interval, and how?
Day 5 — Fairness across sessions
Limits per level, service per session
Hierarchical buckets and fair queuing
The O(1) fair-queuing algorithm: per-flow queues served round-robin, with a deficit counter so flows with large items aren't favoured. Map flows to sessions and packets to commands. Read for the invariant that makes it fair over time, not per round.
Per-session throttles bound each session, but two fairness problems remain:
- Hierarchy. A firm can open twenty sessions to get twenty budgets. So limits nest: session within account within firm, and a message must pass every level. The check must be all-or-nothing. Check all the buckets, then consume from all of them. Consume from the session bucket first and then fail on the account bucket, and the session has lost a token for a message that was never sent. Under sustained load that leak looks like an unexplained throttle.
- Contention at the engine. Even when every session is within its limits, the sum can exceed what the engine can process right now. With one shared FIFO, whoever sends the most in a burst occupies the queue and everyone else waits behind them. Per-session queues served round-robin (or with deficit round robin, when commands have different costs) give each active session a fair share of engine time during contention.
Be careful what you call fair. The order in which the sequencer takes commands is time priority in the book. Round-robin across sessions means a session that sent first in wall-clock time may be sequenced second. Many venues keep strict arrival order and rely on throttles alone. Others use fair queuing, explicitly and in their rulebooks. Either way, it's a market-structure decision, so document it.
A message must pass a session bucket and an account bucket. What goes wrong if the code is `session.try_acquire(now) && account.try_acquire(now)`?
Decide your exchange's policy: strict arrival order into the sequencer, or round-robin across sessions under contention? Write the paragraph you'd put in the rulebook, and the scenario (two sessions, one bursting) a client could use to verify it.
Day 6 — Throttles in the gateway
What counts, what happens, what the client sees
The policy around the algorithm
The algorithm is the easy part. The policy around it decides whether the throttle helps or hurts:
- What counts. New orders and modifies, certainly. Heartbeats, usually not. Cancels are the contentious case: throttling them can trap a client in risk it's trying to reduce during exactly the fast market that triggered the burst. Common answers are a separate, larger cancel budget, or exempting cancels and relying on the engine's capacity margin.
- Where it runs. In the reader task, after framing and session checks and before the command enters the engine queue. Rejected messages cost the gateway a little CPU and the engine nothing.
- What the client sees. A reject with an explicit "throttled" reason and, ideally, when the budget refills. Under a sustained flood, the rejects themselves become load, so escalate after a configured period: throttle the rejects, then disconnect.
- Configuration. Limits per session class (market maker vs. regular), loaded and validated at startup (week 22) and changeable by operations without a restart.
Should cancels count against the same throttle as new orders? Write down the failure scenario for each choice (counted vs. exempt), then design the rule you'd ship, including what stops a client from abusing an exemption.
Capstone
Add throttling to crates/risk (the throttle module), then call it from the week 13 gateway's order path:
- A token bucket with
now_nsinjected. The repo'sTokenBucketuses the GCRA arithmetic in token-bucket form: it stores credit in nanoseconds, one token costsns_per_tokenof credit, elapsed time adds credit one for one, and credit saturates atburst × ns_per_token. No fractional token is lost, refill is a subtract, an add and aminwith no division, a timestamp earlier than one already seen counts as "no time passed", and taking is branch-free. Configure it withBucketConfig { burst, ns_per_token }.BucketConfig::per_second(rate, burst)does the one division at construction and rounds the interval up, so the effective rate never exceedsrate. Eachtry_acquire(now_ns)takes one token. - A sliding window,
SlidingWindow::new(limit, window_ns, now_ns). The repo uses the two-counter interpolating version from Day 4 rather than the drill's K buckets: the current fixed window plus the previous one weighted by its overlap, compared multiplied out inu128, so there's no division per check. It's O(1) memory, never admits more thanlimitwithin one fixed window, and assumes the previous window's messages were spread evenly. That's the abuse-protection trade-off Day 4 describes, not an exact venue rule. - Hierarchical limits:
Throttle::new(per_session, per_account, clock)gives everySessionIdand every account its own bucket, created full on first use.check_at(session, account, now_ns)refills both, checks both and then takes from both or neither, returningErr(RiskReason::RateLimit)on a reject. There's no firm level.remove_sessionon logout drops the session's bucket and keeps the account's, so reconnecting doesn't reset an account's budget. - Storage: account buckets live in a dense
Vec, and each session's bucket caches the index of its account's bucket. The hot path is oneIdMaplookup (the integer-hashed map from week 15) on the session id, plus a second only when a session switches accounts. It isn't a SipHashHashMap, but it isn't lookup-free either. - The clock: a
Clocktrait withMonotonicClock(fromInstant) andFakeClock(clones share one time, for tests).checkreads the injected clock once, andcheck_attakes the time as an argument and reads nothing. The crate's wiring example timestamps each inbound NewOrder once on receipt and callsthrottle.check_at(session, order.account, rx_ns)before week 15'srisk.check. Escalating to disconnect on sustained violation, and per-session reject counters for week 19, are gateway policy to add on top. - Tests (
w17_*incrates/risk): burst then one token per interval, exact refill under irregular calls, capping after a long idle with time that never runs backwards, session isolation under a shared account cap, a rejection charging neither bucket, the sliding window's weighting, andw17_randomized_limiters_match_reference_models, which checks every decision against a bucket that adds credit one nanosecond at a time and a window recomputed from the full log of admitted times.
Pitfalls:
- Hidden clock reads. One
Instant::now()inside the check, or a log line with a timestamp, and you've spent half the budget. Grep the hot path for them. - Division on the hot path. Turning tokens into time or time into tokens with a divide costs tens of cycles on many x86 cores (recent Intel and AMD parts are much faster), and a
u128divide compiles to a library call. Restructure so the check multiplies and compares, as the scaled-units design does, or use GCRA, which only adds and compares. - Shared buckets across threads. If two gateway threads can serve the same account, its bucket is now shared mutable state. Either route all of an account's sessions to one thread, or give each thread a share of the account budget. An atomic bucket with compare-and-swap works but costs far more than 40 ns under contention.
How to think about the 40 ns p99 target: w17.throttle_check times Throttle::check_at (session and account bucket) over 4,096 sessions of 1,024 accounts in random order, one check every 20 ns of simulated time, with each session sending slightly faster than its rate so a few percent are rejected. A correct token-bucket check is a load of one cache line, a subtract, an add, a min, a compare and a store, a few nanoseconds when the bucket is in L1. The cheap integer hash lookup costs a couple more. The target is about keeping it that way. A clock read alone would cost half the budget, a SipHash lookup about as much, and a cache miss to DRAM twice the whole budget. If your p50 is 3 ns and your p99 is 60 ns, look for the cache miss: buckets spread across memory, or evicted by the engine's working set between messages from the same session.
crates/riskThrottles in front of the engine
Per-session and per-account token buckets on an injected monotonic clock, integrated into the gateway, with no clock reads, divisions or allocation on the check.
cargo xtask test --week 17 # tests named w17_*
cargo xtask bench --week 17 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 17 tests pass w17_*
- Token bucket check w17.throttle_checktarget p99 ≤ 40 ns
Feeds into
- Week 24 runs gateway → throttle → risk → engine under load. Your throttles define how much of that load each simulated client is allowed to put on the engine, and the per-session reject counters are part of the run's report.