TRENDING
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Two earthworms lying together on wet brown leaf litter, photographed close up
October 28, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
Two opened leaves of the Verona palimpsest, with an older, darker layer of writing still showing through the newer script written over it
October 28, 2026
Cloudflare Says Its GLM-5.3 Model Returned Other Customers’ Data in 155 Responses, and a Public SGLang Bug Report Had Described the Same Path Since July
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 28, 2026
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 269 Posts
News 272 Posts
Learning Hub 241 Posts
Home/Learning Hub/How to Harden a FastAPI Rate Limiter Against Spoofed Client IPs, IPv6 Address Rotation, and Wrong Retry-After Headers
Learning Hub

How to Harden a FastAPI Rate Limiter Against Spoofed Client IPs, IPv6 Address Rotation, and Wrong Retry-After Headers

Build a token bucket and a one-number GCRA limiter for FastAPI, then fix the three ways limiters fail in production (a forged X-Forwarded-For header, IPv6 address rotation and a rounded-down...

October 23, 2026 36 Min Read
41

A rate limiter looks like a solved problem as soon as you have a token bucket. The arithmetic is about twenty lines of Python, and our earlier tutorial on building rate limiters walks through the common algorithms from scratch. What goes wrong in production is everything around the arithmetic: the limiter counts the wrong thing as a client, it tells refused clients to come back at the wrong moment, or it quietly stops being correct after a refactor and nobody notices.

Table Of Content

  • What you will build, and the words you need first
  • Prerequisites
  • Step 1: Create the project and a clock you can move by hand
  • Step 2: A token bucket in twenty lines
  • Check before moving on
  • Step 3: GCRA, the same bucket with one number per client
  • Step 4: What a client costs in memory, and how to forget idle ones
  • Step 5: Tell the client how long to wait
  • Step 6: Put the limiter in front of FastAPI
  • Step 7: Who is the client? X-Forwarded-For and uvicorn’s trust list
  • Step 8: One IPv6 /64 is one client
  • Step 9: Prove it with tests that can fail
  • Property 1: the wait is honest and tight
  • Properties 2 and 3: remaining is exact, and the bucket keeps its promise
  • Property 4: GCRA and the token bucket are the same limiter
  • Properties 5 to 7: purging is invisible, and clients do not affect each other
  • The application tests
  • Step 10: Break it on purpose
  • How to confirm the whole thing works
  • Common mistakes, with the numbers from this tutorial
  • Where to go next

In this tutorial you will harden a small FastAPI service against those three failures, and you will measure every fix instead of taking it on trust. You do not need the earlier tutorial, because Step 2 recaps the one algorithm we use. If you also want the fixed-window and sliding-window variants, read that one first.

What you will build, and the words you need first

Rate limiting means giving each client a budget of requests and refusing the ones that go over it. A refusal uses the HTTP status code 429 (Too Many Requests, defined in RFC 6585). The hard word in that sentence is client. Your server does not know who is calling. It has the address of the machine at the other end of the connection and some headers the caller wrote, and the limiter needs a key, a short string such as an IP address, so that it can keep one budget per key. RFC 6585 says as much: “this specification does not define how the origin server identifies the user, nor how it counts requests”. Choosing the key is your job, and most of the damage happens when it is chosen badly.

By the end you will have:

  • a token bucket, and an equivalent version that keeps one number per client (it is called GCRA);
  • a pure ASGI middleware that puts the limiter in front of FastAPI, with a background task that forgets idle clients;
  • a client key that a forged X-Forwarded-For header cannot talk its way around, and that treats an IPv6 /64 as one client, checked against six proxy setups on a real uvicorn server;
  • a Retry-After header that a well-behaved client can obey;
  • 29 tests, including property-based tests that run on a fake clock with exact arithmetic, and ten deliberate bugs that those tests catch.

This is not a defence against a flood. A limiter inside your application still spends CPU on every request it refuses, so volumetric attacks need limits at the edge (a CDN, a web application firewall or your reverse proxy). And the counters here live in the memory of one process, which the last section comes back to.

Prerequisites

  • Python 3.11 or newer. Every command below ran on Python 3.13.14 on Windows 11. I read Requires-Python in each pinned package’s metadata: Starlette 1.9.0, httpx2 2.16.0 and Hypothesis 6.170.8 need 3.11, the others 3.10.
  • A bash shell and curl. On Windows I used Git Bash. Linux and macOS need nothing extra.
  • Packages: FastAPI 0.161.1 (which brings Starlette 1.9.0), Uvicorn 0.54.0, httpx2 2.16.0 (Starlette’s test client refuses to start without it), requests 2.34.2, pytest 9.1.1 and Hypothesis 6.170.8.
  • Background: you can read a Python class and a dictionary, and you know what an HTTP request, a header and a status code are. No Redis, no Docker and no cloud account are needed.

Step 1: Create the project and a clock you can move by hand

Start with an empty folder and a virtual environment (a private set of installed packages for this project):

$ mkdir ratelimit-lab
$ cd ratelimit-lab
$ python -m venv .venv
$ source .venv/bin/activate
$ python -m pip install fastapi==0.161.1 uvicorn==0.54.0 httpx2==2.16.0 requests==2.34.2 pytest==9.1.1 hypothesis==6.170.8

On Windows in Git Bash, the activation line is source .venv/Scripts/activate. All the files below go in this one folder. The first line of every file is a comment with its name, so you can tell which file a code block belongs to, and a block whose comment says (continued) goes at the end of the file.

A rate limiter is all about time, and code that reads the clock is hard to test: you would have to sleep through real seconds, and the results would change from run to run. So every limiter in this tutorial takes a clock argument, a function that returns the current time in seconds. In production it is time.monotonic, which the Python documentation describes as “a clock that cannot go backwards”, adding that “The clock is not affected by system clock updates”. That matters here, because the wall clock time.time() can jump when a machine’s clock is corrected: a jump forward would refill every bucket at once, and a jump backward would make the elapsed time negative. In tests the clock is a FakeClock that only moves when you tell it to. Create labkit.py:

# labkit.py
class FakeClock:
    """A clock you move by hand, so every experiment is exact and repeatable."""

    def __init__(self, start=0):
        self.now = start

    def __call__(self):
        return self.now

    def set(self, moment):
        self.now = moment

    def advance(self, seconds):
        self.now += seconds

FakeClock is callable, so it can stand in for time.monotonic. The method set jumps to a moment and advance moves forward. Check that it works:

$ python -c "from labkit import FakeClock; c = FakeClock(); c.advance(2.5); print(c())"
2.5

Step 2: A token bucket in twenty lines

Picture a bucket that holds at most capacity tokens. Tokens drip in at a steady rate, and every request must take one token out. If the bucket has a token, the request goes through; if it is empty, the request is refused. Wikipedia’s description of the algorithm is short: when a request arrives, “the bucket is inspected to see if it contains sufficient tokens at that time”. An idle client slowly refills its bucket, so it earns the right to a short burst, but it can never go faster than the refill rate for long.

A real implementation does not need a timer that adds tokens now and then. It only needs to know how long it has been since it last looked: when a request arrives, add elapsed × rate tokens (never more than the capacity) and then decide. That is lazy refill, and it means an idle client costs nothing. Create limiters.py:

# limiters.py
"""Two rate limiters with one interface: limiter.check(key) -> Decision."""
import math
import time
from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class Decision:
    allowed: bool
    retry_after: float  # seconds until a retry could succeed (0.0 when allowed)
    remaining: int      # further requests that would be allowed right now


class Limiter:
    """Shared plumbing: a clock you can replace in tests and one state entry per key."""

    def __init__(self, clock=time.monotonic):
        self.clock = clock
        self.state = {}

    def purge(self):
        """Forget keys whose state equals a brand new key's state. Returns how many were dropped."""
        now = self.clock()
        idle = [key for key, saved in self.state.items() if self._idle(saved, now)]
        for key in idle:
            del self.state[key]
        return len(idle)


class TokenBucket(Limiter):
    """A bucket holds up to `capacity` tokens and refills at `rate` tokens per second; a request takes one."""

    def __init__(self, rate, capacity, clock=time.monotonic):
        super().__init__(clock)
        self.rate, self.capacity = rate, capacity

    def check(self, key):
        now = self.clock()
        tokens, stamp = self.state.get(key, (self.capacity, now))
        tokens = min(self.capacity, tokens + (now - stamp) * self.rate)
        if tokens >= 1:
            self.state[key] = (tokens - 1, now)
            return Decision(True, 0.0, int(tokens - 1))
        self.state[key] = (tokens, now)
        return Decision(False, (1 - tokens) / self.rate, 0)

    def _idle(self, saved, now):  # used by purge() in Step 7
        tokens, stamp = saved
        return tokens + (now - stamp) * self.rate >= self.capacity

Read it from the top. Decision is the answer every limiter gives: whether the request is allowed, how many seconds the client should wait before trying again (retry_after, zero when allowed) and how many more requests would be accepted right now (remaining). Limiter holds the clock and a dictionary called state with one entry per key; its purge method and the _idle methods belong to Step 4, so skip them for now. In TokenBucket.check a client we have never seen starts with a full bucket, the refill is computed from the elapsed time, and the two branches either spend a token or work out the wait: the missing part of a token divided by the rate, (1 - tokens) / self.rate.

Now a first experiment. step02_bucket.py asks a bucket for seven requests at the same instant, then lets time pass:

# step02_bucket.py
from labkit import FakeClock
from limiters import TokenBucket

clock = FakeClock(0.0)
bucket = TokenBucket(rate=0.5, capacity=5, clock=clock)  # one token every 2 seconds, at most 5 saved up


def ask(label):
    decision = bucket.check("client")
    print(f"t={clock():4.1f} s  {label:20} allowed={decision.allowed!s:5}  remaining={decision.remaining}  retry_after={decision.retry_after:.1f}")


for number in range(1, 8):
    ask(f"request {number} at once")
clock.advance(1.0)
ask("1 s later")
clock.advance(1.0)
ask("1 s after that")

Run it with python step02_bucket.py. You should see:

t= 0.0 s  request 1 at once    allowed=True   remaining=4  retry_after=0.0
t= 0.0 s  request 2 at once    allowed=True   remaining=3  retry_after=0.0
t= 0.0 s  request 3 at once    allowed=True   remaining=2  retry_after=0.0
t= 0.0 s  request 4 at once    allowed=True   remaining=1  retry_after=0.0
t= 0.0 s  request 5 at once    allowed=True   remaining=0  retry_after=0.0
t= 0.0 s  request 6 at once    allowed=False  remaining=0  retry_after=2.0
t= 0.0 s  request 7 at once    allowed=False  remaining=0  retry_after=2.0
t= 1.0 s  1 s later            allowed=False  remaining=0  retry_after=1.0
t= 2.0 s  1 s after that       allowed=True   remaining=0  retry_after=0.0

The bucket holds 5 tokens and refills one every 2 seconds. Requests 1 to 5 drain it, and remaining counts down from 4 to 0. Requests 6 and 7 are refused, and each is told to wait 2.0 seconds, which is one token at 0.5 tokens per second. After one second the bucket holds half a token, so the request is still refused, but the advertised wait has shrunk to 1.0 second. After one more second it holds exactly one token and the request is allowed. The numbers are exact here because 0.5 and 1.0 happen to be exactly representable as binary floating point numbers; Step 3 shows what happens when they are not.

Remember the promise this bucket makes, because we will test it in Step 9: in any stretch of T seconds it lets through at most capacity + rate × T requests. With a capacity of 5 and a rate of 0.5 per second, that is at most 10 requests in any 10 seconds, even though the sustained speed is only one request every 2 seconds. A token bucket is a long-run rate (here 5 requests per 10 seconds) plus a burst allowance (5 more). If you want to promise no more than 10 in any 10 seconds, pick the capacity and the rate so that capacity + rate × 10 comes to 10.

Check before moving on

Your output should match the block above line for line. If the sixth request is allowed, check that capacity is 5 and that the experiment uses FakeClock, not the real clock.

Step 3: GCRA, the same bucket with one number per client

The token bucket stores two things per client: how many tokens it has and when we last looked. There is an equivalent algorithm that stores one. The generic cell rate algorithm (GCRA) was designed for network traffic control, and Wikipedia says that its standards “describe the GCRA in two equivalent ways: as a virtual scheduling algorithm and as a continuous state leaky bucket algorithm”. The same article adds that “The GCRA should be considered as a version of the leaky bucket as a meter rather than the leaky bucket as a queue”. In other words, it counts requests and decides yes or no; it never holds requests back in a queue.

Here is the idea in plain words. Give every client a schedule. If a client is allowed one request every interval seconds, then after each accepted request the next request is on schedule one interval later. GCRA stores that moment, the theoretical arrival time (TAT). A request that arrives after the TAT is late and always welcome. A request that arrives before it is early, and it is accepted only if it is not too early: the tolerance, which is the burst size minus one, times the interval, says how far ahead of schedule a client may be. Each accepted request pushes the TAT one interval into the future, so a client that bursts runs far ahead of its schedule and then has to wait for the schedule to catch up. Add the class to the end of limiters.py:

# limiters.py (continued)
class GCRA(Limiter):
    """Generic cell rate algorithm: one number per key, the theoretical arrival time (TAT) of the next request."""

    def __init__(self, rate, burst, clock=time.monotonic):
        super().__init__(clock)
        self.interval = 1 / rate                      # one request every `interval` seconds on average
        self.tolerance = (burst - 1) * self.interval  # how far ahead of schedule a request may arrive

    def check(self, key):
        now = self.clock()
        tat = max(self.state.get(key, now), now)
        if now < tat - self.tolerance:
            return Decision(False, tat - self.tolerance - now, 0)
        tat += self.interval
        self.state[key] = tat
        remaining = math.floor((self.tolerance + now - tat) / self.interval) + 1
        return Decision(True, 0.0, max(0, remaining))

    def _idle(self, tat, now):  # used by purge() in Step 7
        return tat <= now

Two lines in check carry the idea. max(self.state.get(key, now), now) says that a client that has been quiet cannot bank unlimited credit: its schedule never lags behind the present. The if line refuses a request that is earlier than tat - self.tolerance, and how much earlier it is is exactly the wait. The two lines after it push the schedule one interval forward and save it, and the rest works out remaining.

Why is this the same as the bucket? A bucket with burst tokens that refills one token per interval holds burst - (TAT - now) / interval tokens at any moment, and a request needs at least one token. Rearranging that inequality gives now >= TAT - tolerance, the GCRA rule. Claims like that deserve a test, so step03_gcra.py feeds the same 100,000 requests from three clients to both limiters and compares every answer, once with exact fractions and once with ordinary floating point numbers:

# step03_gcra.py
import random
from fractions import Fraction

from labkit import FakeClock
from limiters import GCRA, TokenBucket

rng = random.Random(11)
# 100,000 requests from 3 clients, 0 to 400 ms apart: about 1.7 requests per second per client against a rate of 1
steps = [(rng.randrange(0, 400), rng.randrange(3)) for _ in range(100_000)]


def run(number):
    """Replay the steps with times of type `number`: Fraction is exact arithmetic, float is the usual kind."""
    bucket_clock, gcra_clock = FakeClock(number(0)), FakeClock(number(0))
    bucket = TokenBucket(number(1), 10, bucket_clock)
    gcra = GCRA(number(1), 10, gcra_clock)
    now = number(0)
    allowed = flags = remaining = retries = 0
    biggest_gap = 0.0
    for millis, key in steps:
        now = now + number(millis) / 1000
        bucket_clock.set(now)
        gcra_clock.set(now)
        a, b = bucket.check(key), gcra.check(key)
        allowed += a.allowed
        flags += a.allowed != b.allowed
        remaining += a.remaining != b.remaining
        retries += a.retry_after != b.retry_after
        biggest_gap = max(biggest_gap, abs(float(a.retry_after - b.retry_after)))
    return allowed, flags, remaining, retries, biggest_gap


for label, number in (("exact (Fraction)", Fraction), ("float", float)):
    allowed, flags, remaining, retries, gap = run(number)
    print(f"{label:17} allowed {allowed} of {len(steps)}; allow/refuse differs {flags}, remaining differs {remaining}, "
          f"retry_after differs {retries} (largest gap {gap:.1e} s)")

Python’s fractions module “provides support for rational number arithmetic”, so Fraction(1, 3) is exactly one third, with no rounding. Run python step03_gcra.py:

exact (Fraction)  allowed 59900 of 100000; allow/refuse differs 0, remaining differs 0, retry_after differs 0 (largest gap 0.0e+00 s)
float             allowed 59900 of 100000; allow/refuse differs 0, remaining differs 1, retry_after differs 36934 (largest gap 5.8e-13 s)

With exact arithmetic the two limiters agreed on all 100,000 requests: same allow or refuse answer, same retry_after, same remaining. With floats the allow-or-refuse answers still matched every time, but the retry_after values differed in 36,934 decisions, by at most 5.8e-13 seconds (that is, 0.00000000000058 s), and remaining differed once. Nothing is wrong. Two formulas that are equal on paper round differently in the last binary digit, and that is the reason the test suite in Step 9 uses Fraction: a test should fail because the code is wrong, not because 0.1 + 0.2 is not 0.3.

Step 4: What a client costs in memory, and how to forget idle ones

Which one should you ship? Both make the same decisions, so compare what they cost. step04_memory.py measures the bytes of state per client with tracemalloc (the same tool we used in our tutorial on finding memory leaks), measures the time of one check, and then floods a limiter with 200,000 clients that each make a single request:

# step04_memory.py
import time
import tracemalloc

from labkit import FakeClock
from limiters import GCRA, TokenBucket

MAKERS = {
    "token bucket": lambda clock: TokenBucket(1.0, 10, clock),
    "GCRA": lambda clock: GCRA(1.0, 10, clock),
}
N = 50_000
KEYS = [f"client-{i}" for i in range(N)]  # built first, so the key strings are not counted


def bytes_per_key(make, requests_per_key):
    clock = FakeClock(1000.0)
    limiter = make(clock)
    tracemalloc.start()
    before = tracemalloc.get_traced_memory()[0]
    for key in KEYS:
        for _ in range(requests_per_key):
            clock.advance(0.001)  # distinct timestamps, like real traffic
            limiter.check(key)
    after = tracemalloc.get_traced_memory()[0]
    tracemalloc.stop()
    return (after - before) / N


def nanoseconds_per_check(make, calls=200_000):
    clock = FakeClock(0.0)
    limiter = make(clock)
    names = [f"c{i}" for i in range(1000)]
    start = time.perf_counter()
    for i in range(calls):
        clock.now = i * 0.001
        limiter.check(names[i % 1000])
    return (time.perf_counter() - start) / calls * 1e9


print(f"{'':18}{'1 request/key':>15}{'10 requests/key':>17}{'time per check':>16}")
for name, make in MAKERS.items():
    print(f"{name:18}{bytes_per_key(make, 1):>13.0f} B{bytes_per_key(make, 10):>15.0f} B{nanoseconds_per_check(make):>13.0f} ns")

# a flood of one-request clients, then purge
clock = FakeClock(0.0)
bucket = TokenBucket(rate=2, capacity=5, clock=clock)
for i in range(200_000):
    bucket.check(f"spoofed-{i}")
print("\nkeys after a flood of one-request clients:", len(bucket.state))
clock.advance(0.2)
print("purge 0.2 s later:", bucket.purge(), "dropped (their buckets are not full yet)")
clock.advance(2.0)
for _ in range(30):
    bucket.check("heavy")  # one client keeps draining its bucket
print("purge 2.2 s later:", bucket.purge(), "dropped; keys left:", list(bucket.state))

Run it with python step04_memory.py:

                    1 request/key  10 requests/key  time per check
token bucket                118 B            140 B          420 ns
GCRA                         62 B             62 B          404 ns

keys after a flood of one-request clients: 200000
purge 0.2 s later: 0 dropped (their buckets are not full yet)
purge 2.2 s later: 200000 dropped; keys left: ['heavy']

GCRA needs 62 bytes per client against 118 to 140 for the bucket, because it keeps one float where the bucket keeps a tuple of two. Both take about 0.4 microseconds per check on this machine, so the processor is not the problem. The number of clients is. Every key you have ever seen stays in the dictionary, and if an attacker can invent keys, the dictionary grows without bound. Steps 7 and 8 show how easily keys can be invented: a header you believe, or an IPv6 address from a range the attacker controls.

The fix rests on one observation: a client whose bucket is full is indistinguishable from a client you have never seen. Both get a full burst on their next request, so you can delete the first kind without changing a single answer. That is what the _idle methods in limiters.py decide (for the bucket: would the refill since the last visit have filled it?; for GCRA: is the schedule already in the past?) and what Limiter.purge acts on.

In the output, the first purge, 0.2 seconds after the flood, dropped nothing, because each of the 200,000 buckets was still missing 0.6 of a token. The second purge, 2.2 seconds after the flood, dropped all 200,000 and kept only heavy, the one client that was still draining its bucket. Step 6 calls purge from a background task, and Step 9 proves that purging never changes an answer.

Step 5: Tell the client how long to wait

A refusal is only half of the answer. RFC 6585 says a 429 response “MAY include a Retry-After header indicating how long to wait before making a new request”, and RFC 9110 defines what the header may contain: “A delay-seconds value is a non-negative decimal integer, representing time in seconds.” That sentence hides a trap. Our limiters compute waits like 0.32 seconds, and the header can only carry whole seconds. Add the conversion function to the end of limiters.py:

# limiters.py (continued)
def retry_after_header(seconds):
    """Retry-After takes whole seconds (RFC 9110 delay-seconds), so round up, never down, and never to 0."""
    return str(max(1, math.ceil(seconds)))

The tempting version is str(int(seconds)), which rounds down. A wait of 0.32 seconds becomes Retry-After: 0, which tells the client to come back immediately. To see what that does, step05_retry_after.py simulates a client that empties a one-token bucket and then retries exactly as the header says, taking 50 ms per round trip, once with the rounded-down header and once with retry_after_header:

# step05_retry_after.py
from labkit import FakeClock
from limiters import TokenBucket, retry_after_header

ROUND_TRIP = 0.05  # a client needs 50 ms to send a request and read the answer


def round_down(seconds):
    return str(int(seconds))


def attempts_until_allowed(header, wait):
    """A client empties a one-token bucket, then retries exactly as the Retry-After header says."""
    clock = FakeClock(0.0)
    bucket = TokenBucket(rate=1 / wait, capacity=1, clock=clock)
    bucket.check("client")  # takes the only token
    attempts = 0
    while True:
        attempts += 1
        decision = bucket.check("client")
        if decision.allowed:
            return attempts, clock()
        clock.advance(int(header(decision.retry_after)) + ROUND_TRIP)


print(f"{'true wait':>10}{'int() header':>24}{'ceil() header':>24}")
for wait in (0.32, 1.72, 59.23):
    down = attempts_until_allowed(round_down, wait)
    up = attempts_until_allowed(retry_after_header, wait)
    print(f"{wait:>8} s{down[0]:>10} requests, {down[1]:>6.2f} s{up[0]:>10} requests, {up[1]:>6.2f} s")

Run python step05_retry_after.py:

 true wait            int() header           ceil() header
    0.32 s         8 requests,   0.35 s         2 requests,   1.05 s
    1.72 s        16 requests,   1.75 s         2 requests,   2.05 s
   59.23 s         6 requests,  59.25 s         2 requests,  60.05 s

With the rounded-down header the client sent 8, 16 and 6 requests before it got through, because a header of 0, or one that is short by a fraction of a second, sends it straight back. With the rounded-up header it sent exactly two: the refused one, and the one that succeeded. The price is honest: the polite client finished later, 1.05 seconds instead of 0.35 in the first row, because a whole-second header cannot express 0.32. That is the right trade. A server that is already refusing traffic should not also be hit by every refused client retrying in a tight loop, and max(1, ...) makes sure the header never says zero.

The IETF is working on richer headers. The draft RateLimit header fields for HTTP (revision 11 in May 2026, still an Internet-Draft) defines RateLimit-Policy and RateLimit fields that advertise the quota and what is left of it. Two of its sentences are worth knowing today: clients “MUST NOT assume that a positive available quota is a guarantee that further requests will be served”, and when a response carries both fields, “the Retry-After field value SHOULD NOT reference a point in time earlier than the end of the effective window”. Until it is final, Retry-After is the header every HTTP client library already understands.

Step 6: Put the limiter in front of FastAPI

Now connect the limiter to a web application. You could add a dependency to each route, but a middleware (code that wraps every request) is better here: it sees the request before routing, so a refused request costs the application almost nothing, and it also covers routes you add next month without anyone remembering to decorate them. FastAPI runs on ASGI, the Python standard for asynchronous web servers, and a plain ASGI middleware is a class with an __call__(scope, receive, send) method. The scope dictionary describes the connection; its type is http, websocket or lifespan (the server telling the app that it is starting up or shutting down). Our middleware limits only HTTP requests and passes everything else through untouched, so WebSocket connections are not limited by it; limit those separately if you expose any. Create middleware.py:

# middleware.py
import ipaddress
import json

from limiters import retry_after_header


class RateLimitMiddleware:
    """Pure ASGI middleware: ask the limiter first, so a refused request costs the app almost nothing."""

    def __init__(self, app, limiter, key_func, exempt=("/healthz",)):
        self.app = app
        self.limiter = limiter
        self.key_func = key_func
        self.exempt = set(exempt)

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http" or scope["path"] in self.exempt:
            await self.app(scope, receive, send)
            return
        decision = self.limiter.check(self.key_func(scope))
        if decision.allowed:
            await self.app(scope, receive, send)
            return
        wait = retry_after_header(decision.retry_after)
        body = json.dumps({
            "type": "about:blank",
            "title": "Too Many Requests",
            "status": 429,
            "detail": f"Rate limit exceeded. Try again in {wait} second(s).",
        }).encode()
        await send({
            "type": "http.response.start",
            "status": 429,
            "headers": [
                (b"content-type", b"application/problem+json"),
                (b"content-length", str(len(body)).encode()),
                (b"retry-after", wait.encode()),
            ],
        })
        await send({"type": "http.response.body", "body": body})

Three details deserve a sentence each. Health checks are exempt, because a load balancer that polls /healthz every few seconds must not use up anyone’s budget. A refusal is a JSON document in the problem details format, with type set to about:blank. RFC 9457 explains that this value “indicates that the problem has no additional semantics beyond that of the HTTP status code” and that the title “SHOULD be the same as the recommended HTTP status phrase for that code”, which is why the title is Too Many Requests; our tutorial on problem details goes further. And the middleware asks self.key_func(scope) who the client is, a function we pass in, which brings us to the second half of the file. Add these two key functions to the end of middleware.py:

# middleware.py (continued)
def naive_key(scope):
    """The tempting version: believe whatever the X-Forwarded-For header says."""
    headers = dict(scope["headers"])
    forwarded = headers.get(b"x-forwarded-for")
    if forwarded:
        return forwarded.decode("latin-1").split(",")[0].strip()
    return scope["client"][0]


def client_key(scope):
    """Key a request by the peer address the ASGI server reports. IPv6 peers share one key per /64."""
    client = scope.get("client")
    if not client:
        return "unknown"
    try:
        address = ipaddress.ip_address(client[0])
    except ValueError:
        return client[0]  # not an IP address (a unix socket path or the test client's name)
    if address.version == 6 and address.ipv4_mapped:
        address = address.ipv4_mapped
    if address.version == 6:
        return str(ipaddress.ip_network((address, 64), strict=False))
    return str(address)

Do not worry about them yet. naive_key is the tempting wrong answer and client_key is the better one; Steps 7 and 8 explain both, and for now read client_key as the address of the peer that connected to us. Now the application itself. Create app.py:

# app.py
import asyncio
import os
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware

from limiters import TokenBucket
from middleware import RateLimitMiddleware, client_key, naive_key


def make_app(limiter, key_func=client_key, cors_origins=(), purge_every=60):
    @asynccontextmanager
    async def lifespan(app):
        async def sweep():
            while True:
                await asyncio.sleep(purge_every)
                limiter.purge()  # forget clients whose buckets are full again

        task = asyncio.create_task(sweep())
        try:
            yield
        finally:
            task.cancel()

    app = FastAPI(lifespan=lifespan)
    # Starlette makes the LAST middleware you add the OUTERMOST one, so add the limiter first:
    # CORS then wraps it and a 429 still gets its CORS headers.
    app.add_middleware(RateLimitMiddleware, limiter=limiter, key_func=key_func)
    if cors_origins:
        app.add_middleware(
            CORSMiddleware,
            allow_origins=list(cors_origins),
            expose_headers=["Retry-After"],
        )

    @app.get("/healthz")
    def healthz():
        return {"ok": True}

    @app.get("/v1/hello")
    def hello(request: Request):
        return {"hello": "world", "peer": request.client.host if request.client else None}

    return app


RATE = float(os.environ.get("RATE", "2"))
BURST = int(os.environ.get("BURST", "5"))
KEY_FUNC = naive_key if os.environ.get("KEY_MODE") == "header" else client_key
app = make_app(TokenBucket(rate=RATE, capacity=BURST), KEY_FUNC)

make_app builds the FastAPI application around any limiter. Its lifespan function starts a background task when the server starts, and every purge_every seconds the task calls limiter.purge(), the Step 4 fix for the memory growth; it cancels the task when the server stops. The comment about middleware order is a measured fact, not a guess. FastAPI’s documentation says that “The last middleware added is the outermost, and the first is the innermost”, so adding the limiter first and the CORS middleware second puts CORS on the outside, and a 429 response still gets its CORS headers. Browsers hide the details of a response without them, and a front end could not read Retry-After (our CORS tutorial explains why). The three environment variables at the bottom, RATE, BURST and KEY_MODE, only exist so that the experiments can reconfigure the app; KEY_MODE=header switches to the wrong key function on purpose.

You can try the app by hand. A bucket of 5 tokens that refills one token every 2 seconds is slow enough to watch, so start the server with those settings in one terminal and send requests from a second one with curl:

$ RATE=0.5 BURST=5 python -m uvicorn app:app --port 8000

To make the session repeatable, step06_live_demo.py starts the server itself, sends eight requests, looks at a refusal, waits, and checks that the health endpoint is never refused. It prints each command and what came back. (It uses port 8000, so stop the server from the first terminal before you run it.)

# step06_live_demo.py
"""Start the real app with uvicorn and talk to it with curl, printing each command and what it answered."""
import os
import subprocess
import sys
import time

import requests

HERE = os.path.dirname(os.path.abspath(__file__))
env = dict(os.environ, RATE="0.5", BURST="5", PYTHONDONTWRITEBYTECODE="1")
server = subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app", "--port", "8000", "--log-level", "warning"],
                          cwd=HERE, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)


