Rate and concurrency limits¶
Requests per window and requests at a time, per client or shared, across processes.
In fastapi_alcazaba.rate_limit.
In the guide: Rate limiting, Concurrency limits. In an app: the example.
fastapi_alcazaba.rate_limit.FastAPIRateLimiter
¶
FastAPIRateLimiter(times: int = 1, milliseconds: int = 0, seconds: int = 0, minutes: int = 0, hours: int = 0, identifier: Identifier | None = None, callback: OnLimit | None = None, *, name: str | None = None, cache: Cache | None = None, fail_open: bool = True, cost: int = 1, headers: bool = True)
FastAPI dependency that lets each client make times requests per window to a route; the
next ones get a 429 with Retry-After until the window slides past enough of them.
@app.get("/search", dependencies=[Depends(FastAPIRateLimiter(times=10, minutes=1))])
The counters live in the cache's backend (the cache given, else the one get_cache
returns), so every process shares them, and go through its timeout and circuit breaker. Their
keys are SHA-256 digests: identities never reach the backend in clear.
It's a sliding window: the count of the previous window is added in proportion to how much of
it is still less than a window ago, so no client doubles its rate around the boundary between
two windows. That assumes the previous window's requests were spread evenly; when they came at
its end, it's a little stricter than times per window. Requests over the limit don't count:
checking and counting is one atomic operation where the backend allows it. Each process then
refuses a client with nothing left without asking the backend, until something fits again, so
a flood doesn't reach the backend.
Responses carry the RateLimit and RateLimit-Policy headers of the IETF draft
(draft-ietf-httpapi-ratelimit-headers), for clients to slow down before they get a 429.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
times
|
int
|
requests allowed per window. |
1
|
milliseconds
|
int
|
the window is the sum of |
0
|
identifier
|
Identifier | None
|
who the client is; its address by default ( |
None
|
callback
|
OnLimit | None
|
called with the request and the seconds to wait when a request is over the
limit; |
None
|
name
|
str | None
|
the routes using limiters with this name and the same rate share one budget; by default each route (its methods and path template) has its own. Also the policy's name in the headers. |
None
|
cache
|
Cache | None
|
the Cache whose backend keeps the counters. |
None
|
fail_open
|
bool
|
when the backend fails, let requests through (the default, as the cache does) or refuse them with a 503. |
True
|
cost
|
int
|
how much of the budget each request takes: an expensive route can take more of a
budget it shares with others ( |
1
|
headers
|
bool
|
send the |
True
|
forget
¶
forget() -> None
Forget the clients this process refuses without asking the backend (those with nothing left): their next requests ask it again. The counters in the backend stay. It's done by itself when the cache changes, as each test's lifespan sets one up.
hit
async
¶
hit(connection: HTTPConnection, key: str = '', cost: int | None = None) -> float | None
Count a request: None if it's within the limit, else the seconds until it would be
(and it isn't counted). Call it yourself to limit something else than whole requests:
each message of a WebSocket, or the login attempts to an account (key=username).
cost is how much of the budget it takes (the limiter's cost by default).
fastapi_alcazaba.rate_limit.client_ip
¶
client_ip(connection: HTTPConnection) -> str
The client's address as the ASGI server reports it, IPv6 addresses by their /64 network:
that's what an ISP gives a single customer, who can use any of its 2⁶⁴ addresses. Behind a
proxy, let the server take it from the proxy's headers (uvicorn --proxy-headers
--forwarded-allow-ips=<proxy address>): read here, X-Forwarded-For would let every client
choose who it is.
fastapi_alcazaba.rate_limit.client_network
¶
client_network(ipv4_prefix: int = 32, ipv6_prefix: int = 64) -> Identifier
An identifier like client_ip with other network sizes: client_network(24, 48) counts a
whole IPv4 /24 or IPv6 /48 as one client.
fastapi_alcazaba.rate_limit.too_many_requests
¶
too_many_requests(connection: HTTPConnection, retry_after: float, headers: Mapping[str, str] | None = None) -> NoReturn
The default reaction to a request over the limit: a 429 with Retry-After (and headers),
or closing a WebSocket with 1008 (policy violation).
fastapi_alcazaba.rate_limit.FastAPIConcurrencyLimiter
¶
FastAPIConcurrencyLimiter(limit: int, *, name: str | None = None, identifier: Identifier | None = None, wait: float = 0.0, ttl: float = 60.0, cache: Cache | None = None, fail_open: bool = True, retry_after: int = 1)
FastAPI dependency (with yield) that lets at most limit requests run at the same time;
the slot is released when the request is done (see the module's docstring for streaming).
@app.post("/summaries", dependencies=[Depends(FastAPIConcurrencyLimiter(8, wait=5))])
The slots live in the cache's backend (the cache given, else the one get_cache returns),
so every process shares them. Their keys are SHA-256 digests: identities never reach the
backend in clear.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
limit
|
int
|
requests that may run at the same time, 1 to MAX_LIMIT. |
required |
name
|
str | None
|
routes whose limiters have this name and the same |
None
|
identifier
|
Identifier | None
|
who the client is (e.g. |
None
|
wait
|
float
|
seconds to wait for a slot when they're all taken; 0 tries once. |
0.0
|
ttl
|
float
|
seconds after which the slot of a process that died is freed; renewed every
|
60.0
|
cache
|
Cache | None
|
the Cache whose backend keeps the slots. |
None
|
fail_open
|
bool
|
when the backend fails, count the slots in each process instead (so at most
|
True
|
retry_after
|
int
|
the |
1
|
slot
async
¶
slot(key: str = '', *, connection: HTTPConnection | None = None) -> AsyncIterator[None]
Hold a slot around a block, outside a dependency:
async with limiter.slot(key=tenant_id):
await render(report)
key gives each value its own limit slots. With a connection, the route and the
identifier apply as in the dependency; without one, the limiter needs a name. Raises
ConcurrencyLimitExceeded when no slot frees within wait seconds, and BackendUnavailable
when the backend fails and fail_open is False.
fastapi_alcazaba.rate_limit.ConcurrencyLimitExceeded
¶
ConcurrencyLimitExceeded(status_code: int, retry_after: int)
Bases: HTTPException
Every slot stayed taken for as long as the limiter waits. FastAPI answers it with a 503 (a
429 when the limit is per client) and Retry-After.