Rate Limit Headers and 429 Responses in FastAPI

Key takeaways:

  • Send Retry-After on every 429. Without it, clients guess, and their guesses synchronise.
  • Send X-RateLimit-Limit, -Remaining and -Reset on successful responses too, so callers can pace themselves.
  • 429 is "you exceeded your quota"; 503 is "the server is overloaded". Do not use one to mean the other.
  • The body should name the limit, the window and the wait — a bare {"detail": "Too Many Requests"} costs your users a support ticket.
  • Emit the headers from the same middleware that makes the decision, so allowed and rejected paths cannot drift.

Your API started rejecting a client's traffic and their engineer wants to know what the limit is, how much of it they have left, and when they can try again. If the only thing your 429 carries is a status code, they will find out by retrying in a tight loop. This page is part of Rate Limiting and Throttling and covers the response side of a limiter: what to send, and why each field earns its place.

Headers on allowed and rejected responses A limiter decision splitting into an allowed 200 response carrying limit and remaining headers, and a rejected 429 carrying the same headers plus Retry-After and a problem details body. limiter take a token 200 OK X-RateLimit-Limit: 3 X-RateLimit-Remaining: 2 429 Too Many Requests X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1060 Retry-After: 60
Both branches carry the quota headers; only the rejected one adds Retry-After and an explanatory body.

What Each Field Means

Retry-After is the one header a client can act on without any prior knowledge of your API. It is defined by RFC 9110 for 429, 503 and 3xx responses, and takes either a delay in seconds (Retry-After: 60) or an HTTP-date. Send seconds — dates require the client to trust its own clock relative to yours. Round up: telling a caller to come back in 59.4 seconds when the window resets at 60 guarantees a wasted request.

X-RateLimit-Limit is the ceiling for the window. X-RateLimit-Remaining is how many requests are left in it. X-RateLimit-Reset is when the window resets — and this is the field everyone disagrees about. GitHub sends a Unix timestamp; other APIs send seconds-until-reset. Both are common, neither is standard, and a client integrating with several APIs will get it wrong at least once. Document which you send in the same place you document the limit.

None of the X-RateLimit-* trio is standardised. The IETF's draft on rate limit header fields proposes RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset without the X- prefix, alongside a structured RateLimit field. Until that stabilises, the X- names remain the pragmatic choice because that is what client libraries look for. Whichever you pick, be consistent across every endpoint.

429 Versus 503

The distinction is about whose fault it is, and it changes what the client should do.

429 Too Many Requests means this particular caller exceeded a quota that applies to them. Other callers are unaffected. The remedy is on the client side: slow down, and the response tells them by how much. A 429 is not an incident on your side — it is the system working.

503 Service Unavailable means the server cannot serve requests right now, for reasons that have nothing to do with which client is asking. A dependency is down, a queue is saturated, a deploy is in progress. Everyone gets it. Retry-After is meaningful here too, and it is a hint about recovery rather than about quota.

Getting this backwards has real consequences. Sending 429 for a server-side overload tells every client that they are misbehaving, so well-implemented clients back off individually while your dashboards show a client-error spike rather than a server problem — the error budget is charged to the wrong side, and the alert that should have paged you does not. In the other direction, sending 503 for quota exhaustion makes a single abusive client look like an outage.

There is a third case worth naming: shedding load at the edge when a downstream is failing. That is neither the client's quota nor a total outage; 503 with a short Retry-After is the honest answer.

A Limiter That Emits Real Headers

The example below is a fixed-window counter with a frozen clock, so the numbers in the transcript are reproducible. The decision and the headers live in one middleware, which is the structural point — an allowed request and a rejected one cannot disagree about the quota if the same code computes both.

import math

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

LIMIT = 3            # Requests allowed per window.
WINDOW = 60.0        # Window length in seconds.
NOW = {"t": 1000.0}  # A frozen clock: real code uses time.monotonic().

BUCKETS: dict[str, dict[str, float]] = {}