def show(command):
    print(f"$ {command.rstrip()}")
    result = subprocess.run(["bash", "-c", command], capture_output=True, text=True, cwd=HERE)
    print((result.stdout + result.stderr).rstrip("\n"))


try:
    for _ in range(80):
        try:
            if requests.get("http://127.0.0.1:8000/healthz", timeout=1).status_code == 200:
                break
        except requests.RequestException:
            time.sleep(0.25)
    show(r'for i in 1 2 3 4 5 6 7 8; do curl -s -o /dev/null -w "%{http_code} " http://127.0.0.1:8000/v1/hello; done; echo')
    show(r'''curl -si http://127.0.0.1:8000/v1/hello | tr -d "\r" | grep -iE '^(HTTP|content-type|retry-after|\{)' ''')
    show(r'sleep 2; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/v1/hello')
    show(r'for i in 1 2 3 4 5 6 7 8; do curl -s -o /dev/null -w "%{http_code} " http://127.0.0.1:8000/healthz; done; echo')
finally:
    server.terminate()
    server.wait(timeout=10)

Run python step06_live_demo.py:

$ for i in 1 2 3 4 5 6 7 8; do curl -s -o /dev/null -w "%{http_code} " http://127.0.0.1:8000/v1/hello; done; echo
200 200 200 200 200 429 429 429 
$ curl -si http://127.0.0.1:8000/v1/hello | tr -d "\r" | grep -iE '^(HTTP|content-type|retry-after|\{)'
HTTP/1.1 429 Too Many Requests
content-type: application/problem+json
retry-after: 2
{"type": "about:blank", "title": "Too Many Requests", "status": 429, "detail": "Rate limit exceeded. Try again in 2 second(s)."}
$ sleep 2; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/v1/hello
200
$ for i in 1 2 3 4 5 6 7 8; do curl -s -o /dev/null -w "%{http_code} " http://127.0.0.1:8000/healthz; done; echo
200 200 200 200 200 200 200 200 

