|
19 | 19 | - Storage is Redis (shared across uvicorn workers / Celery beats), not |
20 | 20 | slowapi's default in-memory dict, so the 5/min budget is enforced |
21 | 21 | correctly under multi-worker deployments. |
| 22 | +
|
| 23 | +Redis failure policy (issue #399): fail open, the same policy `login_throttle.py` |
| 24 | +already documents for the login guesswork slowdown. A rate limit's job is to |
| 25 | +keep one caller from crowding out everyone else; it is not the last line of |
| 26 | +defence for anything it guards, and turning a Redis outage into a site-wide |
| 27 | +outage would make a shared cache a hard dependency of every limited endpoint, |
| 28 | +login included, for the sake of a control that is explicitly a courtesy |
| 29 | +rather than a wall. |
| 30 | +
|
| 31 | +That policy is not the one slowapi ships with. Left to its defaults, |
| 32 | +`Limiter` re-raises whatever its storage raises: `RateLimitExceeded` first |
| 33 | +became a real question during this issue when a Redis restart in dev turned |
| 34 | +every limited endpoint into a 500 instead of the plain pass-through the name |
| 35 | +"rate limit" implies. `swallow_errors=True` does not fix it either -- slowapi |
| 36 | +only sets `request.state.view_rate_limit` once its evaluation loop finishes |
| 37 | +without raising, and the header-injection code that runs after the swallowed |
| 38 | +exception reads that attribute unconditionally, so the endpoint still 500s |
| 39 | +with an `AttributeError` instead of a `RedisError`. Confirmed by driving both |
| 40 | +configurations against an unreachable Redis directly (see |
| 41 | +`tests/unit/test_rate_limit.py`); neither is a shape this module can rely on. |
| 42 | +
|
| 43 | +So the fix sits one layer down, in the storage `FailOpenRedisStorage` |
| 44 | +plugs in below. `Limiter.hit()` only ever asks its storage two things: |
| 45 | +"how many hits does this key have" (`incr`, `get`) and "when does it reset" |
| 46 | +(`get_expiry`). Answering "zero" and "now" for those during an outage is |
| 47 | +indistinguishable, from the strategy's point of view, from a key that has |
| 48 | +never been hit -- the request is allowed, the evaluation loop finishes |
| 49 | +normally, and `request.state.view_rate_limit` gets set exactly like the |
| 50 | +healthy path. Nothing upstream needs to know Redis was unreachable; slowapi |
| 51 | +only sees answers it already knows how to handle. |
22 | 52 | """ |
23 | 53 |
|
24 | 54 | from __future__ import annotations |
25 | 55 |
|
26 | 56 | import os |
| 57 | +import time |
| 58 | +from typing import Any |
27 | 59 |
|
| 60 | +import structlog |
28 | 61 | from fastapi import Request |
29 | 62 | from fastapi.responses import JSONResponse |
| 63 | +from limits.storage.redis import RedisStorage |
| 64 | +from redis.exceptions import RedisError |
30 | 65 | from slowapi import Limiter |
31 | 66 | from slowapi.errors import RateLimitExceeded |
32 | 67 | from slowapi.util import get_remote_address |
33 | 68 |
|
34 | 69 | from core.config import redis_url |
35 | 70 | from core.errors import PROBLEM_CONTENT_TYPE |
36 | 71 |
|
| 72 | +log = structlog.get_logger("ratelimit") |
| 73 | + |
| 74 | +#: A blocked call here sits in front of every limited endpoint, so it gets the |
| 75 | +#: same short leash `login_throttle.py` gives its own Redis client: without a |
| 76 | +#: bound, a Redis that drops packets rather than refusing them hangs the |
| 77 | +#: request instead of failing it, which defeats the point of failing open. |
| 78 | +_SOCKET_TIMEOUT_SECONDS = 1.0 |
| 79 | + |
| 80 | +#: `redis://` and `rediss://` rewritten to schemes `FailOpenRedisStorage` |
| 81 | +#: registers itself under, so `Limiter(storage_uri=...)` picks our subclass |
| 82 | +#: instead of `limits`' own `RedisStorage`. `redis+unix://` is intentionally |
| 83 | +#: left alone: nothing in this deployment uses it, and guessing at a |
| 84 | +#: transform for a scheme nobody exercises is worse than leaving it on the |
| 85 | +#: standard (fail-closed) storage and finding out. |
| 86 | +_FAIL_OPEN_SCHEMES = {"redis": "redis+failopen", "rediss": "rediss+failopen"} |
| 87 | + |
| 88 | + |
| 89 | +def _fail_open_storage_uri(url: str) -> str: |
| 90 | + """Rewrite a `redis(s)://` URL to the scheme `FailOpenRedisStorage` owns.""" |
| 91 | + scheme, sep, rest = url.partition("://") |
| 92 | + mapped = _FAIL_OPEN_SCHEMES.get(scheme) |
| 93 | + if not sep or mapped is None: |
| 94 | + return url |
| 95 | + return f"{mapped}{sep}{rest}" |
| 96 | + |
| 97 | + |
| 98 | +def _degraded(action: str, exc: Exception) -> None: |
| 99 | + """Redis could not answer a rate-limit check. Say so, then allow it. |
| 100 | +
|
| 101 | + Mirrors `login_throttle._degraded`: the warning exists so an outage is |
| 102 | + visible to somebody reading logs, since the outward behaviour is the same |
| 103 | + limiter quietly not counting anything until Redis answers again. |
| 104 | + """ |
| 105 | + log.warning("ratelimit.storage_unavailable", action=action, error=str(exc)) |
| 106 | + |
| 107 | + |
| 108 | +class FailOpenRedisStorage(RedisStorage): |
| 109 | + """`limits`' Redis storage, answering "not limited" when Redis cannot answer. |
| 110 | +
|
| 111 | + Registers itself under `redis+failopen` / `rediss+failopen` (see |
| 112 | + `STORAGE_SCHEME` below) rather than overriding `redis` / `rediss` |
| 113 | + directly, so a reader who greps for the scheme in a stack trace finds |
| 114 | + this class instead of wondering why `limits`' own `RedisStorage` grew a |
| 115 | + fail-open habit it does not document. |
| 116 | +
|
| 117 | + Overrides only the three calls `FixedWindowRateLimiter` (this app's |
| 118 | + strategy; see the `Limiter(...)` construction below) makes: `incr`, |
| 119 | + `get`, `get_expiry`. `check()` already reports "unhealthy" rather than |
| 120 | + raising in the parent class, and `reset()` / `clear()` are operator |
| 121 | + tools this app does not call from the request path, so a failure there |
| 122 | + is somebody's terminal, not somebody's login. |
| 123 | + """ |
| 124 | + |
| 125 | + STORAGE_SCHEME = ["redis+failopen", "rediss+failopen"] |
| 126 | + |
| 127 | + def __init__(self, uri: str, **options: Any) -> None: |
| 128 | + scheme, sep, rest = uri.partition("://") |
| 129 | + real_scheme = scheme.removesuffix("+failopen") |
| 130 | + super().__init__(f"{real_scheme}{sep}{rest}", **options) |
| 131 | + |
| 132 | + def incr(self, key: str, expiry: int, amount: int = 1) -> int: |
| 133 | + """Zero hits recorded reads as "not limited" to every caller of this.""" |
| 134 | + try: |
| 135 | + return super().incr(key, expiry, amount=amount) |
| 136 | + except RedisError as exc: |
| 137 | + _degraded("incr", exc) |
| 138 | + return 0 |
| 139 | + |
| 140 | + def get(self, key: str) -> int: |
| 141 | + try: |
| 142 | + return super().get(key) |
| 143 | + except RedisError as exc: |
| 144 | + _degraded("get", exc) |
| 145 | + return 0 |
| 146 | + |
| 147 | + def get_expiry(self, key: str) -> float: |
| 148 | + """A key we could not read reports its expiry as now. |
| 149 | +
|
| 150 | + We do not know when it actually resets, and claiming a real deadline |
| 151 | + would tell a caller building on `X-RateLimit-Reset` something we did |
| 152 | + not observe. |
| 153 | + """ |
| 154 | + try: |
| 155 | + return super().get_expiry(key) |
| 156 | + except RedisError as exc: |
| 157 | + _degraded("get_expiry", exc) |
| 158 | + return time.time() |
| 159 | + |
| 160 | + |
37 | 161 | # Module-level constant — this is policy, not configuration. Keep it in code. |
38 | 162 | LOGIN_RATE_LIMIT = "5/minute" |
39 | 163 |
|
@@ -134,10 +258,20 @@ def _authenticated_user_key(request: Request) -> str: |
134 | 258 | # @limiter.limit(...). storage_uri uses Redis so the 5/min budget is shared |
135 | 259 | # across uvicorn workers (the function call is a runtime call, not a cached |
136 | 260 | # module constant — CLAUDE.md rule #11 is about getenv, not bootstrap config). |
| 261 | +# The `+failopen` scheme routes construction to `FailOpenRedisStorage` above |
| 262 | +# instead of `limits`' own `RedisStorage`; the socket timeouts bound how long |
| 263 | +# a hung connection can delay a request before that fail-open policy applies. |
137 | 264 | limiter = Limiter( |
138 | 265 | key_func=_client_ip_for_limit, |
139 | 266 | default_limits=[], |
140 | | - storage_uri=redis_url(), |
| 267 | + storage_uri=_fail_open_storage_uri(redis_url()), |
| 268 | + # slowapi types storage_options as Dict[str, str], but it forwards the |
| 269 | + # values unchanged to redis.from_url(**options), which wants real floats |
| 270 | + # here rather than strings it would have to parse back. |
| 271 | + storage_options={ |
| 272 | + "socket_timeout": _SOCKET_TIMEOUT_SECONDS, # type: ignore[dict-item] |
| 273 | + "socket_connect_timeout": _SOCKET_TIMEOUT_SECONDS, # type: ignore[dict-item] |
| 274 | + }, |
141 | 275 | enabled=_limiter_enabled(), |
142 | 276 | ) |
143 | 277 |
|
|
0 commit comments