Prima Get an API key

Module kit / Identity and auth

Rate limiting

identity-auth/rate-limiting

Kind backend
Domain identity-auth
Maturity prod
Reusable yes
Stack fastapi-0.118, starlette-basehttpmiddleware, redis-6, uvicorn-0.37
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/error-envelope, _shared/test-layout, _shared/cloudflare-worker-and-estate-deploy
Frontends identity-auth/ui/auth-screens
Reference implementation
  • alchemy_labs/backend/api/dependencies/rate_limit.py
  • alchemy_labs/backend/services/rate_limit_service.py
  • alchemy_labs/backend/utils/net.py
  • alchemy_labs/backend/api/middleware/unified_middleware.py
  • alchemy_labs/backend/api/routers/auth_router.py
  • alchemy_labs/backend/api/routers/primaapi_router.py
  • alchemy_labs/backend/api/routers/webauthn_router.py
  • alchemy_labs/backend/core/config.py
  • alchemy_labs/backend/tests/test_webauthn_multi_rp.py
  • alchemy_labs/backend/tests/test_primaapi_gateway.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Also measured on
  • alchemycloud main b3d34da 2026-09-11
  • prima feature/per-model-pricing cd2c248 2026-09-12
Version 1

Purpose

A caller who floods the API gets 429 with a Retry-After before any handler runs (at fd8558f only a same-origin caller sees it as a 429: the middleware's short-circuit answers below the CORS layer, Gotchas), and a caller who floods a credential-issuing route gets it sooner (at fd8558f without the header: the envelope handler drops it on the dependency tiers, Gotchas), while a client whose analytics beacon shares an office IP with their own login is never locked out by their own traffic. The invariant that makes it trustworthy is that every counter is keyed on the client IP the trusted proxy reported (never the peer address and never the leftmost X-Forwarded-For entry) and lives in Redis shared by every worker, with a per-process dictionary only as the fallback when Redis is absent; and that the limiter fails open by decision, so a Redis outage slows nothing and blocks nothing, which is why the money-bearing and mail-sending routes carry their own fail-closed check instead.

When to use

Include it from every recipe that mounts a public or credential-issuing route, and from every recipe that deploys behind a reverse proxy. Trigger phrases: "too many requests", "429", "Retry-After", "brute force on login", "one mailbox flooded with links", "the client IP is the load balancer", "why did the office get locked out", "is this per worker or shared".

Do not use it for the per-workspace mail relay ceiling (api/routers/mail_router.py 59 to 140, which refuses to send at all without Redis and belongs to messaging-email-sms), for the gateway's spend cap (a 429 from the meter, ai-gateway, B58), for the browser proof-of-work on public forms (forms-leads/form-protection-challenge, B26), for the desk intake's in-process token bucket (services/service_desk/intake_check.py 27 and 62 to 72, per user per worker), or for the aurum and prima-chat variants (B22, B21). The per-domain magic-link budget of the desk sign-in is identity-auth/desk-domain-sign-in (B20); it counts rows, not Redis keys.

Stack assumptions

Pinned at fd8558f (2026-09-12, requirements.txt): fastapi==0.118.2, redis==6.4.0 (redis.asyncio, services/rate_limit_service.py line 40), uvicorn==0.37.0 (its ProxyHeadersMiddleware is part of the trust chain, Gotchas). The middleware is a Starlette BaseHTTPMiddleware (unified_middleware.py 21), mounted once at app_fastapi.py line 523. Foundations assumed: S4 (middleware order, mount_router), S5 (the auth routes this decorates), S7 (the three 429 shapes below; the Retry-After fix lands in its http_exception_handler), S9 (the _fallback_limits.clear() fixture pattern), S13 (the per-app heredoc that carries REDIS_URL).

Environment names and their fail behaviour (core/config.py):

NameDefaultBehaviour
RATE_LIMIT_ENABLEDtrue (804)gates the middleware tier only (unified_middleware.py 43, 82); the route dependencies run regardless; false in production logs a warning and proceeds (817 to 828)
RATE_LIMIT_IP_PER_MINUTE1000 (811)the global per-IP ceiling on /api/*
RATE_LIMIT_PUBLIC_IP_PER_MINUTE240 (815)the stricter ceiling on PUBLIC_PREFIXES
TRUSTED_PROXY_COUNT1 (47)how many X-Forwarded-For hops are trusted; 0 means the peer address is the client (utils/net.py 40 to 44); set nowhere in alchemycloud main b3d34da (2026-09-11; git grep TRUSTED_PROXY_COUNT b3d34da prints nothing), so the default holds
REDIS_ENABLED, REDIS_URLtrue, built from REDIS_HOST/PORT/DB/password when unset (388 to 433)the shared store; disabled or unreachable means the in-memory fallback, per worker
PRIMA_SIGNUP_MAX_PER_IP_PER_HOUR, PRIMA_SIGNUP_MAX_PER_EMAIL_PER_HOUR5, 3 (698 to 699)the signup abuse guard's hourly caps
RATE_LIMIT_PER_MINUTE, RATE_LIMIT_PER_HOUR60, 1000 (805 to 806)read only by the placeholder RateLimitDependency and the unmounted api/middleware/rate_limit.py; dead (Gotchas)

The constants that are not configuration: MAX_REQUESTS = 30 per 60 s per IP per path and EMAIL_MAX_REQUESTS = 10 per 60 s per address per scope (api/dependencies/rate_limit.py 26 to 35, since e895e7d on 2026-09-05); _SIGNUP_RATE_WINDOW_SECONDS = 3600 (primaapi_router.py 543).

Data model

No table. The state is Redis keys with a TTL, one per bucket, written with INCR then EXPIRE on the first hit (rate_limit_service.py 334 to 340):

rate_limit:ip:<client_ip>                       INTEGER, TTL 60 s   the global per-IP ceiling (middleware)
rate_limit:public_ip:<client_ip>                INTEGER, TTL 60 s   the public-prefix ceiling (middleware)
rate_limit:auth:<path>:<client_ip>              INTEGER, TTL 60 s   auth_rate_limit, e.g. rate_limit:auth:/api/auth/login:203.0.113.7
rate_limit:auth_email:<scope>:<email>           INTEGER, TTL 60 s   enforce_email_rate_limit, scope "magic-link" today
rate_limit:prima_signup:ip:<client_ip>          INTEGER, TTL 3600 s the signup guard
rate_limit:prima_signup:email:<email>           INTEGER, TTL 3600 s

The value is the count in the current fixed window; the window starts at the first hit and is not sliding. The fallback is RateLimitService._fallback_limits: Dict[str, {"count": int, "expires": float}] (line 32), one dictionary per worker process, swept of expired keys on every call (374 to 379).

What is not stored: the email in clear anywhere but the key (the key is the lowercased address; the logs carry it too, Gotchas); a ban list (the comment at 222 says a ban "could" be triggered and nothing does); any per-user or per-API-key counter on the live path (check_rate_limit, 68 to 132, the five-layer design, has no caller outside the unmounted api/middleware/rate_limit_middleware.py 78); the api_keys.rate_limit_tier column (models/api_key_model.py 36, default="standard"), which nothing reads at fd8558f (grep -rn rate_limit_tier --include='*.py' . hits the model, the baseline migration and the placeholder docstring in api/middleware/api_key_auth.py 160 to 163), so a builder copying the api_keys DDL from identity-auth/developer-api-keys-and-security-policy (B10) drops it.

The three 429 bodies (S7): the middleware answers {"error": "rate_limit_exceeded", "message": "Too many requests. Please slow down."} as a raw JSONResponse (unified_middleware.py 185 to 192, the middleware shape); a dependency raises HTTPException(429, detail=<string>), rendered by S7's http_exception_handler as the Labs envelope {error, code: "RATE_001", request_id, timestamp, details: null} on every path but one; under PRIMA_GATEWAY_PREFIX (app_fastapi.py 750 to 766, the two /api/prima/v1/accounts* routes) the same exception is rendered as the OpenAI-shaped object {"error": {"message", "type": "rate_limit_error", "param": null, "code": "RATE_001", "request_id"}}, the gateway shape, which F1 never sees and the developer portal's signup screen (F7) parses. Measured 2026-09-12 with TestClient(app_fastapi.app): the 6th POST /api/prima/v1/accounts is 429 with that object and "Too many signup attempts. Please try again later." as its message. All three are raised with Retry-After: <seconds>, floor 1; at fd8558f only the middleware's header reaches the client, because http_exception_handler rebuilds the dependency's 429 with the CORS headers alone (Gotchas), and the middleware's own 429 reaches a cross-origin client with nothing but that header (Gotchas).

API surface

The module owns no route; it decorates routes. Where each tier applies at fd8558f:

TierWhereKeyLimitCode and detail
Middleware, globalevery /api/* path except /api/health* and /api/auth* (unified_middleware.py 81 to 86, 130, 164 to 165)rate_limit:ip:<ip>RATE_LIMIT_IP_PER_MINUTE per 60 s429 rate_limit_exceeded
Middleware, publicon top of the global tier for PUBLIC_PREFIXES (133 to 141): /api/scribe, /api/tracking, /api/f/, /api/public, /api/waitlist, /api/feedback, /api/commerce/self-servicerate_limit:public_ip:<ip>RATE_LIMIT_PUBLIC_IP_PER_MINUTE per 60 s429 rate_limit_exceeded
Depends(auth_rate_limit)POST /api/auth/register (234), /login (436), /forgot-password (879), /reset-password (901), /resend-verification (921), /verify-email (955), /set-password (978), /request-magic-link (1007); POST /api/auth/webauthn/login/begin, /login/begin-discoverable, /login/complete, /login/complete-discoverable (webauthn_router.py 215 to 274) and GET /check (338); POST /api/prima/v1/accounts (639) and /accounts/verify (701)rate_limit:auth:<path>:<ip>30 per 60 s429 "Too many authentication attempts. Please try again later."
_magic_link_email_throttlePOST /api/auth/request-magic-link only (1001 to 1007)rate_limit:auth_email:magic-link:<email>10 per 60 s429 "Too many requests for this email. Please try again later."
_signup_abuse_guardPOST /api/prima/v1/accounts (585 to 636)rate_limit:prima_signup:ip:<ip>, ...:email:<email>5 and 3 per 3600 s429 "Too many signup attempts. Please try again later.", in the gateway shape (app_fastapi.py 750 to 766), as is the auth_rate_limit 429 on the two /api/prima/v1/accounts* routes

Auth dep on the decorated routes: none of them carries a user (they issue credentials), which is why the keys are IP and address, never sub. Tenancy: none; a rate-limit key knows no workspace. Ordering against validation, measured 2026-09-12 with TestClient(app_fastapi.app): FastAPI 0.118 decodes the JSON body before it solves any dependency, so a body that is not JSON answers 422 (json_invalid) and spends nothing (POST /api/auth/login with not json: 422, _fallback_limits stays empty); a well-formed body that fails Pydantic does spend the IP bucket, because auth_rate_limit and _signup_abuse_guard take only request and run before the model is built ({} thirty times, then 429: test_rate_limit_still_bites_a_cross_origin_caller relies on this, test_webauthn_multi_rp.py 769 to 790). _magic_link_email_throttle(request_data: MagicLinkRequest) (auth_router.py 1000 to 1004) takes the parsed model, so the mailbox bucket is spent only on a valid body ({} to request-magic-link: 422, the IP key present, no auth_email key). The signup guard reads the memoised body itself (request.json(), 599 to 604) to find the email before Pydantic sees it.

Response headers the middleware adds to every response it lets through: X-Request-ID and X-Response-Time (unified_middleware.py 103 to 104). No X-RateLimit-Remaining is emitted anywhere, although check_simple_limit returns remaining and reset (352 to 356); a new build surfaces them on the 200 path.

Application logic

UnifiedMiddleware.dispatch (unified_middleware.py 49 to 121), per request:

  1. On the first request only, initialize_services calls rate_limit_service.initialize() when RATE_LIMIT_ENABLED (41 to 47): redis.from_url(settings.REDIS_URL, socket_timeout=1, socket_connect_timeout=1) and one PING; success sets _initialized; any failure leaves redis_client = None and _initialized = False (rate_limit_service.py 34 to 66).
  2. A request id is minted and put on request.state; timing starts; memory pressure is checked and logged.
  3. If the path starts with /api/ and not /api/health: _check_rate_limit (143 to 197). Inside: get_client_ip(request); any AUTH_PREFIXES path returns None untouched (164 to 165); check_simple_limit("rate_limit:ip:<ip>", RATE_LIMIT_IP_PER_MINUTE, 60); if allowed and the path is under PUBLIC_PREFIXES, the second bucket; if not allowed, the 429 JSONResponse with Retry-After. Any exception in the block is logged and the request proceeds (193 to 195).
  4. call_next; the two headers; the response log line; memory stats for slow or AI paths.

get_client_ip (utils/net.py 29 to 67): trusted = max(0, TRUSTED_PROXY_COUNT); with 0, the peer; without an X-Forwarded-For header, the peer; else split on commas, take the entry at index len(parts) - trusted (clamped to 0), which for one trusted hop is the rightmost entry, the one the trusted proxy itself appended and the client cannot forge; if that entry is not an IP literal, the peer.

check_simple_limit(key, limit, window) (rate_limit_service.py 415 to 432): initialise if not yet; dispatch on redis_client. Redis path (318 to 361): INCR; on 1, EXPIRE window; read TTL; a missing TTL is re-armed (the self-heal for a key that lost its expiry, comment 327 to 333); count > limit is a denial carrying retry_after = ttl; any exception is logged and allowed. Memory path (363 to 413): sweep expired entries; a new key is created at count 1 and allowed; count >= limit is a denial with retry_after = expires - now; else increment. Both paths therefore admit exactly limit requests per window and refuse the next.

auth_rate_limit(request) (api/dependencies/rate_limit.py 38 to 60): key rate_limit:auth:{request.url.path}:{client_ip}, check_simple_limit(..., 30, 60), 429 with Retry-After = max(1, retry_after) on denial. enforce_email_rate_limit(email, scope) (63 to 90): strip and lowercase; a blank address is a no-op (so anti-enumeration answers keep their shape); key rate_limit:auth_email:{scope}:{email}, 10 per 60 s. _signup_abuse_guard (primaapi_router.py 585 to 636): read the email from the memoised body, run the invite or allowlist gate when it has an @, then the per-IP and (when there is an email) per-email hourly buckets in that order; the log line names the bucket and the IP and never the email (626 to 629).

Middleware order at fd8558f (app_fastapi.py 446 to 535, added in this order): CORSMiddleware (446), the public-CORS function (483), SecurityHeadersMiddleware (507), TrustedHostMiddleware in production (511), ErrorLoggingMiddleware (518), UnifiedMiddleware (523), WorkspaceMiddleware (528). add_middleware inserts at the outside, so at runtime the last added runs first (S4 §7): a request passes WorkspaceMiddleware, then UnifiedMiddleware, then error logging, host check, security headers, and reaches CORSMiddleware last before routing. app.user_middleware outermost-first, read 2026-09-12: WorkspaceMiddleware, UnifiedMiddleware, ErrorLoggingMiddleware, SecurityHeadersMiddleware, BaseHTTPMiddleware (the public-CORS function), CORSMiddleware. The consequence for this module: the limiter's short-circuit at _check_rate_limit (185 to 192) returns from the second layer, above everything that would add Access-Control-Allow-Origin, the security headers or X-Request-ID (the two headers at 103 to 104 are added only after call_next), so the middleware-tier 429 carries Retry-After and nothing else (Gotchas).

Invariants

  • The client IP is get_client_ip, never request.client.host, in every counter (unified_middleware.py 159, rate_limit.py 43, primaapi_router.py 610); request.client.host appears in the limiter's own file only in the request log line (73).
  • One trusted hop means the rightmost X-Forwarded-For entry; a client that sends its own X-Forwarded-For moves its forged entries left and changes nothing (utils/net.py 50 to 61).
  • /api/auth/* is outside the volumetric tiers (AUTH_PREFIXES, 123 to 130, since e895e7d), so no amount of unrelated traffic from the same IP can 429 a login; the 15 credential-issuing routes carry the per-route bucket; every other /api/auth route is unlimited at fd8558f (Gotchas). On a build to this recipe every anonymous route under the prefix carries auth_rate_limit.
  • A bucket admits limit requests per fixed window and refuses the next, on both stores (current_count > limit at 342; entry['count'] >= limit after the create-at-1 at 396).
  • Every denial is raised with Retry-After with a floor of 1 second (rate_limit.py 59 and 89, unified_middleware.py 191, primaapi_router.py 632); at fd8558f the envelope handler drops it on the three dependency tiers, so only the middleware's reaches the client (Gotchas). On a build to this recipe the client sees it on all four, and a cross-origin script can read it because Retry-After is in expose_headers (Security & tenancy).
  • A 429 from any tier carries the same CORS, security and X-Request-ID headers as the 200 it replaces. At fd8558f the middleware tier breaks this (its 429 carries Retry-After alone, so a cross-origin browser reports a network error and never sees the status, Gotchas); on a build to this recipe the short-circuit response is built with those headers.
  • Authenticated /api/auth routes (/logout, GET and PATCH /me, /workspaces, /change-password, /ws-ticket, the five passkey management routes) are outside every tier by decision: a session is the ceiling, and /change-password verifies the current password under a session that a flood of wrong guesses cannot obtain. The five anonymous undecorated routes are the defect (Gotchas), not these.
  • The limiter fails open: an exception in the Redis path (358 to 361) or in the middleware block (193 to 195) admits the request; the only fail-closed limiter on the tree is the mail relay's, which is not this module.
  • The email throttle is per scope and per address, independent of IP, so rotating IPs does not enlarge a mailbox's budget, and a NAT full of real devices does not shrink it (the docstring at 63 to 73).
  • auth_rate_limit and _signup_abuse_guard run before Pydantic validation: a well-formed body that fails validation still counts. A body that is not JSON answers 422 before any dependency and spends nothing, and _magic_link_email_throttle needs the parsed model, so the mailbox bucket is spent on valid bodies only (API surface).
  • No counter is keyed on a user id or an API key on the live path; the five-layer check_rate_limit is unreachable.

Frontend

identity-auth/ui/auth-screens                  the 429 state on LoginAlchemy, ForgotPassword, MagicLink request and the
                                               passkey flows: readFailure parses the two shapes it can meet (the
                                               middleware's {error, message} and the envelope's {error, code}) and
                                               renders the Retry-After as copy ("in N Sekunden erneut versuchen");
                                               the button stays disabled for that long. At fd8558f the middleware
                                               shape is reachable same-origin only (its 429 has no CORS headers,
                                               Gotchas) and the Retry-After is readable by neither shape from
                                               another origin (not in expose_headers, Security & tenancy)
identity-auth/ui/developer-portal-auth-pages   the signup screen's 429 on POST /api/prima/v1/accounts, which is the
                                               gateway shape {error: {message, type, code}} and not the envelope;
                                               its Gotcha at line 209 records the expose_headers trap first

monitoring-ops/ui/admin-dashboard-and-system-logs (F85, not a file yet) shows the limiter's warnings in the system-logs console (the Rate limit exceeded for IP and Auth rate limit exceeded lines, unified_middleware.py 184, rate_limit.py 53 and 85) and the redis entry of /health/detailed (B166), which at fd8558f is "healthy" if settings.REDIS_ENABLED else "disabled" (api/routers/health_router.py 101): configuration, not liveness, so it says healthy in the exact case this recipe cares about (Redis configured, unreachable, per-worker buckets). Whether the fallback is in use is visible only in the boot log line Redis unavailable (...), using in-memory fallback (rate_limit_service.py 65) and on no endpoint (Gotchas).

Minimum admin and debug surface: the three warning lines above; the boot log line for the fallback, or on a build to this recipe a rate_limit_backend: redis|memory field on GET /health/detailed (at fd8558f the endpoint reports only whether Redis is configured, Gotchas); redis-cli --scan --pattern 'rate_limit:*' on the store, which lists every live bucket with its count and TTL; rate_limit_service.reset_limit(key) (481 to 496) for an operator who must unblock one caller, callable from a shell and from no route.

Security & tenancy

Auth dependency: none of its own; the decorated routes are anonymous by nature. Workspace derivation: none. Fail direction: open, by decision (the inventory's cross-cutting rule, 2026-09-12: "rate limiting fails OPEN"), on three seams: Redis down at boot (memory fallback, per worker), a Redis error mid-request (allowed), an exception in the middleware block (allowed). RATE_LIMIT_ENABLED=false removes the middleware tiers and leaves the route dependencies.

What the fallback costs: with WEB_CONCURRENCY=2 (repo-root Dockerfile 77 and 117; deploy/infomaniak/.env.infomaniak.example 12) two workers each hold their own dictionary, so every ceiling is effectively doubled while Redis is absent (30 per minute per IP per path becomes up to 60), and a restart resets every bucket. The inventory's "x4 on 4 workers" is the WORKERS: int = 4 default in core/config.py line 41, which the Dockerfile does not read; measured 2026-09-12, the shipped process count is 2.

Attacker-controlled inputs: X-Forwarded-For (only the rightmost trusted hop is read; everything left of it is ignored); the request path (part of the auth key, so /api/auth/login and /api/auth/login/ are two buckets and a trailing slash doubles the budget; FastAPI redirects the slash variant with 307 before the dependency runs, which closes it for registered routes); the email in the body (lowercased into a key; a plus tag is not stripped here, only in the desk path, B20, so [email protected] and [email protected] are two mailbox buckets on request-magic-link while delivering to one inbox); the Host header (irrelevant to the key). The signup guard parses the body as JSON before validation and ignores a body it cannot parse (600 to 604), leaving the 422 to the endpoint.

Credentials at rest: none. Logging: the email throttle logs the normalised address at WARNING (rate_limit.py 85); the signup guard deliberately does not (627); a new build logs a fingerprint (B20's email_fingerprint) on both.

The trust chain, measured 2026-09-12: Caddy terminates TLS and appends the peer to X-Forwarded-For (repo-root deploy/infomaniak/Caddyfile.staging 120 to 141: no header_up, Caddy sets the forwarded headers itself); uvicorn runs with --proxy-headers --forwarded-allow-ips='*' (repo-root Dockerfile 117); with * uvicorn 0.37.0's ProxyHeadersMiddleware rewrites request.client.host to x_forwarded_for_hosts[0], the leftmost entry (uvicorn/middleware/proxy_headers.py 131 to 132), which the client chose. get_client_ip does not use it, so the counters are sound; everything else that reads request.client.host is reading an attacker-supplied string (Gotchas).

What a cross-origin caller can read of a 429, measured 2026-09-12: CORSMiddleware is built with expose_headers=["X-Total-Count", "X-Request-ID"] (app_fastapi.py 452), so even on a build with the header merge a browser script on another origin cannot read Retry-After from a dependency 429; F7's Gotcha (developer-portal-auth-pages.md 209) records the same trap from the client side, and this module owns the fix: a build adds Retry-After to that list (S4's CORS block), and the public-CORS function (483 to 505), which answers the PUBLIC_CORS_PREFIXES embeds, adds Access-Control-Expose-Headers: Retry-After, X-Request-ID on its way out. The middleware-tier 429 has the prior problem that it carries no Access-Control-Allow-Origin at all (Gotchas).

Tests

Backend (alchemy_labs/backend/tests/, run 2026-09-12 at fd8558f against the :5434 Postgres):

  • test_webauthn_multi_rp.py -k rate_limit and test_primaapi_gateway.py -k "rate_limit or hourly_cap": 1 failed, 3 passed in 6.62s. Passing: test_auth_endpoints_are_still_rate_limited (793 to 816: walks the route table and asserts auth_rate_limit is among the dependencies of the four passkey login routes and GET /check), test_per_ip_hourly_cap_returns_429 and test_per_email_hourly_cap_returns_429 (test_primaapi_gateway.py 341 to 371: monkeypatch the cap to a small number, post until 429, assert Retry-After present; the autouse _fresh_limiter fixture at 331 to 339 clears _fallback_limits before and after). Those two assert the header on a bare FastAPI() that mounts the router alone (signup_client, 253 to 259), which has no http_exception_handler; against app_fastapi.app the same assertion fails, because the handler drops the header (Gotchas), so they prove the raise and not the response. Failing: test_rate_limit_still_bites_a_cross_origin_caller (769 to 790), which posts eleven invalid bodies and asserts the eleventh is 429; since e895e7d raised MAX_REQUESTS from 10 to 30 the eleventh is 422 (Gotchas).
  • tests/conftest.py 537 to 545 clears _fallback_limits around every test, which is the S9 pattern a new service copies: the singleton's dictionary is process state, and a test that leaves a bucket behind fails the next file.
  • tests/unit/test_security.py::TestRateLimiter (167 to 215) tests a RateLimiter class from layers/common/python/utils.py, a Lambda-layer artefact loaded by file path (10 to 30); it is not this module and passes for reasons of its own.
  • Known-answer vectors, for a rebuilt get_client_ip: TRUSTED_PROXY_COUNT=1, peer 10.0.0.5, X-Forwarded-For: 198.51.100.9, 203.0.113.7 yields 203.0.113.7; TRUSTED_PROXY_COUNT=2 on the same header yields 198.51.100.9; TRUSTED_PROXY_COUNT=1 with X-Forwarded-For: not-an-ip yields 10.0.0.5; TRUSTED_PROXY_COUNT=0 with any header yields 10.0.0.5; no header yields 10.0.0.5. One test exists at fd8558f: tests/unit/test_capi_identity_preservation.py::TestClientIpExtraction::test_get_client_ip_does_not_use_spoofable_first_entry (330 to 369, since e3d5e79 on 2026-07-28) feeds 198.51.100.5, 203.0.113.5, 10.0.0.1 with TRUSTED_PROXY_COUNT at its default and asserts only that the leftmost entry is not returned (ip in ("203.0.113.5", "10.0.0.1")), a negative assertion on one vector; grep -rn get_client_ip tests/ hits that file and the import note in test_security.py line 15 and nothing else. The other four rows (two hops, a non-IP literal, zero hops, no header) and the exact answer on the first have no test; a new build adds tests/unit/test_client_ip.py with the five rows.
  • What a new build adds for the buckets: the 31st POST /api/auth/login from one IP within a minute is 429 and the 31st from another IP is not; /api/auth/login and /api/auth/register are separate buckets; the 11th request-magic-link for one address from eleven IPs is 429 and the 11th for a second address is 202; a /api/scribe/... flood does not touch the /api/auth/login budget; with REDIS_ENABLED=false the same assertions hold in one process.

Hermetic throughout: every test above runs on the memory fallback (REDIS_ENABLED is off in tests/conftest.py's settings), so the Redis path (_check_limit_redis, 318 to 361) has no test at fd8558f; a new build runs the bucket assertions once more against a throwaway Redis in docker-compose.test.yml. Playwright: none at fd8558f (frontend/e2e/auth.spec.ts is stale and asserts nothing about 429: grep -n -i "429\|rate\|retry" frontend/e2e/auth.spec.ts prints nothing, and F1's Gotchas record that no spec covers /login as built); a new build adds the 31st-submit wait state to F1's spec, and the counting is tested here, never in the browser.

Deploy & ops

Config keys per app heredoc (S13): REDIS_URL (one line, the managed Redis with its password; deploy/infomaniak/.env.infomaniak.example 57 to 58) is the only one of this module's keys the Labs heredoc sets in alchemycloud main b3d34da (scripts/distribute-env.sh 234 and 375; git grep for RATE_LIMIT_ENABLED, REDIS_ENABLED and TRUSTED_PROXY_COUNT at b3d34da prints nothing), so RATE_LIMIT_ENABLED=true and REDIS_ENABLED=true ride on their core/config.py defaults (804, 388 to 433); a new service writes all three explicitly; optionally RATE_LIMIT_IP_PER_MINUTE and RATE_LIMIT_PUBLIC_IP_PER_MINUTE, and TRUSTED_PROXY_COUNT only when the hop count changes (one Caddy in front of uvicorn is the default 1; a second proxy, a CDN or a Cloudflare tunnel in front of Caddy makes it 2, and forgetting that makes every client share Caddy's bucket). The uvicorn flags --proxy-headers --forwarded-allow-ips='*' stay: they are what makes request.url reconstruct as https:// for OAuth callbacks (the Dockerfile comment 107 to 116); they do not feed this module. The CORS block is part of this module's deploy surface: expose_headers must name Retry-After for the countdown to be readable cross-origin (Security & tenancy).

Migrations order: none. Scheduler jobs: none; keys expire by TTL. Argus checks: the boot log line Redis unavailable (...), using in-memory fallback (rate_limit_service.py 65) absent from the last start, or on a build to this recipe rate_limit_backend: redis on GET /health/detailed (at fd8558f the endpoint's redis: healthy means configured, not answering, and cannot fire on the fallback, Gotchas); when the fallback is in use every ceiling is per worker and a deploy resets it; a synthetic probe that posts 31 bad logins from one IP within a minute and expects the last to be 429 with Retry-After is the only end-to-end check of the auth tier, and it must run against staging, never against production, because it spends a real bucket. Rollback: configuration only; a wrong TRUSTED_PROXY_COUNT is fixed by redeploying the value, and a stuck bucket by reset_limit or by waiting out the TTL (60 s, 3600 s for the signup guard).

Gotchas

  • 2026-09-12 (measured at fd8558f, D8 gotcha, red test): tests/test_webauthn_multi_rp.py::test_rate_limit_still_bites_a_cross_origin_caller (769 to 790) asserts the 11th request is 429; the ceiling has been 30 since e895e7d (2026-09-05), so the assertion reads assert 422 == 429. The fix is range(31) and statuses[:30] == [422] * 30, or importing MAX_REQUESTS.
  • 2026-09-12 (measured at fd8558f, D8 gotcha): http_exception_handler (app_fastapi.py 724 to 788) builds both of its JSONResponses with headers=_error_cors_headers(request) (754 and 779) and never reads exc.headers, so the Retry-After that auth_rate_limit, enforce_email_rate_limit and _signup_abuse_guard raise (rate_limit.py 59 and 89, primaapi_router.py 632) never reaches the client; only the middleware's raw JSONResponse (unified_middleware.py 185 to 192) carries it. Measured with TestClient(app_fastapi.app): the 31st POST /api/auth/login {} is 429 with no retry-after among its headers (the security headers, x-request-id and x-response-time are there), body {"error":"Too many authentication attempts. Please try again later.","code":"RATE_001",...}; the 11th request-magic-link for one address is 429 with no retry-after; the 6th POST /api/prima/v1/accounts likewise; four /api/forms/templates under RATE_LIMIT_IP_PER_MINUTE=3 give 429 with retry-after: 59. The fix is headers={**_error_cors_headers(request), **(exc.headers or {})} on both return sites. Five sibling recipes assert the header on a dependency 429 against the reference app and are wrong at fd8558f until that merge lands: ui/auth-screens.md 146 (which also names the code RATE_LIMIT_EXCEEDED; the wire value is RATE_001, the enum's value), registration-with-email-verification.md 245, magic-link-login.md 249, password-reset.md 231 and api-only-self-serve-signup.md 237 (whose two hourly-cap tests pass only on the bare-router signup_client, Tests). This recipe is the measured one; those lines are corrected in their own recipes.
  • 2026-09-12 (measured at fd8558f, D8 gotcha): AUTH_PREFIXES (e895e7d) takes every path under /api/auth out of the volumetric tiers, and only the 15 decorated routes have a bucket of their own, so every undecorated route under the prefix has no limiter of any tier. Anonymous ones first: POST /api/auth/verify-magic-link (auth_router.py 1126, redeems a token), POST /refresh (642, redeems a refresh token), GET /cli-login (1510), GET /api/auth/infomaniak/start and /callback (auth_infomaniak_router.py 38 and 53, mounted at /api/auth, app_fastapi.py 821; grep -c auth_rate_limit prints 0); and POST /api/auth/dev-login (auth_router.py 1684 to 1686), registered only when ENVIRONMENT=development, which mints a session for the first admin with no limiter and is what the AUTHORING.md harness relies on, so it stays unlimited and stays out of production. Measured with TestClient(app_fastapi.app): 35 POST /api/auth/verify-magic-link from one IP inside a minute and no 429 among them. Authenticated ones: /logout (671), GET /me (697), /workspaces (733), PATCH /me (823), /change-password (845), /ws-ticket (1623), and the five undecorated passkey routes (webauthn_router.py 167, 186, 296, 309, 317). Sibling recipes record it per route (magic-link-login.md, infomaniak-oidc-login.md). The fix is Depends(auth_rate_limit) on the five anonymous routes (30 per minute per IP per path, the same order as the login routes), or narrowing AUTH_PREFIXES to the decorated paths so the rest fall back under the 1000 per minute ceiling; the first is the one this recipe specifies.
  • 2026-09-12 (measured at fd8558f, D8 gotcha): GET /health/detailed reports "redis": "healthy" if settings.REDIS_ENABLED else "disabled" (api/routers/health_router.py 101; grep -n -i redis on the file prints that line alone), so it never probes Redis and says healthy while every worker runs on its own dictionary. The inventory row B166 records the same ("reports config presence not liveness"). The only evidence of the fallback at fd8558f is the boot log line Redis unavailable (...), using in-memory fallback (rate_limit_service.py 65). The fix is a PING behind the entry, or a rate_limit_backend: redis|memory field read from rate_limit_service.redis_client is not None, which is what the Argus check above reads.
  • 2026-09-12 (measured at fd8558f, D8 gotcha): RateLimitDependency (api/dependencies.py 425 to 447) is a class whose __call__ ends in pass; rate_limit_strict built from it (457 to 460) is declared on POST /api/accounting/drive/watch and /sync (accounting_drive_router.py 30 and 46), which therefore believe they are limited to 10 per minute and are limited to nothing. The fix is Depends(auth_rate_limit) there (30 per minute per IP per path, which is the same order) and deleting the class, rate_limit_default and RATE_LIMIT_PER_MINUTE/RATE_LIMIT_PER_HOUR with it.
  • 2026-09-12 (measured at fd8558f, D8 gotcha): two middleware files implement a limiter and neither is mounted: api/middleware/rate_limit_middleware.py (200 lines, the only caller of the five-layer check_rate_limit) and api/middleware/rate_limit.py (reads RATE_LIMIT_PER_MINUTE). grep -rn "RateLimitMiddleware" app_fastapi.py prints nothing. A third placeholder, api/middleware/api_key_auth.py::check_rate_limit (146 to 172), ends in return True # For now, always allow and has no caller (grep -rn "check_rate_limit(" api services finds only the unified middleware's own method, the public-forms and form-protection helpers, and the unmounted file). Delete all three and check_rate_limit through _check_cost_based_limit (rate_limit_service.py 68 to 292), which leaves check_simple_limit, reset_limit and the two store paths.
  • 2026-09-12 (measured at fd8558f): initialize() sets _initialized only on a successful PING (58). With REDIS_ENABLED=true and Redis unreachable, every check_simple_limit call re-enters initialize() (428 to 429), builds a client and waits up to socket_connect_timeout=1 for the PING before falling back: one second added to every limited request, and to every /api/* request through the middleware, for as long as Redis is down. The fix is to set _initialized = True on the failure branches too and re-probe on a timer.
  • 2026-09-12 (measured at fd8558f): auth_rate_limit's docstring (39 to 42) and the signup guard's (588) still say 10 per minute per IP; the constant is 30. The commit message of e895e7d is the record.
  • 2026-09-12 (measured at fd8558f): enforce_email_rate_limit names three callers in its docstring (magic link, password reset, verification resend; 64 to 66); only request-magic-link calls it (grep -rn enforce_email_rate_limit api/ hits auth_router.py 1004 alone). /forgot-password and /resend-verification are per IP only, so one mailbox can receive 30 reset mails a minute per source IP. A new build adds the throttle with scopes password-reset and verification to both.
  • 2026-09-12 (measured at fd8558f): the middleware's request log line (73) and create_session's ip_address (auth_router.py 1228 and every other request.client.host call site) record the value uvicorn's proxy middleware derived, which under --forwarded-allow-ips='*' is the leftmost X-Forwarded-For entry, i.e. whatever the client wrote. sessions.ip_address is therefore not evidence of where a session came from. A new build replaces every request.client.host with get_client_ip(request) (grep -rn "request.client.host" api services | wc -l prints 45 at fd8558f) or passes --forwarded-allow-ips the proxy's address instead of *.
  • 2026-09-12 (measured at fd8558f): the middleware limiter was a no-op until 3d39ef2 (2026-07-27): the 2026-06-12 audit's item 4 (operations/security-audit-2026-06-12.md line 19, "TEMPORARILY DISABLED", matching only /api/v1/*) describes the file before that commit. The wiki row is stale; the code at fd8558f is what this recipe describes.
  • 2026-09-05 (e895e7d, measured in the commit and at fd8558f): before that commit /api/auth was inside the shared per-IP counter, and a client's own Scribe beacon on their own site, plus several devices behind one office NAT, spent the budget and 429'd that client's login. AUTH_PREFIXES and the 10 to 30 change are the fix; the per-email throttle is what kept mailbox-flood protection when the per-IP ceiling loosened.
  • 2026-09-12 (measured at fd8558f): the Redis path's denial carries retry_after = ttl, which is never negative (337 to 340 replace a missing or negative TTL with window before the comparison, and an EXPIRE exception takes the fail-open branch at 358 to 361) but can be 0 when the TTL is read in the key's last second; the memory path's retry_after = expires - now can round to 0 the same way. The max(1, ...) floors at every raise site cover both.
  • 2026-09-12 (measured at fd8558f, D8 gotcha): the middleware-tier 429 reaches a cross-origin browser as a CORS failure, not as a 429. UnifiedMiddleware is the second layer from the outside (app.user_middleware at fd8558f: WorkspaceMiddleware, UnifiedMiddleware, ErrorLoggingMiddleware, SecurityHeadersMiddleware, BaseHTTPMiddleware (public CORS), CORSMiddleware), so the JSONResponse its _check_rate_limit returns (185 to 192) never passes CORSMiddleware, the public-CORS function or SecurityHeadersMiddleware, and dispatch sets X-Request-ID only after call_next (103). Measured with TestClient(app_fastapi.app), RATE_LIMIT_IP_PER_MINUTE=3, Origin: https://prima.li (an allowed origin), four GET /api/forms/templates: statuses 400, 400, 400, 429; the three 400s carry access-control-allow-origin, x-content-type-options, x-frame-options and x-request-id; the 429's headers among those and retry-after are retry-after: 59 alone. The same four against /api/f/... with a customer origin: three 404s with the public-CORS access-control-allow-origin, then a bare 429. S7's table already records the request-id half (error-envelope.md 150: neither body nor header). The surface this hits is exactly PUBLIC_PREFIXES (Scribe beacons, form embeds, the self-service cancel), whose callers are always cross-origin, so at fd8558f a client site that trips the 240 per minute ceiling sees TypeError: Failed to fetch and no Retry-After. The fix is to build the short-circuit response with the headers the outer layers would have added: headers={**cors_headers_for(request), "Retry-After": str(max(1, retry_after)), "X-Request-ID": request.state.request_id}, where cors_headers_for reflects the origin the way _error_cors_headers does (app_fastapi.py 691 to 707) and, on a PUBLIC_CORS_PREFIXES path, the way the public-CORS function does (497 to 503); that helper cannot be imported from app_fastapi.py into the middleware (circular), so it moves to core/cors.py and both call it. Running the limiter inside CORSMiddleware would also fix it and is not the answer for a new service: S4 wants the request-context middleware outermost.
  • 2026-09-12 (measured at fd8558f): the middleware tiers do not cover paths outside /api/ (the hosted form at /f/..., the booking shell, /.well-known/), so a flood of /f/ renders is unlimited by this module; the form's PoW (B26) is the ceiling there.

Reference implementation

alchemy_labs, branch d1a-token-source at fd8558f (2026-09-12; backend identical to feat/service-desk b1089b6). Paths relative to backend/ unless marked repo-root:

  • api/dependencies/rate_limit.py (90 lines): constants (26 to 35), auth_rate_limit (38 to 60), enforce_email_rate_limit (63 to 90). Last change e895e7d (2026-09-05).
  • services/rate_limit_service.py (554 lines): initialize (34 to 66), the dead five-layer path (68 to 292), _check_limit (294 to 316), _check_limit_redis (318 to 361), _check_limit_memory (363 to 413), check_simple_limit (415 to 432), get_usage_stats (434 to 479), reset_limit (481 to 496), get_all_limits (498 to 550), the singleton (554).
  • utils/net.py: get_client_ip (29 to 67); the SSRF helpers that share the file (71 to 133) belong to the connections and scraper recipes.
  • api/middleware/unified_middleware.py (197 lines): initialize_services (41 to 47), dispatch (49 to 121), AUTH_PREFIXES (130), PUBLIC_PREFIXES (133 to 141), _check_rate_limit (143 to 197). Last change bd81eb7 (2026-09-11, the self-service prefix).
  • app_fastapi.py 446 to 535: the middleware stack (452 the expose_headers list without Retry-After; 483 to 505 the public-CORS function); 523 the mount; 691 to 707 _error_cors_headers; 724 to 788 http_exception_handler (750 to 766 the gateway-shape branch; the two return sites at 754 and 779 that drop exc.headers); 821 the OIDC router mounted under /api/auth.
  • api/routers/health_router.py 101: the redis entry of /health/detailed (configuration, not liveness).
  • api/routers/auth_infomaniak_router.py 38 and 53: two /api/auth routes with no limiter.
  • core/config.py: TRUSTED_PROXY_COUNT (44 to 47), REDIS_* (388 to 433), RATE_LIMIT_* (803 to 828), PRIMA_SIGNUP_MAX_* (698 to 699).
  • api/routers/auth_router.py: the eight Depends(auth_rate_limit) sites (234, 436, 879, 901, 921, 955, 978, 1007), _magic_link_email_throttle (1000 to 1004), dev-login (1684 to 1686, development only).
  • api/routers/webauthn_router.py: 215, 232, 252, 274, 338.
  • api/routers/primaapi_router.py: _SIGNUP_RATE_WINDOW_SECONDS (543), _signup_abuse_guard (585 to 636), the two decorated routes (639, 701). These two signup routes are the part of the file that stays in Labs by design (prima/prima/api/v1.py 12 to 14 at cd2c248: /accounts and /accounts/verify STAYED in Labs); the chat and embeddings gateway in the same file (196, 473) is the legacy copy, and its meter's 429 is B58's under D3.
  • api/dependencies.py 425 to 460: the placeholder (to delete); api/routers/accounting_drive_router.py 30 and 46: its two callers.
  • api/middleware/rate_limit_middleware.py, api/middleware/rate_limit.py: unmounted (to delete); api/middleware/api_key_auth.py 146 to 172: the return True placeholder (to delete); models/api_key_model.py 36: the unread rate_limit_tier column.
  • repo-root Dockerfile 77 and 117: the worker count and the uvicorn flags (there is no backend/Dockerfile; backend/ holds only Dockerfile.runpod); repo-root deploy/infomaniak/Caddyfile.staging 120 to 141: the proxy's header behaviour.
  • tests/unit/test_capi_identity_preservation.py 330 to 369: the one get_client_ip test.
  • Tests as listed under Tests.

Deviations of the reference from this recipe, all dated 2026-09-12: the red 11-request test; the placeholder dependency on two accounting routes; two unmounted limiter files and the dead five-layer path; the one-second re-initialisation penalty while Redis is down; the stale "10/min" docstrings; the per-email throttle on one route instead of three; request.client.host recorded as a session address; no X-RateLimit-* headers on success; the address logged in clear by the email throttle; Retry-After dropped by the envelope handler on the three dependency tiers (the header merge on both return sites); five anonymous /api/auth routes with no limiter of any tier (auth_rate_limit on the anonymous five; the eleven authenticated ones stay unlimited by decision, Invariants); /health/detailed reporting Redis as configured rather than answering (the rate_limit_backend field); the middleware-tier 429 answered above the CORS and security-header layers with Retry-After alone (the short-circuit built with the reflected CORS headers and X-Request-ID); Retry-After absent from expose_headers (one entry in S4's CORS block and on the public-CORS function); a third return True limiter placeholder in api_key_auth.py and the unread api_keys.rate_limit_tier column (both deleted).

Acceptance checks

Run from alchemy_labs/backend at fd8558f with the :5434 Postgres up, or from a new service built to this recipe. Expected observations are exact.

  1. python -m pytest tests/test_webauthn_multi_rp.py -k rate_limit tests/test_primaapi_gateway.py -k "rate_limit or hourly_cap" -q --no-cov -o addopts="" prints 1 failed, 3 passed at fd8558f and 4 passed once the 11-request test reads MAX_REQUESTS.
  2. grep -rn "Depends(auth_rate_limit)" api/routers | wc -l prints 15 (8 in auth_router.py, 5 in webauthn_router.py, 2 in primaapi_router.py) at fd8558f; for f in auth_router webauthn_router primaapi_router auth_infomaniak_router; do echo -n "$f "; grep -c "Depends(auth_rate_limit)" api/routers/$f.py; done prints 8 5 2 0 at fd8558f and 11 5 2 2 on a build to this recipe (refresh, verify-magic-link, cli-login, infomaniak/start, infomaniak/callback added); grep -rn "rate_limit_strict\|rate_limit_default" api/routers | wc -l prints 3 at fd8558f (the import and the two decorators in accounting_drive_router.py) and 0 on a build to this recipe; grep -n expose_headers app_fastapi.py prints one line naming X-Total-Count and X-Request-ID at fd8558f and the same line with Retry-After added on a build.
  3. grep -n "request.client.host" api/middleware/unified_middleware.py api/dependencies/rate_limit.py utils/net.py services/rate_limit_service.py prints four lines, two of them docstrings (rate_limit.py 7, net.py 8) and two code (unified_middleware.py 73, the request log line; net.py 41, the peer fallback): no counter reads the peer. grep -rn "request.client.host" api services | wc -l prints 45, the sites the last Gotcha but three names.
  4. python -c " from types import SimpleNamespace as N import core.config as c; from utils.net import get_client_ip def req(xff, peer='10.0.0.5'): return N(client=N(host=peer), headers={'x-forwarded-for': xff} if xff else {}) c.settings.TRUSTED_PROXY_COUNT=1; print(get_client_ip(req('198.51.100.9, 203.0.113.7')), get_client_ip(req('not-an-ip')), get_client_ip(req(None))) c.settings.TRUSTED_PROXY_COUNT=2; print(get_client_ip(req('198.51.100.9, 203.0.113.7'))) c.settings.TRUSTED_PROXY_COUNT=0; print(get_client_ip(req('198.51.100.9, 203.0.113.7'))) " prints 203.0.113.7 10.0.0.5 10.0.0.5, then 198.51.100.9, then 10.0.0.5.
  5. With the app on 8020 (the harness runs one worker on the memory fallback): for i in $(seq 1 31); do curl -s -o /dev/null -w "%{http_code} " -X POST -H "Content-Type: application/json" -d '{}' http://127.0.0.1:8020/api/auth/login; done; echo prints thirty 422 followed by one 429; curl -s -i -X POST -H "Content-Type: application/json" -d '{}' http://127.0.0.1:8020/api/auth/login | grep -i "retry-after\|Too many" prints one line at fd8558f, the body's "error":"Too many authentication attempts. Please try again later.", and no retry-after (the handler drops it, Gotchas); on a build with the header merge it prints two, retry-after: <1..60> first; the same 31 requests against /api/auth/register in the same minute print thirty 422 and one 429 of their own (a separate bucket).
  6. In the same minute: curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"x"}' -H "X-Forwarded-For: 203.0.113.99" http://127.0.0.1:8020/api/auth/login prints 401 or 422, not 429: a different reported client is a different bucket. The header is honoured because get_client_ip reads X-Forwarded-For itself with TRUSTED_PROXY_COUNT=1 and no proxy stands in front of the harness to append the real peer (tooling/verify/labs-local.sh 160 starts uvicorn without --proxy-headers), so the client's own entry is the rightmost one; behind Caddy the same forged header is shifted left and ignored.
  7. for i in $(seq 1 11); do curl -s -o /dev/null -w "%{http_code} " -X POST -H "Content-Type: application/json" -d '{"email":"[email protected]"}' -H "X-Forwarded-For: 203.0.113.$i" http://127.0.0.1:8020/api/auth/request-magic-link; done; echo prints ten 202 and one 429 whose body says "Too many requests for this email. Please try again later.": eleven source addresses, one mailbox budget.
  8. for i in $(seq 1 1001); do curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8020/api/forms/templates; done | sort | uniq -c shows 1000 of one status and 1 of 429, and the 429 body is {"error":"rate_limit_exceeded","message":"Too many requests. Please slow down."}; curl -s -i -H 'Origin: https://prima.li' http://127.0.0.1:8020/api/forms/templates | grep -i "^access-control-allow-origin\|^x-request-id\|^retry-after\|^x-frame-options" on the 1001st request prints retry-after alone at fd8558f (the three other lines are present on the 1000 before it) and all four lines on a build to this recipe; repeating the loop immediately against /api/auth/me shows no 429 at fd8558f, which is the hole and not a guarantee: /me is outside the volumetric tier and carries no bucket of its own, so 1001 requests answer 401 (Gotchas). On a build to this recipe the same loop against POST /api/auth/verify-magic-link with {"token":"x"} prints thirty 400 and then 429.
  9. With Redis reachable (REDIS_ENABLED=true, REDIS_URL set) and two workers: the checks in 5 and 7 hold with the same counts; redis-cli --scan --pattern 'rate_limit:auth:*' lists one key per path and IP with a TTL at most 60; stopping Redis and repeating 5 still prints one 429 per worker's own count and every request answers within the second (on a build with the re-initialisation fix) or one second slower (at fd8558f).

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.