def take_token(key: str) -> tuple[bool, int, float]:
    """Fixed-window counter. Returns (allowed, remaining, reset_epoch)."""
    now = NOW["t"]
    bucket = BUCKETS.get(key)
    if bucket is None or now >= bucket["reset"]:
        bucket = {"used": 0.0, "reset": now + WINDOW}
        BUCKETS[key] = bucket
    if bucket["used"] >= LIMIT:
        return False, 0, bucket["reset"]
    bucket["used"] += 1
    return True, int(LIMIT - bucket["used"]), bucket["reset"]


@app.middleware("http")
async def rate_limit(request: Request, call_next):
    if request.url.path in {"/sent-headers", "/docs", "/openapi.json"}:
        return await call_next(request)   # Never rate-limit the docs or the inspection route.

    key = request.client.host if request.client else "anon"
    allowed, remaining, reset = take_token(key)
    common = {
        "X-RateLimit-Limit": str(LIMIT),
        "X-RateLimit-Remaining": str(remaining),
        "X-RateLimit-Reset": str(int(reset)),
    }
    if not allowed:
        retry_after = max(1, math.ceil(reset - NOW["t"]))
        return JSONResponse(
            status_code=429,
            headers={**common, "Retry-After": str(retry_after)},
            content={
                "type": "https://example.com/errors/rate-limited",
                "title": "Too Many Requests",
                "status": 429,
                "detail": f"Limit of {LIMIT} requests per {int(WINDOW)}s exceeded for this key.",
                "retry_after_seconds": retry_after,
                "limit": LIMIT,
                "window_seconds": int(WINDOW),
            },
        )
    response = await call_next(request)
    response.headers.update(common)
    return response

The fourth request in the window produces this — a real 429 body:

$ GET /search
429 Too Many Requests
{
  "type": "https://example.com/errors/rate-limited",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "Limit of 3 requests per 60s exceeded for this key.",
  "retry_after_seconds": 60,
  "limit": 3,
  "window_seconds": 60
}

And these are the headers that actually left the process, recorded by a pure-ASGI wrapper around the whole stack so nothing could be added or lost afterwards:

$ GET /sent-headers
200 OK
{
  "responses": [
    {
      "path": "/search",
      "status": 200,
      "headers": {
        "x-ratelimit-limit": "3",
        "x-ratelimit-remaining": "2",
        "x-ratelimit-reset": "1060"
      }
    },
    {
      "path": "/search",
      "status": 200,
      "headers": {
        "x-ratelimit-limit": "3",
        "x-ratelimit-remaining": "1",
        "x-ratelimit-reset": "1060"
      }
    },
    {
      "path": "/search",
      "status": 200,
      "headers": {
        "x-ratelimit-limit": "3",
        "x-ratelimit-remaining": "0",
        "x-ratelimit-reset": "1060"
      }
    },
    {
      "path": "/search",
      "status": 429,
      "headers": {
        "x-ratelimit-limit": "3",
        "x-ratelimit-remaining": "0",
        "x-ratelimit-reset": "1060",
        "retry-after": "60"
      }
    }
  ]
}

The remaining count walks 2, 1, 0 across the successful requests — a client watching that header knows it is about to be limited before it is, which is the entire value of sending quota headers on 200s. The 429 repeats the same quota headers and adds Retry-After: 60. Note the header names are lowercase in the transcript because that is how they exist on the wire in HTTP/2 and in the ASGI message; HTTP header names are case-insensitive, so clients matching on X-RateLimit-Remaining still find them.

Designing the Error Body

The body above follows RFC 9457 problem details: a type URI that identifies the error class, a human-readable title, the status, and a detail describing this occurrence. The extra members (limit, window_seconds, retry_after_seconds) are permitted by the spec and are what make the response machine-actionable without header parsing.

Three things not to put in it. Do not include the identity of other clients or global capacity numbers — that is information disclosure about your infrastructure. Do not include a stack trace or an internal limiter key. And do not vary the wording per request; a stable type URI is what lets a client branch on the error programmatically, and a detail string that changes on every occurrence defeats log aggregation.

