jfastframework
Docs Español PyPI GitHub

Rate limiting

A login endpoint with no rate limit is a credential-stuffing target. This plugin is the limit, and it lives in the application rather than at the edge.

[plugins]
enabled = ["observability", "cache", "auth", "ratelimit"]

[plugin.ratelimit]
limit = 100
window = 60.0

Off unless you enable it. A limit is a policy decision with a wrong answer for somebody, and a service that starts refusing traffic because a default turned itself on is worse than one with no limit.

This is the application-layer limiter, not a replacement for the edge

An edge limiter — Caddy, an ingress, Cloudflare — sees addresses and paths. This one sees the things an edge cannot:

The two are complementary and you want both. The edge stops a volumetric flood before it reaches a worker; this stops one customer from spending another's quota, and it is the only layer that can tell those apart. Note the boundary in the other direction: enforcement is a per-route dependency, so a request that matches no route — a 404 flood, a scan — is not limited here. That is the edge's job.

Algorithm: token bucket, evaluated in Redis

A fixed window lets a caller spend a full quota at 11:59:59 and another at 12:00:00, so the real ceiling is twice the configured one across every boundary. A token bucket has no boundary. It refills continuously at limit / window tokens per second, and its burst size is an explicit knob instead of an accident of where the clock happens to tick. It also costs one hash per identity, where a sliding-window log costs one entry per request.

Setting Meaning
limit Requests per window. Also the refill rate: limit / window per second.
window Seconds to refill an empty bucket completely.
burst Bucket size. Defaults to limit — a full window may arrive at once.

Why a Lua script and not GET then SET

Two requests that read the same counter before either writes both pass. That is not a rare interleaving; it is precisely the load a limiter exists for, and a limiter that leaks under load is decoration. Redis runs a script to completion with nothing else interleaved, so the read, the decision and the write are one indivisible step:

local clock = redis.call('TIME')
local now = tonumber(clock[1]) + tonumber(clock[2]) / 1000000

local stored = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(stored[1])
-- refill, then spend, then store -- all before anyone else runs
tokens = math.min(burst, tokens + (now - ts) * rate)
if tokens >= cost then allowed = 1; tokens = tokens - cost end
redis.call('HSET', key, 'tokens', tokens, 'ts', now)

Two details that are not incidental:

The bucket is in Redis and not in the process because an in-process counter is wrong the moment there are two workers, and both jfast dev and the generated compose run more than one. The connection comes from the cache plugin — hence requires = ["cache"] — rather than a second pool to the same server.

What the limit is keyed on

key_sources is an order of specificity, and the first source that answers wins.

Source Key Notes
principal sub:<subject> From the verified token. The right answer when there is one.
api_key key:<sha256 prefix> Off unless api_key_header is set.
tenant tenant:<id> Anonymous traffic, limited per tenant.
ip ip:<address> The fallback.

Keyed on IP, one office behind one NAT is one caller. Keyed on the subject, a compromised account cannot spend its neighbours' quota. Falling back to the tenant limits anonymous traffic per tenant rather than globally — which also means one abusive caller can spend that tenant's anonymous quota. Drop tenant from key_sources where that is the wrong trade.

api_key is off by default on purpose. An unverified header is chosen by the caller, and a caller who can choose their own bucket has no limit at all. Set api_key_header only where something upstream has already verified the key.

The IP, behind a proxy

Behind a load balancer every request carries the balancer's address, so a limiter keyed on the socket peer gives the entire internet one shared bucket.

This plugin never parses X-Forwarded-For itself. The header is attacker-written until something that knows the proxy topology has walked it from the right and stopped at the first untrusted hop; a limiter that trusts it raw hands every caller a free bucket per forged hop. The contract is one attribute:

request.state.client_ip = "203.0.113.7"   # written by the trusted-proxy middleware

Until that is set, the socket peer is used, and a request arriving with forwarding headers that nobody resolved logs a warning once, saying that every client behind the proxy is sharing one bucket.

Per-route cost

One global limit is either too tight for the cheap routes or too loose for the expensive ones. Override it per route with a dependency, the same shape require_scopes uses:

from fastapi import Depends
from jfastframework.plugins.builtin.ratelimit import rate_limit

@router.post("/search", dependencies=[Depends(rate_limit(10, 60))])
async def search(): ...

# One combined budget across a family of expensive endpoints.
@router.post("/embed", dependencies=[Depends(rate_limit(30, 60, scope="vectors"))])
async def embed(): ...

# Or keep the shared budget and charge more for this one.
@router.post("/reindex", dependencies=[Depends(rate_limit(100, 60, cost=25))])
async def reindex(): ...

An override replaces the service-wide default on that route rather than stacking with it, and it has its own bucket: spending the /search budget does not touch the default one. Routes sharing a scope share a budget.

The default is installed on every API route at startup, which is when routers have finished mounting. Routes added after the app has started do not get it.

The response

429, with a problem document — the same application/problem+json shape every other failure in a JFast service uses:

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 12
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 47

{
  "type": "about:blank",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "Rate limit of 100 request(s) per 60s exceeded.",
  "instance": "/search",
  "limit": 100,
  "remaining": 0,
  "retry_after": 12,
  "request_id": "01J..."
}

RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset are on successful responses too, so a well-behaved client can slow down before it is refused. Reset is the seconds until the bucket is full again; Retry-After is the seconds until one more request would be allowed, and is never 0 — telling a refused client to retry immediately is the one thing it must not do.

When Redis is down: fail open

The limiter allows the request, logs at WARNING, and reports the service degraded in /ready.

Failing closed turns a cache outage into a total outage: Redis blinks and every endpoint in the fleet starts answering 429, including the ones that were never near a limit. Failing open costs the limit for the duration of the outage. An availability problem is traded for an abuse window, and the abuse window is the cheaper of the two.

What makes that trade defensible is that it is not silent, because "silently" is the entire objection to it:

{
  "status": "degraded",
  "checks": {
    "ratelimit": {
      "healthy": false,
      "critical": false,
      "detail": "failing open: rate limit backend unreachable, no limit is being enforced",
      "meta": {"enforcing": false}
    }
  }
}

/ready still answers 200 — the check is not critical, because taking every replica out of rotation is the outage this design exists to avoid — but it says degraded and names the reason. Alert on it.

Set fail_open = false on a service where the limit matters more than the endpoint does.

Settings

Key Default Meaning
limit 100 Requests per window.
window 60.0 Seconds for a full refill.
burst limit Bucket size.
key_prefix app name Redis key namespace.
key_sources ["principal", "api_key", "tenant", "ip"] Order of specificity.
api_key_header "" Off. Only set it where the key is verified upstream.
fail_open true See above.
exempt_paths probes and docs /health, /ready, /info, /metrics, /docs, /redoc, /openapi.json

Probes are exempt by default and should stay that way: an orchestrator polling /ready every two seconds must never be throttled out of the fleet.

Environment overrides use the JFAST_RATELIMIT_ prefix — JFAST_RATELIMIT_LIMIT=500.