FastAPI endpoints rate limited with slowapi, tested with TestClient: working versions (tested 2026-10-15)
Add per-route and per-API-key rate limits to FastAPI with slowapi, then test 429 behavior offline with TestClient and the in-memory store.
passed5 passed · 0 failed2026-10-15 13:21 UTC- Status
- passed
- Last passed
- 2026-10-15 13:21 UTC
- Tests
- 5 / 5
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38741369695
The code
Exactly the files that passed. Your agent can pull them with get_recipe("fastapi-slowapi-rate-limit") (install), or see them on GitHub.
fastapi==0.143.1 slowapi==0.1.10 httpx==0.28.1 pytest==9.1.1
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
def api_key_or_ip(request: Request) -> str:
return request.headers.get("X-API-Key") or get_remote_address(request)
limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.get("/ping")
@limiter.limit("3/minute")
def ping(request: Request):
# slowapi needs the Request in the signature to read the client address
return {"ok": True}
@app.get("/search")
@limiter.limit("2/minute", key_func=api_key_or_ip)
def search(request: Request, q: str = ""):
return {"q": q}
import pytest
from fastapi.testclient import TestClient
from app import app, limiter
@pytest.fixture(autouse=True)
def fresh_limits():
# the memory store is process-wide, so clear it before and after every test
limiter.reset()
limiter.enabled = True
yield
limiter.reset()
limiter.enabled = True
@pytest.fixture
def client():
with TestClient(app) as c:
yield c
def test_allows_up_to_limit(client):
assert [client.get("/ping").status_code for _ in range(3)] == [200, 200, 200]
def test_returns_429_over_limit(client):
for _ in range(3):
client.get("/ping")
r = client.get("/ping")
assert r.status_code == 429
assert "Rate limit exceeded" in r.text
def test_keys_have_separate_buckets(client):
# TestClient always comes from the same IP, so only the API key differs here
for _ in range(2):
assert client.get("/search", params={"q": "a"}, headers={"X-API-Key": "k1"}).status_code == 200
assert client.get("/search", params={"q": "a"}, headers={"X-API-Key": "k1"}).status_code == 429
assert client.get("/search", params={"q": "b"}, headers={"X-API-Key": "k2"}).status_code == 200
def test_reset_restores_budget(client):
for _ in range(3):
client.get("/ping")
assert client.get("/ping").status_code == 429
limiter.reset()
assert client.get("/ping").status_code == 200
def test_disabled_limiter_bypasses_limits(client):
limiter.enabled = False
assert all(client.get("/ping").status_code == 200 for _ in range(10))
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
FastAPI config via pydantic-settings as a cached dependency
Load config from env vars with pydantic-settings, inject it into FastAPI routes through a cached dependency, and test it offline with monkeypatch and dependency_overrides.
FastAPI endpoint with Pydantic v2 discriminated unions and Annotated validators
Accept a tagged union of payment methods with Field(discriminator), add Annotated validators, and test the exact 422 error shapes offline with TestClient.
FastAPI Jinja2 full pages vs HTMX partials via HX-Request
Serve a full HTML page to normal browsers and a bare HTML fragment to HTMX requests, chosen by the HX-Request header, with Vary set for caches. Tested offline with the sync TestClient.
FastAPI lifespan state tested with httpx ASGITransport and asgi-lifespan
A FastAPI app that opens a SQLite connection in its lifespan, then test it offline through httpx ASGITransport, with asgi-lifespan running startup and shutdown.