Skip to content

Rate Limit

A RateLimiter hands back a RateLimitResult and stops. Every service then writes the same 429 by hand, and most write it wrong: no Retry-After, no quota, a body that does not match what the rest of the app answers with, and a bucket keyed on the socket peer, which behind an ingress is the ingress.

Take the decision to the edge instead:

from fastapi import FastAPI

from grelmicro import Grelmicro
from grelmicro.http import ErrorResponses, RateLimitedRequests
from grelmicro.providers.redis import RedisProvider
from grelmicro.resilience import RateLimiter, RateLimiterComponent
from grelmicro.security import TrustedProxies

redis = RedisProvider("redis://localhost:6379/0")

burst = RateLimiter.sliding_window("burst", limit=100, window=60)
daily = RateLimiter.sliding_window("daily", limit=10000, window=86400)

micro = Grelmicro(
    uses=[
        RateLimiterComponent(redis),
        ErrorResponses(),
        RateLimitedRequests(
            burst,
            daily,
            trusted=TrustedProxies(["10.0.0.0/8"]),
            exclude=("/livez", "/readyz"),
        ),
    ]
)
app = FastAPI()

micro.install(app)

Every request spends one token of every limiter listed. A burst limit stands beside a daily one, and a request passes both or is turned away by the first that says no.

What the caller is told

Allowed or refused, the response states what is left, in the two fields of the RateLimit header specification:

RateLimit: "burst";r=97;t=42, "daily";r=9713;t=51840
RateLimit-Policy: "burst";q=100;w=60, "daily";q=10000;w=86400

r= is what is left, t= the seconds until it resets, q= the quota and w= the window. Both fields are lists, which is why two policies fit in one answer.

A refusal adds Retry-After and travels the same AdmissionError path every other rejection takes, so it is rendered by the registered ErrorResponses as application/problem+json, and one except still covers every way a caller is turned away.

legacy_headers=True adds X-RateLimit-Limit, -Remaining and -Reset, the superseded fields, for a client that reads only those. They carry whichever limiter is closest to being spent.

A token bucket states no policy

RateLimit-Policy describes a quota over a window. A token bucket refills continuously, so its reset is the wait for the next token rather than the edge of a window, and a policy built from it would promise a reset that never comes. A window shorter than a second is left out for the same reason: written as the whole second the header takes, it would publish half the rate it enforces. Both state RateLimit and nothing else.

Who is metered

The bucket is the resolved client address: the socket peer, unless a proxy you trust vouched for another one.

RateLimitedRequests(burst, trusted=TrustedProxies(["10.0.0.0/8"]))

That is not a detail. A limiter keyed on the peer meters the ingress, so one caller spends everybody's budget. A limiter keyed on a raw X-Forwarded-For meters whatever the caller wrote there, so nobody spends anything. TrustedProxies is what tells the two apart, and it is required: without it, and without a key of your own, the component is refused where it is written rather than metering the wrong thing.

The address is resolved once per request and left where ClientAddressMiddleware leaves it, so a route that meters itself reads the same caller.

A bucket is only as good as its key

A key a caller controls is a key a caller can rotate. Behind a trusted proxy the address is vouched for and cannot be, which is the point of the trusted set. In front of one, a spoofed source address is a spoofed bucket, and rate limiting is not the tool that stops that.

key= replaces the whole thing, for a service that meters by tenant or by API key. It takes the ASGI scope and returns the bucket, or None to leave a request unmetered, on the component and on a route alike.

Metering one route

RateLimitedRequests meters the app. A route that costs more than the rest, or has a quota of its own, declares it:

from grelmicro.integrations.fastapi import RateLimited


@app.get("/search", dependencies=[RateLimited(search, cost=5)])
async def do_search(query: str) -> list[Hit]: ...

A route that returns a Response of its own needs the component registered for those fields to reach the wire, because a framework merges what a dependency states into the response it builds itself and not into one a handler already built. The tokens are spent either way.

Both budgets are spent, and both are stated in the answer: the standard fields are lists, so the route's policy and the app's join into one, and two declarations on one route join the same way. One limiter metered twice is one policy, stated with the smaller count, which is the one a client has to pace itself off. The superseded X-RateLimit-* fields are single integers that cannot be read twice, so the meter with less left answers for both: a client reading only those is told the budget that refuses it first.

cost= is how many tokens the call takes, for an endpoint worth more than one. A cost no limiter could ever serve is refused where it is written rather than failing every request, on the route and on the app alike. A route that finds no caller to meter says so once rather than metering nothing in silence.

Waiting instead of refusing

max_wait=0.0 refuses as soon as the budget is spent, which is the default, because waiting at the edge holds the connection open and a client that retries is cheaper than a server that queues.

Give a budget where a short wait is better than a retry:

RateLimitedRequests(burst, trusted=..., max_wait=0.5)

A budget that runs out is still a refusal: the caller is answered 429 with the same headers, not an error.

Where it sits

Register it before CachedResponses() and IdempotentRequests(). Registration order is wrapping order, and a cache hit or an idempotent replay answers without reaching what sits inside it: put the limiter outside, or a caller spends no tokens on the requests it repeats.

Which endpoints it applies to

include names the paths metered and exclude names the ones left alone, the same two words every grelmicro middleware takes. Where a rule applies covers the matching and what wins.

What is never metered

exclude= names the paths that pass through, and takes the same patterns every grelmicro middleware takes. Put the probes there: Kubernetes polls /livez and /readyz every few seconds forever, and that is not a caller's budget to spend.

A request whose caller cannot be read at all is let through rather than metered under a bucket that is not theirs, and the reason is logged once.

So is one whose caller the walk could only take as far as your own proxy. That means trusted= does not describe this deployment, and metering every caller behind that proxy as one would let any of them spend the budget of all of them. It is logged once, as a configuration to fix.

When a setting stops describing the deployment

Two of them are read again on every request, and both are answered by serving the caller and saying so once, because a number that changed must not take the service down with it: a quota reconfigured below the cost a request spends, and a trusted= set that resolves nothing but your own proxy.

When the backend is down

RateLimiter decides that, not this middleware. A limiter built with fail_open=True serves the request and reports a full quota, so the headers say a budget nobody counted. A limiter built without it raises, and the app answers as it does for any dependency failure. One setting, on the limiter, wherever it is used.

In the schema

A registered RateLimitedRequests documents the 429, and the RateLimit, RateLimit-Policy and Retry-After fields it carries, on every operation in the OpenAPI schema. So a generated client has a branch for it.

Every operation, not the metered ones. Which paths are metered is tuned while the service runs, and the schema is built once when the app starts, so naming the current set would publish a document that stops being true the first time an operator narrows it. A 429 says only what a client may be answered with, never what it must send, so stating it everywhere stays true whichever paths are metered.

Pass openapi=False to leave the schema alone.

Options

Every option of RateLimitMiddleware is taken by RateLimitedRequests and forwarded, so a registered component and a hand-added middleware answer the same.

Option What it does
*limiters the limiters every request spends
trusted the proxies whose forwarded entries may be believed
key builds the bucket key itself
cost tokens one request spends of each
max_wait seconds a throttled request waits before it is refused
include paths metered, empty means every path
exclude paths never metered, whatever include says
legacy_headers also send the superseded X-RateLimit-* fields
openapi describe the 429 on every operation in the schema
name keep two sets of rules apart on one app