If your API already returns FastAPI's default {"detail": "..."} shape everywhere, keep the shape and enrich it rather than introducing a second error format for one status code. Consistency across an API is worth more than conformance to a spec on one endpoint; the general approach is in global exception handlers for consistent API responses.

Where the Limiter Belongs

Middleware, for this shape of limiter, because it needs to touch every response including the rejected ones. That is what the example does and it is the simplest thing that keeps both paths consistent.

A dependency is the better fit when limits are per-route and per-user rather than global — different quotas on a search endpoint and an export endpoint, say. The trade-off is that a dependency raising HTTPException(429, headers={...}) cannot easily add quota headers to the successful responses, so you end up with headers on rejections only. If you go that way, either accept that or pair the dependency with a thin middleware that reads a value the dependency stashed on request.state.

Either way, the counter itself must be shared across workers. An in-process dict, like the one above, means four uvicorn workers enforce four separate limits and the effective ceiling is four times what you documented. Redis is the standard answer — see FastAPI rate limiting with Redis and SlowAPI — and the algorithm choice, particularly the burst behaviour that a fixed window gets wrong at window boundaries, is covered in per-user token bucket throttling.

Verification

Assert the headers, not just the status code. A limiter that returns 429 with nothing else passes a naive test and fails its users:

def test_429_carries_retry_after_and_quota(client):
    for _ in range(3):
        assert client.get("/search").status_code == 200
    response = client.get("/search")
    assert response.status_code == 429
    assert response.headers["retry-after"] == "60"
    assert response.headers["x-ratelimit-remaining"] == "0"
    assert response.json()["limit"] == 3


def test_successful_responses_expose_remaining(client):
    first = client.get("/search")
    assert first.headers["x-ratelimit-remaining"] == "2"

In production, count 429s per client key as a metric — the technique is in Prometheus metrics for FastAPI, with the caveat that the client key must not become a metric label. A sustained 429 rate on one key is either an abusive client or a limit set too low for a legitimate integration, and you cannot tell which without looking.

Trade-offs and When Not To

Quota headers leak information. X-RateLimit-Limit tells an attacker exactly how much probing they get per window, and Remaining tells them how much they have left. For a public API that is a fair trade for usability. For an authentication endpoint being brute-forced it is assistance; there, send the 429 with Retry-After and nothing else.

Computing headers on every successful response costs a limiter read on the hot path even when nothing is near the limit. With a Redis-backed limiter that is a round trip per request, which may be more than you want to pay on your highest-volume endpoints — a common compromise is quota headers on write endpoints and 429-only headers on reads.

Finally, Retry-After is a hint, not a contract. Clients ignore it, and a client that retries immediately gets another 429, which is fine and costs you one cheap rejection. What is not fine is a fleet of clients that all wake at exactly the moment you told them to; add a little jitter on the server side by varying the advertised delay slightly, or accept the thundering herd at each window boundary.

FAQ

What is the difference between 429 and 503? 429 means this client exceeded its own quota and other clients are unaffected; the fix is for the caller to slow down. 503 means the server as a whole cannot serve right now. Sending 429 for a server-side overload misleads clients into thinking they did something wrong.

Is Retry-After required on a 429 response? Not by the specification, but omit it and every client has to guess. Retry-After takes either a number of seconds or an HTTP date, and it turns retry behaviour from guesswork into a schedule the server controls.

Are X-RateLimit headers standardised? No. The X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset trio is a widely copied convention, not a standard. An IETF draft defines RateLimit-Limit and RateLimit-Remaining without the prefix; pick one shape and document it.

Should rate limit headers appear on successful responses too? Yes. Sending the remaining quota on every response lets a well-behaved client pace itself before it hits the limit, which is far better than discovering the ceiling by being rejected.

Why does HTTPException make it awkward to set rate limit headers? It does accept a headers argument, but a limiter usually needs to add headers to allowed responses too, and that happens in middleware. Returning a JSONResponse directly from the middleware keeps both paths in one place.