Line by line: the first eight requests got 200 200 200 200 200 429 429 429, the five tokens of the burst and then three refusals. The second command prints the interesting parts of a refusal: the status line, the problem details content type, retry-after: 2 (the real wait was a little under 2 seconds, rounded up) and the JSON body. After sleep 2 the same request succeeds, and eight requests to /healthz all return 200 even though the bucket had just been empty.

Step 7: Who is the client? X-Forwarded-For and uvicorn’s trust list

Production traffic rarely reaches your application directly. It goes through a reverse proxy (nginx, a cloud load balancer, a CDN), a server that accepts the connection from the user and opens a second one to your app. From the app’s point of view every request now comes from the proxy’s address, so a limiter that keys on the connection’s address gives all your users one shared budget. Proxies solve that by adding a header, X-Forwarded-For, that lists the addresses the request has passed through, each proxy appending the address it saw. The nginx documentation describes the variable commonly used to do it, $proxy_add_x_forwarded_for, as the client’s own header “with the $remote_addr variable appended to it, separated by a comma”.

That header is also the single easiest thing to forge, because the user can send one too. The tempting limiter reads it directly, which is what naive_key does: take the first address in the header. Anyone with curl then gets a new budget for every request by sending a new address each time. The better design is to let the web server work out the real client address and to read only that. Uvicorn can do it: with --proxy-headers, which its documentation says is “enabled, but is restricted to only trusting connecting IPs in the forwarded-allow-ips configuration”. The default allow list is short: “127.0.0.1 and ::1 are trusted”. When the connection comes from a trusted address, uvicorn reads X-Forwarded-For, and its source explains how: “each proxy appends to the header list so check it in reverse order”. It walks the list from the right and takes the first address that is not itself a trusted proxy, so the addresses a user typed at the left end are ignored.

