FastAPI Rate-Limit-Header: Die Slowapi-Stolperfalle mit dem Response-Parameter
Ein Fehler bei den FastAPI Rate-Limit-Headern legte ratenbegrenzte POST-Endpoints mit 500ern lahm. Was slowapi's _inject_headers wirklich braucht — und der One-Line-Fix pro Endpoint.
FastAPI Rate-Limit-Header: Die Slowapi-Stolperfalle mit dem Response-Parameter
Letzte Woche begann ein ratenbegrenzter POST-Endpoint in der Produktion 500er zurückzugeben. Nicht einer — alle. Jeder Endpoint, der mit unserem @default_mutation_limit oder @expensive_mutation_limit umwickelt war, scheiterte in dem Moment, in dem ein echter Client ihn aufrief. Der 429-Pfad funktionierte. Der Erfolgspfad lieferte die Daten. Aber überall, wo slowapi versuchte, FastAPI Rate-Limit-Header an die Antwort anzuhängen, brach der Call Stack in parameter response must be an instance of starlette.responses.Response zusammen.
Der Fix dauerte zehn Minuten. Das Verständnis länger. Das ist die Aufzeichnung, die wir gern gelesen hätten, bevor wir den ursprünglichen Decorator ausgerollt haben.
Der Bug tauchte in Wield auf, unserem Recruiting-Pipeline-Produkt (intern die cvflow-Codebasis), wo Rate Limiting rund zwei Dutzend Mutation-Endpoints schützt — Uploads, Dossier-Generierung, Evaluation-Runs — damit nicht ein einziger Client in einer engen Schleife das gesamte Tenant-Kontingent verbrennt. Dasselbe Muster zeigt sich in jedem FastAPI-Service, der slowapi einsetzt, um eine kostenpflichtige LLM-API abzusichern. Wenn du am Endpoint keinen Response-Parameter deklarierst, wird die Library dich irgendwann beissen.
Was slowapi wirklich mit deiner Response macht
Der beworbene Vertrag von slowapi ist einfach: Endpoint mit @limiter.limit("10/minute") dekorieren, Throttling und die Header X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset und Retry-After gratis bekommen. In der Praxis gibt es zwei Codepfade, und die haben sehr unterschiedliche Anforderungen.
Der Throttling-Pfad läuft früh. Innerhalb von _check_request_limit inspiziert slowapi den eingehenden Request, wendet deine key_func an (wir nutzen einen Per-User-Key, damit Admins hinter einem gemeinsamen Corporate-NAT nicht kollektiv ein Kontingent verbrennen), prüft den Bucket und wirft RateLimitExceeded, falls überschritten. Dieser Pfad braucht nichts aus deiner Endpoint-Signatur. Er gibt einen 429 mit JSON-Body zurück, egal ob dein Handler etwas Spezielles deklariert hat oder nicht.
Der Header-Injection-Pfad ist der, der bricht. Nachdem dein Handler zurückgegeben hat, ruft slowapi's async_wrapper _inject_headers auf, um die informativen X-RateLimit-*- und Retry-After-Header anzuhängen. Und _inject_headers erzeugt kein Response-Objekt — es verlangt, dass eines bereits existiert, übergeben als Parameter an deinen Endpoint, damit es response.headers["X-RateLimit-Remaining"] = ... direkt aufrufen kann. In slowapi 0.1.9 löst der Async-Wrapper diesen Parameter über kwargs.get("response") auf, nachdem FastAPI die Dependency Injection abgeschlossen hat. Enthält deine Endpoint-Signatur kein response: Response, hat FastAPI nie eines injiziert, kwargs["response"] ist None, und _inject_headers wirft parameter response must be an instance of starlette.responses.Response.
Das Frustrierende: Das manifestiert sich nur, wenn slowapi tatsächlich Header anhängen würde — also bei einer erfolgreichen, ratenbegrenzten Antwort. Der 429-Pfad läuft früher und kümmert sich um seine eigenen Header. Unit-Tests, die eine Handvoll Requests abfeuern, sehen daher grün aus. Die 500er beginnen in der Produktion, wenn deine echten Nutzer bequem unter dem Limit sitzen und der Erfolgspfad läuft, jedes Mal.
Wer mit FastAPIs Response-Parameter noch nicht vertraut ist: Die offizielle Doku zu Advanced Response Headers beschreibt den Mechanismus. Wenn du response: Response in einer Endpoint-Signatur deklarierst, bittest du FastAPI darum, ein leeres Response-Objekt zu injizieren, an das nachgelagerter Code (deiner oder eine Library) Header anhängen kann, bevor das Framework den eigentlichen Rückgabewert serialisiert. Es ist ein bewusst leichtgewichtiger Vertrag — die Funktion gibt weiterhin ganz normal ein Dict oder Pydantic-Modell zurück — aber genau auf diesen Vertrag verlässt sich slowapi stillschweigend.
Hotfix vs. richtige Lösung
Die erste Reaktion auf "Rate Limiting wirft in der Produktion 500er" ist, die Blutung zu stoppen. Der schnelle Hotfix: slowapi's Header-Injection komplett abschalten:
limiter = Limiter(
key_func=per_user_key,
headers_enabled=False,
)
headers_enabled=False schliesst _inject_headers kurz, bevor es das Response-Objekt überhaupt inspiziert. Throttling bleibt aktiv — der 429-Pfad läuft früher in _check_request_limit und kümmert sich nicht um dieses Flag. Du verlierst die informativen X-RateLimit-Limit / -Remaining / -Reset- und Retry-After-Header auf erfolgreichen Antworten, aber wenn kein Client in deinem Codebase sie liest, ist das ein überlebbarer Verlust für ein paar Stunden.
Derselbe Hotfix landet in deiner Test-Suite. Jede Assertion, dass die 429-Response einen Retry-After-Header trägt, muss gelockert werden, denn Retry-After wird ebenfalls in _inject_headers gesetzt. Wir haben diese Teständerung mit einem Follow-up-Ticket markiert, damit die gelockerte Assertion den Workaround nicht stillschweigend überlebt.
Die richtige Lösung ist der Einzeiler, den du beim ersten Mal hättest ausliefern sollen. An jedem Endpoint, der von einem Rate-Limit-Decorator umwickelt ist, deklarierst du einen Response-Parameter:
from fastapi import APIRouter, Response
@router.post("/documents/upload-batch")
@default_mutation_limit
async def upload_batch(
payload: UploadBatchIn,
response: Response, # <-- das fehlende Stück
user: User = Depends(current_user),
):
...
return UploadBatchOut(...)
Das war's. FastAPI sieht den Parameter response: Response, injiziert während der Dependency-Auflösung eine leere Response, slowapi's _inject_headers findet sie in kwargs, und die X-RateLimit-*- und Retry-After-Header reisen auf jeder ratenbegrenzten Erfolgsantwort mit. Wir haben headers_enabled=True wieder aktiviert, die Retry-After-Assertion in der Rate-Limit-Test-Suite wiederhergestellt und über TestClient bestätigt, dass die Header auf einer Live-Response vorhanden sind.
Die richtige Lösung berührte siebenundzwanzig Endpoints. Lästig, aber mechanisch — ein git grep nach den beiden Decorators liefert die komplette Liste, und die Änderung an jeder Stelle ist dieselbe Zeile in der Signatur. Wir haben das in einem einzigen PR erledigt, damit der zweizeilige Config-Flip zurück auf headers_enabled=True nicht versehentlich der Per-Endpoint-Arbeit davonläuft.
Eine Sache lohnt sich noch zu verkabeln, solange du dabei bist: CORS. Der Limit-Remaining-Header nützt nichts, wenn der Browser-fetch() ihn nicht lesen kann. Wir haben X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset und Retry-After zur CORS-expose_headers-Liste hinzugefügt, damit die SPA dem Nutzer den Live-Kontingentstand zeigen kann, statt auf einen undurchsichtigen 429 zu warten. Wenn du etwas Credit- oder Kontingent-basiertes baust, ist das Freigeben der Header der Unterschied zwischen "Deine Credits sind aufgebraucht" bevor der Nutzer klickt — oder erst danach.
Was das für alle bedeutet, die FastAPI-APIs bauen
Drei Erkenntnisse aus dem Vorfall, in der Reihenfolge, in der sie dir Zeit sparen.
Den Response-Parameter an jedem ratenbegrenzten Endpoint injizieren lassen. Behandle ihn nicht als optional, nur weil slowapi's Quickstart-Beispiele ihn nicht immer zeigen. Der Async-Wrapper verlangt ihn, sobald headers_enabled=True (der Default) ist, und du willst diese Asymmetrie nicht in der Produktion entdecken. Eine Lint-Regel, die deine Rate-Limit-Decorators mit einem response: Response-Parameter paart, ist günstiger als die Postmortem.
Behandle informative Response-Header als Teil des Vertrags, nicht als Deko. RFC 6585 definiert 429 Too Many Requests und den Retry-After-Header aus einem Grund gemeinsam: Clients sollen sich anhand des Headers zurückziehen, nicht anhand eines fest verdrahteten Sleeps. Wenn deine API von einem Frontend konsumiert wird, das du ebenfalls besitzt — die Situation, in der die meisten SaaS-Teams stecken — ist die Freigabe der X-RateLimit-*-Familie via CORS das günstige Upgrade, das aus "die App fühlt sich nahe am Limit kaputt an" ein "die UI zeigt verbleibende Credits in Echtzeit" macht. Es ist dieselbe Intuition, die hinter der Advisor-Pattern-Architektur aus unserem April-Beitrag steckt: günstiges Signal an die richtige Schicht schlägt teures Raten an der falschen.
Per-User key_func zählt mehr als die Limit-Zahl. Der slowapi-Default ist die IP-Adresse des Requests. Hinter einem Corporate-NAT, einem Cloudflare-Proxy oder einem geteilten Mobilfunknetz teilen sich zwanzig nicht verwandte Nutzer denselben Bucket. Wir binden den Key an die authentifizierte User-ID, fallen nur für nicht-authentifizierte Routen (Login, Passwort-Reset) auf die IP zurück und dokumentieren die Wahl im Limiter-Setup. Dieselbe Logik gilt für die DSGVO-sensiblen Routen — unser POST /data-subject/export ist auf 3/Stunde begrenzt und DELETE /data-subject/account auf 5/Stunde, beides pro Nutzer, damit ein einzelner Tenant-Admin unter Last den Export-Endpoint nicht für den Rest der Firma blockiert. Wenn du ähnliche Policies in einem regulierten Kontext aufbaust, findest du das breitere Spielbuch in unserer ChatGPT-Governance-Checkliste.
Die Hauptlehre passt auf eine Haftnotiz: Wenn du einen FastAPI-Endpoint mit einem slowapi-Rate-Limit dekorierst, deklariere einen Response-Parameter am selben Endpoint. Der Grund ist die längere Lektüre wert — Middleware, die ihr Verhalten stillschweigend basierend auf den Typen der Werte umstellt, die sie in kwargs findet, ist eine Bug-Kategorie, die weiterhin frische Opfer finden wird, und dieselbe Form taucht überall dort wieder auf, wo eine Library tief in FastAPIs Dependency-Injection-Maschinerie hineingreift.
Wenn du Rate Limiting für eine LLM-basierte API aufbaust und vor dem Release ein zweites Augenpaar auf die Decorator-Oberfläche, die Key-Funktion und die CORS-Expose-Liste haben möchtest, buche einen kostenlosen AI Potenzial-Check — oder lies, wie wir über KI-Agenten für Schweizer KMU denken, um den breiteren Architektur-Kontext zu bekommen.
wield · Die Recruiting‑Pipeline, die mit deinem Volumen mitskaliert.
CV‑Pipeline mit KI‑gestützter Dossier‑Generierung und Bewertung. Für Recruiter, die hundert Bewerbungen in einer Stunde sortieren — ohne Qualität zu verlieren.