That is the theory. step07_proxy_matrix.py tests it against six setups on a real uvicorn 0.54.0 server, each with a bucket of 5 tokens that refills one token every 5 seconds, so that one shared budget allows 5 requests and independent budgets allow all of them. It starts a fresh server for each case, sends requests with chosen X-Forwarded-For values and counts the successes:

# step07_proxy_matrix.py
import os
import subprocess
import sys
import time

import requests

HERE = os.path.dirname(os.path.abspath(__file__))
port = 8120


def start_server(port, key_mode, flags, extra_env):
    env = dict(os.environ, KEY_MODE=key_mode, RATE="0.2", BURST="5", PYTHONDONTWRITEBYTECODE="1", **extra_env)
    command = [sys.executable, "-m", "uvicorn", "app:app", "--port", str(port), "--log-level", "warning", *flags]
    process = subprocess.Popen(command, cwd=HERE, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    for _ in range(80):
        try:
            if requests.get(f"http://127.0.0.1:{port}/healthz", timeout=1).status_code == 200:
                return process
        except requests.RequestException:
            time.sleep(0.25)
    process.kill()
    raise RuntimeError("the server did not start")


def stop_server(process):
    process.terminate()
    process.wait(timeout=10)


def run_case(label, key_mode, flags, forwarded_for, count, extra_env=None):
    global port
    port += 1
    process = start_server(port, key_mode, flags, extra_env or {})
    try:
        session = requests.Session()
        allowed, seen = 0, None
        for i in range(count):
            reply = session.get(f"http://127.0.0.1:{port}/v1/hello", headers={"X-Forwarded-For": forwarded_for(i)})
            if reply.status_code == 200:
                allowed += 1
                seen = seen or reply.json()["peer"]
        print(f"{label:46}{seen or '-':>14}{allowed:>8} of {count}")
    finally:
        stop_server(process)


forged = lambda i: f"203.0.113.{i}"                         # one forged address per request
proxy_appends = lambda i: f"203.0.113.{i}, 198.51.100.9"    # forged address, then the real peer a proxy appended
three_users = lambda i: f"198.51.100.{i % 3 + 1}"           # three honest users, as a proxy reports them

print(f"{'':46}{'app sees':>14}{'allowed':>12}")
run_case("A  app reads X-Forwarded-For itself", "header", [], forged, 20)
run_case("B  peer key, default uvicorn, lone header", "peer", [], forged, 20)
run_case("C  peer key, default uvicorn, proxy appends", "peer", [], proxy_appends, 20)
run_case("D  peer key, FORWARDED_ALLOW_IPS='*'", "peer", [], proxy_appends, 20, {"FORWARDED_ALLOW_IPS": "*"})
run_case("E  3 users, --no-proxy-headers", "peer", ["--no-proxy-headers"], three_users, 15)
run_case("F  3 users, default uvicorn", "peer", [], three_users, 15)

Run python step07_proxy_matrix.py. The six servers start and stop within a few seconds:

                                                    app sees     allowed
A  app reads X-Forwarded-For itself              203.0.113.0      20 of 20
B  peer key, default uvicorn, lone header        203.0.113.0      20 of 20
C  peer key, default uvicorn, proxy appends     198.51.100.9       5 of 20
D  peer key, FORWARDED_ALLOW_IPS='*'             203.0.113.0      20 of 20
E  3 users, --no-proxy-headers                     127.0.0.1       5 of 15
F  3 users, default uvicorn                     198.51.100.1      15 of 15

The column app sees is the client address the application received for the first allowed request. Here is what each row means.

  • A: the app reads the header itself. 20 of 20 requests allowed. Every forged address was a new client, so the limit did nothing. This is the bug to avoid.
  • B: the app uses the peer address, but the header arrives alone from a trusted address. 20 of 20 again. With uvicorn’s defaults, 127.0.0.1 is trusted, so a header that reaches uvicorn from there is believed. This is what happens when a proxy on the same machine passes the user’s own header through unchanged, which a proxy that forwards the original request headers will do unless it is told to set the header itself. nginx’s proxy_pass_request_headers directive, for example, defaults to on and “Indicates whether the header fields of the original request are passed to the proxied server”.
  • C: the proxy appends the address it saw. 5 of 20, the correct answer. The request carried a forged address on the left and the real one, 198.51.100.9, appended on the right. Uvicorn walked the list from the right, and the first address it did not trust was 198.51.100.9, so all twenty requests shared one budget.
  • D: the trust list is *. 20 of 20. A literal * means trust everything in the uvicorn documentation, so it takes the leftmost address, the one the user typed. Never use it unless the app can be reached only by your proxy, for example because a firewall or a private network enforces that.
  • E: three honest users, proxy headers switched off. 5 of 15. The app sees only the proxy’s address, 127.0.0.1, so the three users share one budget and one noisy user can lock out the other two. This is the opposite failure, and it is what you get if you deploy behind a proxy and forget to configure the app for it.
  • F: the same three users with the default trust list. 15 of 15, each user with a separate budget of 5. This is the configuration to aim for.

Two practical notes. First, case D sets FORWARDED_ALLOW_IPS as an environment variable, which uvicorn’s documentation supports, instead of passing --forwarded-allow-ips * on the command line. On my Windows machine the bare * was expanded into the names of the files in the current folder before uvicorn saw it (it answered “Got unexpected extra arguments” and listed my files), so check what your shell does with a star. Second, the allow list should name the addresses of your own proxies, nothing else. If several proxies stand in a chain, list all of them so that uvicorn can walk past each one. If you use another server, such as Gunicorn or Hypercorn, its rules and defaults are its own: run this six-case check against yours before you trust it. The check is cheap enough to keep as a test in your deployment pipeline.

Step 8: One IPv6 /64 is one client

IPv6 breaks a habit from the IPv4 world: that one address means one machine or one customer. RFC 4291 says that for unicast addresses (with one exception that does not matter here) “Interface IDs are required to be 64 bits long”. In practice that means the first 64 bits identify a network (a /64) and the last 64 bits are chosen by the machine on it, so whoever controls one /64 can send from any of 264 addresses. A limiter that keys on the full address gives that attacker a new budget for every address, and a new entry in your dictionary too, which is the memory problem of Step 4 again.

The fix is in the second half of client_key: an IPv6 address is replaced by its /64 network, and an IPv4-mapped IPv6 address (::ffff:203.0.113.7, which some servers use to report an IPv4 client on a dual-stack socket) is turned back into plain IPv4 first. Python’s ipaddress module does the work: ip_network((address, 64), strict=False) clears the host bits. step08_ipv6.py tries 1,000 addresses from one /64 against a limiter that keys on the full address and against client_key, then prints a few keys:

# step08_ipv6.py
from labkit import FakeClock
from limiters import TokenBucket
from middleware import client_key


def scope(address):
    return {"client": (address, 50000)}


# an attacker who controls one IPv6 /64 can send from any of its 2**64 addresses; here, 1,000 of them
addresses = [f"2001:db8:abcd:12::{n:x}" for n in range(1, 1001)]
for label, key_func in (("one key per address", lambda s: s["client"][0]), ("client_key (one key per /64)", client_key)):
    limiter = TokenBucket(rate=0.2, capacity=5, clock=FakeClock(0.0))
    allowed = sum(limiter.check(key_func(scope(a))).allowed for a in addresses)
    print(f"{label:30} allowed {allowed} of {len(addresses)}")

print()
for peer in ("203.0.113.7", "::ffff:203.0.113.7", "2001:db8:abcd:12::1", "2001:db8:abcd:12:ffff::9", "2001:db8:abcd:13::1", "testclient"):
    print(f"{peer:28} -> {client_key(scope(peer))}")
print(f"{'(no peer information)':28} -> {client_key({})}")

Run python step08_ipv6.py:

one key per address            allowed 1000 of 1000
client_key (one key per /64)   allowed 5 of 1000

203.0.113.7                  -> 203.0.113.7
::ffff:203.0.113.7           -> 203.0.113.7
2001:db8:abcd:12::1          -> 2001:db8:abcd:12::/64
2001:db8:abcd:12:ffff::9     -> 2001:db8:abcd:12::/64
2001:db8:abcd:13::1          -> 2001:db8:abcd:13::/64
testclient                   -> testclient
(no peer information)        -> unknown

Keyed by address, the attacker got 1,000 of 1,000 requests through. Keyed by /64, the same traffic got the 5 requests of one bucket. In the second part of the output, the two addresses in 2001:db8:abcd:12:: map to the same key and the one in 2001:db8:abcd:13:: to another. The last two rows are the defaults: a peer that is not an IP address (the test client’s name, or a unix socket path) is used as it is, and a request with no peer information at all shares the single key unknown. There is a trade-off. If a provider puts many unrelated customers inside one /64, they will share a budget. A /64 is the standard size of one subnet, but for logged-in users the better key is the account or the API key anyway, applied after authentication; keep the address-based limit for the routes that need no login, such as signing in or asking for a password reset (see our password reset tutorial).

Step 9: Prove it with tests that can fail

You now have the code and some measurements. Measurements prove that the code worked once; tests prove that it keeps working. We will write two kinds. Property-based tests state a rule that must hold for every possible input, and the Hypothesis library then generates many inputs, looks for one that breaks the rule and shrinks it to the smallest example (our Hypothesis tutorial covers the basics). Example tests check one scenario through the real application. Everything runs on the fake clock, with Fraction numbers so that ties and rounding cannot cause false alarms.

First, a small configuration file that defines two profiles of Hypothesis settings: a quick one (100 generated cases per test) for every run, and a thorough one (3,000) for a nightly job. Create conftest.py:

# conftest.py
from hypothesis import settings

settings.register_profile("lab", max_examples=100, deadline=None)
settings.register_profile("thorough", max_examples=3000, deadline=None)
settings.load_profile("lab")

Then test_properties.py. It starts with the setup shared by all properties: both limiters in one dictionary, so each property runs against both, a strategy for lists of gaps between requests (many zeros, so that requests arrive together, and round numbers, so that exact ties happen) and two helpers. probe asks a copy of the limiter what it would answer some time later, so a test can look into the future without disturbing the present.

# test_properties.py
import copy
from fractions import Fraction

import pytest
from hypothesis import given, strategies as st

from labkit import FakeClock
from limiters import GCRA, TokenBucket

HALF = Fraction(1, 2)
EPS = Fraction(1, 10**6)
FACTORIES = {
    "bucket": lambda clock: TokenBucket(HALF, 5, clock),  # one token every 2 s, at most 5 saved up
    "gcra": lambda clock: GCRA(HALF, 5, clock),
}
# gaps in milliseconds; plenty of zeros, so requests arrive together, and round numbers, so exact ties happen
gaps = st.lists(st.sampled_from([0, 0, 1, 7, 100, 999, 1000, 2000, 4000, 12000]), min_size=1, max_size=60)
# (gap in ms, which client, purge first?) for the tests that mix clients and purging
steps = st.lists(st.tuples(st.sampled_from([0, 1, 500, 3000, 20000]), st.sampled_from("abc"), st.booleans()), min_size=1, max_size=60)


def arrival_times(gap_list):
    now, times = Fraction(0), []
    for millis in gap_list:
        now += Fraction(millis, 1000)
        times.append(now)
    return times


def probe(limiter, delay, key="k"):
    """What would the limiter say `delay` seconds from now, if nothing else arrived? (Asks a copy.)"""
    twin = copy.deepcopy(limiter)
    twin.clock.advance(delay)
    return twin.check(key)

Property 1: the wait is honest and tight

Whenever a request is refused, the advertised retry_after must be neither too short nor too long: a request one microsecond early must still be refused, and one a microsecond late must be allowed.

# test_properties.py (continued)
@pytest.mark.parametrize("name", FACTORIES)
@given(gaps)
def test_retry_after_is_neither_too_short_nor_too_long(name, gap_list):
    clock = FakeClock(Fraction(0))
    limiter = FACTORIES[name](clock)
    for moment in arrival_times(gap_list):
        clock.set(moment)
        decision = limiter.check("k")
        if decision.allowed:
            continue
        assert decision.retry_after > 0
        if decision.retry_after > EPS:
            assert not probe(limiter, decision.retry_after - EPS).allowed  # not too optimistic
        assert probe(limiter, decision.retry_after + EPS).allowed          # not too pessimistic

Properties 2 and 3: remaining is exact, and the bucket keeps its promise

If an allowed request reports remaining of k, exactly k more requests at the same instant must be allowed and the next one refused. And the promise from Step 2 becomes executable: among any run of accepted requests, the count never exceeds capacity + rate × elapsed.

# test_properties.py (continued)
@pytest.mark.parametrize("name", FACTORIES)
@given(gaps)
def test_remaining_is_exactly_how_many_more_fit_right_now(name, gap_list):
    clock = FakeClock(Fraction(0))
    limiter = FACTORIES[name](clock)
    for moment in arrival_times(gap_list):
        clock.set(moment)
        decision = limiter.check("k")
        if not decision.allowed:
            assert decision.remaining == 0
            continue
        twin = copy.deepcopy(limiter)
        for _ in range(decision.remaining):
            assert twin.check("k").allowed
        assert not twin.check("k").allowed


@pytest.mark.parametrize("name", FACTORIES)
@given(gaps)
def test_never_more_than_burst_plus_rate_times_time(name, gap_list):
    clock = FakeClock(Fraction(0))
    limiter = FACTORIES[name](clock)
    accepted = []
    for moment in arrival_times(gap_list):
        clock.set(moment)
        if limiter.check("k").allowed:
            accepted.append(moment)
    for i in range(len(accepted)):
        for j in range(i, len(accepted)):
            assert j - i + 1 <= 5 + HALF * (accepted[j] - accepted[i])

Property 4: GCRA and the token bucket are the same limiter

The claim from Step 3, now checked on every generated schedule with exact arithmetic, comparing the whole Decision object.

# test_properties.py (continued)
@given(gaps)
def test_gcra_makes_exactly_the_decisions_of_the_token_bucket(gap_list):
    bucket_clock, gcra_clock = FakeClock(Fraction(0)), FakeClock(Fraction(0))
    bucket, gcra = FACTORIES["bucket"](bucket_clock), FACTORIES["gcra"](gcra_clock)
    for moment in arrival_times(gap_list):
        bucket_clock.set(moment)
        gcra_clock.set(moment)
        assert bucket.check("k") == gcra.check("k")

Properties 5 to 7: purging is invisible, and clients do not affect each other

Run one limiter that purges at random moments next to one that never purges, and require identical answers: that is the claim of Step 4. A second test checks that a quiet limiter really does empty. The last property says that a client’s answers depend only on that client’s own traffic.

# test_properties.py (continued)
@pytest.mark.parametrize("name", FACTORIES)
@given(steps)
def test_purging_never_changes_a_decision(name, step_list):
    clock_a, clock_b = FakeClock(Fraction(0)), FakeClock(Fraction(0))
    with_purge, without = FACTORIES[name](clock_a), FACTORIES[name](clock_b)
    now = Fraction(0)
    for millis, key, purge_first in step_list:
        now += Fraction(millis, 1000)
        clock_a.set(now)
        clock_b.set(now)
        if purge_first:
            with_purge.purge()
        assert with_purge.check(key) == without.check(key)


@pytest.mark.parametrize("name", FACTORIES)
def test_purge_empties_a_limiter_whose_clients_have_gone_quiet(name):
    clock = FakeClock(Fraction(0))
    limiter = FACTORIES[name](clock)
    for key in "abcde":
        limiter.check(key)
    clock.advance(1000)
    assert limiter.purge() == 5
    assert limiter.state == {}


@pytest.mark.parametrize("name", FACTORIES)
@given(steps)
def test_one_clients_traffic_never_changes_another_clients_answers(name, step_list):
    clock_both, clock_alone = FakeClock(Fraction(0)), FakeClock(Fraction(0))
    both, alone = FACTORIES[name](clock_both), FACTORIES[name](clock_alone)
    now = Fraction(0)
    for millis, key, _ in step_list:
        now += Fraction(millis, 1000)
        clock_both.set(now)
        clock_alone.set(now)
        decision = both.check(key)
        if key == "a":
            assert decision == alone.check("a")

The application tests

Now the example tests in test_app.py, which exercise the real FastAPI app through Starlette’s test client, one behaviour each. Create the file with the first part:

# test_app.py
import time

import pytest
from fastapi.testclient import TestClient

from app import make_app
from labkit import FakeClock
from limiters import TokenBucket, retry_after_header
from middleware import client_key, naive_key


def test_retry_after_header_rounds_up_and_never_says_zero():
    assert retry_after_header(0.2) == "1"
    assert retry_after_header(1.2) == "2"
    assert retry_after_header(3) == "3"
    assert retry_after_header(59.01) == "60"


def build(rate=0.8, capacity=2, **options):
    clock = FakeClock(0.0)
    client = TestClient(make_app(TokenBucket(rate=rate, capacity=capacity, clock=clock), **options))
    return client, clock

The test client does not use a network, so the fake clock still controls time. The scenarios, in order: the third request gets a problem document with Retry-After: 2 (the real wait is 1.25 seconds, so a rounded-down header would say 1); waiting the advertised time works; health checks are exempt and cost nothing; each client has its own budget; and the application’s startup and shutdown pass through the middleware.

# test_app.py (continued)
def test_the_third_request_gets_a_problem_document_and_a_rounded_up_wait():
    client, _ = build()
    assert [client.get("/v1/hello").status_code for _ in range(2)] == [200, 200]
    refused = client.get("/v1/hello")
    assert refused.status_code == 429
    assert refused.headers["retry-after"] == "2"  # the real wait is 1.25 s
    assert refused.headers["content-type"] == "application/problem+json"
    assert refused.json()["status"] == 429


def test_waiting_for_the_advertised_time_works():
    client, clock = build()
    for _ in range(3):
        client.get("/v1/hello")
    clock.advance(2)
    assert client.get("/v1/hello").status_code == 200


def test_health_checks_are_exempt_and_cost_nothing():
    client, _ = build(capacity=1)
    assert [client.get("/healthz").status_code for _ in range(10)] == [200] * 10
    assert client.get("/v1/hello").status_code == 200


def test_each_client_has_its_own_budget():
    app = make_app(TokenBucket(rate=0.8, capacity=1, clock=FakeClock(0.0)))
    first = TestClient(app, client=("203.0.113.1", 5000))
    second = TestClient(app, client=("203.0.113.2", 5000))
    assert first.get("/v1/hello").status_code == 200
    assert first.get("/v1/hello").status_code == 429
    assert second.get("/v1/hello").status_code == 200


def test_lifespan_events_pass_through_the_middleware():
    client, _ = build()
    with client:  # starting and stopping the app sends a lifespan scope through every middleware
        assert client.get("/v1/hello").status_code == 200

The rest cover the background purge task, the CORS headers on a refusal, the key function and the spoofing hole. The background test advances the fake clock by 1,000 seconds, then waits (up to 5 seconds) for the purge task to empty the dictionary on its own:

# test_app.py (continued)
def test_the_app_purges_idle_clients_in_the_background():
    clock = FakeClock(0.0)
    limiter = TokenBucket(rate=0.8, capacity=2, clock=clock)
    with TestClient(make_app(limiter, purge_every=0.01)) as client:
        client.get("/v1/hello")
        assert len(limiter.state) == 1
        clock.advance(1000)  # the client has long since refilled its bucket
        deadline = time.monotonic() + 5
        while limiter.state and time.monotonic() < deadline:
            time.sleep(0.01)
    assert limiter.state == {}


def test_a_refusal_keeps_its_cors_headers_when_cors_wraps_the_limiter():
    client, _ = build(capacity=1, cors_origins=["https://app.example"])
    headers = {"Origin": "https://app.example"}
    client.get("/v1/hello", headers=headers)
    refused = client.get("/v1/hello", headers=headers)
    assert refused.status_code == 429
    assert refused.headers["access-control-allow-origin"] == "https://app.example"
    assert "retry-after" in refused.headers["access-control-expose-headers"].lower()


@pytest.mark.parametrize("peer, expected", [
    ("203.0.113.7", "203.0.113.7"),
    ("::ffff:203.0.113.7", "203.0.113.7"),
    ("2001:db8:abcd:12::1", "2001:db8:abcd:12::/64"),
    ("2001:db8:abcd:12:ffff:ffff:ffff:ffff", "2001:db8:abcd:12::/64"),
    ("2001:db8:abcd:13::1", "2001:db8:abcd:13::/64"),
    ("testclient", "testclient"),
])
def test_client_key(peer, expected):
    assert client_key({"client": (peer, 1)}) == expected


def test_a_request_without_peer_information_gets_one_shared_key():
    assert client_key({}) == "unknown"
    assert client_key({"client": None}) == "unknown"


def test_naive_key_can_be_spoofed_by_anyone():
    def scope(value):
        return {"client": ("198.51.100.9", 1), "headers": [(b"x-forwarded-for", value)]}

    assert naive_key(scope(b"203.0.113.1")) != naive_key(scope(b"203.0.113.2"))

Run everything with python -m pytest -q:

............................. [100%]
29 passed in 1.12s

That is 29 tests (13 about the limiters, 11 of them property-based when each limiter is counted separately, and 16 about the application) in about a second. Now the thorough profile, with 3,000 generated cases for every property:

$ python -m pytest -q --hypothesis-profile=thorough
............................. [100%]
29 passed in 32.96s

It took about half a minute and passed too. Use the quick profile on every commit and the thorough profile in a nightly job: rare coincidences, such as a request landing on the exact moment a token appears, are what the larger run is for.

Step 10: Break it on purpose

A test that cannot fail proves nothing. The way to find out whether yours can is mutation checking: change the code in one small, plausible, wrong way, run the tests, and see that something fails. mutations.py does this for ten bugs, each one a mistake a real person could make during a refactor. It copies the project into a temporary folder, applies one change, runs pytest with -x (stop at the first failure) and reports the failing test:

# mutations.py
"""Break the code on purpose, one bug at a time, and check that the tests notice."""
import os
import re
import shutil
import subprocess
import sys
import tempfile

HERE = os.path.dirname(os.path.abspath(__file__))

MUTANTS = [
    ("Retry-After rounded down", "limiters.py",
     "return str(max(1, math.ceil(seconds)))", "return str(max(1, int(seconds)))"),
    ("bucket never caps its tokens", "limiters.py",
     "tokens = min(self.capacity, tokens + (now - stamp) * self.rate)", "tokens = tokens + (now - stamp) * self.rate"),
    ("a refused request still spends a token", "limiters.py",
     "        self.state[key] = (tokens, now)\n        return Decision(False,", "        self.state[key] = (tokens - 1, now)\n        return Decision(False,"),
    ("GCRA forgets max(tat, now)", "limiters.py",
     "tat = max(self.state.get(key, now), now)", "tat = self.state.get(key, now)"),
    ("a refused request pushes GCRA's clock forward", "limiters.py",
     "        if now < tat - self.tolerance:\n            return Decision(False,", "        if now < tat - self.tolerance:\n            self.state[key] = tat + self.interval\n            return Decision(False,"),
    ("purge drops active clients", "limiters.py",
     "if self._idle(saved, now)]", "if not self._idle(saved, now)]"),
    ("the background purge does nothing", "app.py",
     "limiter.purge()  # forget clients whose buckets are full again", "pass"),
    ("IPv6 clients keyed by full address", "middleware.py",
     "return str(ipaddress.ip_network((address, 64), strict=False))", "return str(address)"),
    ("lifespan scope reaches the key function", "middleware.py",
     'if scope["type"] != "http" or scope["path"] in self.exempt:', 'if scope["path"] in self.exempt:'),
    ("CORS added inside the limiter", "app.py",
     "    app.add_middleware(RateLimitMiddleware, limiter=limiter, key_func=key_func)\n    if cors_origins:\n        app.add_middleware(\n            CORSMiddleware,\n            allow_origins=list(cors_origins),\n            expose_headers=[\"Retry-After\"],\n        )\n",
     "    if cors_origins:\n        app.add_middleware(\n            CORSMiddleware,\n            allow_origins=list(cors_origins),\n            expose_headers=[\"Retry-After\"],\n        )\n    app.add_middleware(RateLimitMiddleware, limiter=limiter, key_func=key_func)\n"),
]


def run_tests(folder):
    env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1", COLUMNS="170")
    result = subprocess.run([sys.executable, "-m", "pytest", "-x", "-q", "-p", "no:cacheprovider", "test_properties.py", "test_app.py"],
                            cwd=folder, env=env, capture_output=True, text=True)
    return result.returncode, result.stdout


killed = 0
for label, filename, old, new in MUTANTS:
    folder = tempfile.mkdtemp(prefix="mutant_")
    try:
        for name in os.listdir(HERE):
            if name.endswith(".py"):
                shutil.copy(os.path.join(HERE, name), folder)
        path = os.path.join(folder, filename)
        source = open(path, encoding="utf-8").read()
        assert source.count(old) == 1, f"{label}: the text to replace occurs {source.count(old)} times"
        open(path, "w", encoding="utf-8", newline="\n").write(source.replace(old, new))
        code, output = run_tests(folder)
        failed = re.findall(r"^FAILED (\S+)", output, flags=re.M)
        verdict = "caught by " + failed[0].split("::", 1)[-1][:70] if code != 0 and failed else ("caught (error)" if code != 0 else "NOT CAUGHT")
        killed += code != 0
        print(f"{label:48} {verdict}")
    finally:
        shutil.rmtree(folder, ignore_errors=True)
print(f"\n{killed} of {len(MUTANTS)} deliberate bugs were caught")

Run python mutations.py:

Retry-After rounded down                         caught by test_retry_after_header_rounds_up_and_never_says_zero
bucket never caps its tokens                     caught by test_never_more_than_burst_plus_rate_times_time[bucket]
a refused request still spends a token           caught by test_retry_after_is_neither_too_short_nor_too_long[bucket]
GCRA forgets max(tat, now)                       caught by test_never_more_than_burst_plus_rate_times_time[gcra]
a refused request pushes GCRA's clock forward    caught by test_retry_after_is_neither_too_short_nor_too_long[gcra]
purge drops active clients                       caught by test_purging_never_changes_a_decision[bucket]
the background purge does nothing                caught by test_the_app_purges_idle_clients_in_the_background
IPv6 clients keyed by full address               caught by test_client_key[2001:db8:abcd:12::1-2001:db8:abcd:12::/64]
lifespan scope reaches the key function          caught by test_lifespan_events_pass_through_the_middleware
CORS added inside the limiter                    caught by test_a_refusal_keeps_its_cors_headers_when_cors_wraps_the_limiter

10 of 10 deliberate bugs were caught

All ten are caught, and the failing test names read like a bug report. Rounding Retry-After down is caught by the header test. A bucket that never caps its tokens is caught by the burst-plus-rate property. A bucket that spends a token on a refused request, and a GCRA that moves its schedule forward on a refusal, are both caught by the honest-wait property: the more a client retries, the longer its penalty, which is exactly what a rate limiter should not do. Forgetting the max in GCRA is caught by the burst property, and a purge that deletes busy clients by the purge-invariance property. The last four are about the plumbing rather than the algorithm: the background purge that does nothing, IPv6 addresses keyed individually, the middleware choking on a lifespan scope, and CORS placed inside the limiter. If one of them had survived, that would be a test to write.

How to confirm the whole thing works

From the project folder, with the virtual environment active and ports 8000 and 8121 to 8126 free, run each step and then the checks:

$ python step02_bucket.py
$ python step03_gcra.py
$ python step04_memory.py
$ python step05_retry_after.py
$ python step06_live_demo.py
$ python step07_proxy_matrix.py
$ python step08_ipv6.py
$ python -m pytest -q
$ python -m pytest -q --hypothesis-profile=thorough
$ python mutations.py

You should see, in order: the seven-request table from Step 2; 100,000 identical decisions under exact arithmetic; GCRA at 62 bytes per client and 200,000 flooded clients purged down to one; the rounded-down header costing 8, 16 and 6 requests against 2; the curl session from Step 6; the six proxy cases A to F (20 of 20, 20 of 20, 5 of 20, 20 of 20, 5 of 15, 15 of 15); 1,000 of 1,000 against 5 of 1,000 for IPv6; 29 passing tests twice; and ten of ten deliberate bugs caught. For your own deployment, repeat the six-case check from Step 7 through your real proxy, because the right answer depends on the proxy’s configuration more than on your code.

Common mistakes, with the numbers from this tutorial

  • Believing X-Forwarded-For. 20 of 20 forged requests got through (case A). Let the server resolve the address and trust only your own proxies.
  • Trusting every proxy address. A trust list of * also let 20 of 20 through (case D).
  • Not configuring the app behind a proxy. Three honest users shared one budget and got 5 of 15 (case E).
  • Keying IPv6 clients by address. 1,000 of 1,000 requests from one /64 passed.
  • Rounding Retry-After down. The client sent 8, 16 and 6 requests instead of 2.
  • Never forgetting clients. 200,000 one-request clients stayed in memory until a purge removed them.
  • Testing with floats and a real clock. Slow, flaky, and unable to check exact boundaries.

Where to go next

The limiter is correct for one process. If you run several worker processes or several servers, each has its own dictionary, so a client’s effective budget is multiplied by the number of workers it reaches. A shared store (Redis is the usual choice) or limits at the edge fix that; keep the interface you built here, because check(key) hides where the state lives. I did not measure a multi-worker setup in this tutorial, so test yours.

From here you could give different routes different budgets, key authenticated requests by account instead of address, and advertise RateLimit-Policy and RateLimit once the IETF draft settles. Overload that comes from everyone at once, rather than from one client, needs a different tool, which our tutorial on backpressure and load shedding covers, and the earlier rate limiter tutorial shows the window-based algorithms and the thread-safety problem that this one skipped.

Tags:

AlgorithmsAPI SecurityFastAPIProperty-Based TestingPythonRate Limiting

Share

A thumb-wheel rubber stamp resting on an ink pad, with rows of letter and number wheels that can be set to print any text
Previous Post

The Agentic AI Foundation’s Four New Projects Turn “Vendor-Neutral” Into a Six-Step Process, and Two Donors’ Own Applications Say They Still Lead Their Projects

Adelie penguins standing on a tall block of blue-white ice, one at the very top, under a grey sky
Next Post

A Linux Kernel Local-Root Bug Has Public Exploit Code and No Merged Fix, After Zero Day Initiative Reports Reached Public Mailing Lists

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
28 Oct
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
Trending
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
October 27, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026