Skip to content

Commit 76aeda5

Browse files
authored
fix(ratelimit): fail open on a Redis outage, matching login_throttle (#418)
slowapi's Limiter re-raises whatever its storage raises, and swallow_errors=True doesn't help either since the header-injection code that runs afterward reads request.state.view_rate_limit unconditionally, which is only set when evaluation finishes without raising. Either way a Redis outage 500'd every rate-limited endpoint, confirmed against a real unreachable Redis. Add FailOpenRedisStorage, a thin Redis storage wrapper that answers "zero hits, resets now" instead of raising when Redis cannot answer. That reads as "not limited" to the strategy above it, so the request goes through with a warning logged rather than a 500 -- the same fail-open policy login_throttle.py already documents and follows for its own Redis calls. Refs #399
1 parent 1aa4288 commit 76aeda5

4 files changed

Lines changed: 385 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,24 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.
313313
rolled back and the caller re-fetches the winner's row, so the rest of the
314314
scan's transaction survives intact (#398-A).
315315

316+
- **A Redis outage could turn every rate-limited endpoint into a 500.**
317+
`core/ratelimit.py` built slowapi's `Limiter` against plain `redis://`, and
318+
left to its defaults slowapi re-raises whatever its storage raises;
319+
`swallow_errors=True` does not save it either, since the header-injection
320+
code that runs after a swallowed exception reads
321+
`request.state.view_rate_limit` unconditionally, and that attribute is
322+
only set once evaluation finishes without raising. Confirmed against a
323+
real unreachable Redis rather than assumed from reading slowapi's source.
324+
A new `FailOpenRedisStorage` answers "zero hits, resets now" instead of
325+
raising when Redis cannot answer, which reads as "not limited" to the
326+
strategy above it and lets the request through with a warning logged
327+
instead of a 500. This makes the login rate limit and every other
328+
`@limiter.limit(...)` endpoint fail open on a Redis outage, matching the
329+
policy `login_throttle.py`'s per-address slowdown already documented and
330+
followed. Both mechanisms are now proven independently, each with the
331+
other's Redis path left healthy, so neither test's pass depends on the
332+
other control also being down.
333+
316334
- **The vulnerability drawer's response builder could drop a field silently.**
317335
`_detail_response` named all 41 fields of `VulnerabilityDetailResponse` as
318336
keyword arguments by hand, so a field the service already computed but

apps/backend/core/ratelimit.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,145 @@
1919
- Storage is Redis (shared across uvicorn workers / Celery beats), not
2020
slowapi's default in-memory dict, so the 5/min budget is enforced
2121
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.
2252
"""
2353

2454
from __future__ import annotations
2555

2656
import os
57+
import time
58+
from typing import Any
2759

60+
import structlog
2861
from fastapi import Request
2962
from fastapi.responses import JSONResponse
63+
from limits.storage.redis import RedisStorage
64+
from redis.exceptions import RedisError
3065
from slowapi import Limiter
3166
from slowapi.errors import RateLimitExceeded
3267
from slowapi.util import get_remote_address
3368

3469
from core.config import redis_url
3570
from core.errors import PROBLEM_CONTENT_TYPE
3671

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+
37161
# Module-level constant — this is policy, not configuration. Keep it in code.
38162
LOGIN_RATE_LIMIT = "5/minute"
39163

@@ -134,10 +258,20 @@ def _authenticated_user_key(request: Request) -> str:
134258
# @limiter.limit(...). storage_uri uses Redis so the 5/min budget is shared
135259
# across uvicorn workers (the function call is a runtime call, not a cached
136260
# 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.
137264
limiter = Limiter(
138265
key_func=_client_ip_for_limit,
139266
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+
},
141275
enabled=_limiter_enabled(),
142276
)
143277

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 TRUSCA contributors
3+
"""A Redis outage must not turn every rate-limited endpoint into a 500 (#399).
4+
5+
Before `FailOpenRedisStorage`, slowapi's default construction re-raised
6+
whatever its storage raised, and the header-injection code that ran on the
7+
`swallow_errors=True` path 500'd too, on an `AttributeError` this time rather
8+
than a `RedisError` (see `core/ratelimit.py`'s module docstring for how that
9+
was confirmed). Either way, a limiter meant to protect the service from one
10+
noisy caller became a single point of failure for the whole service instead.
11+
12+
These tests break only the rate limiter's own storage, leaving the real
13+
Redis the rest of the app (including `login_throttle`) is still driven
14+
against untouched. `test_login_throttle.py::test_a_redis_outage_leaves_sign_in_working`
15+
does the mirror image: it breaks `login_throttle`'s Redis client and leaves
16+
the rate limiter working. Neither test's pass depends on the other
17+
mechanism also being broken, which is the point -- a limiter that is merely
18+
disabled for the test looks identical to one that fails open on its own, and
19+
only exercising each in isolation tells the two apart.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from collections.abc import AsyncIterator, Iterator
25+
26+
import pytest
27+
from httpx import ASGITransport, AsyncClient
28+
29+
from tests._db_required import migrate_to_head
30+
from tests._helpers import strong_password, unique_suffix
31+
32+
pytestmark = pytest.mark.integration
33+
34+
35+
@pytest.fixture(scope="module", autouse=True)
36+
def _migrate_once() -> None:
37+
migrate_to_head()
38+
39+
40+
@pytest.fixture
41+
async def client() -> AsyncIterator[AsyncClient]:
42+
from main import app as fastapi_app
43+
44+
# raise_app_exceptions=False: a regression here is a 500, and the test
45+
# needs to see that status code rather than have httpx re-raise it.
46+
transport = ASGITransport(app=fastapi_app, raise_app_exceptions=False)
47+
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
48+
yield ac
49+
50+
51+
@pytest.fixture
52+
def unreachable_ratelimit_storage(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
53+
"""Point the live `limiter` at a Redis nothing is listening on.
54+
55+
Swaps the storage the strategy actually calls (`limiter._limiter.storage`)
56+
as well as `limiter._storage` (read by `.check()` / `.reset()`), since
57+
slowapi keeps two references to the same object and only one of them is
58+
on `hit()`'s call path. `monkeypatch` restores both when the test ends,
59+
so the shared singleton is never left pointed at a dead host for the
60+
tests that run after this one.
61+
"""
62+
from core.ratelimit import FailOpenRedisStorage, limiter
63+
64+
broken = FailOpenRedisStorage(
65+
"redis+failopen://127.0.0.1:1/0",
66+
socket_timeout=0.5,
67+
socket_connect_timeout=0.5,
68+
)
69+
monkeypatch.setattr(limiter, "_storage", broken)
70+
monkeypatch.setattr(limiter._limiter, "storage", broken) # type: ignore[attr-defined]
71+
yield
72+
73+
74+
async def test_registration_succeeds_when_the_rate_limiter_cannot_reach_redis(
75+
client: AsyncClient, unreachable_ratelimit_storage: None
76+
) -> None:
77+
"""`/auth/register` is decorated with `@limiter.limit(...)` and nothing
78+
else that talks to Redis, so a non-201 here is the rate limiter's doing.
79+
"""
80+
email = f"ratelimit-outage-{unique_suffix()}@example.com"
81+
response = await client.post(
82+
"/auth/register",
83+
json={"email": email, "password": strong_password(), "full_name": "Outage Test"},
84+
)
85+
assert response.status_code == 201, response.text
86+
87+
88+
async def test_login_is_not_blocked_by_a_rate_limiter_outage(
89+
client: AsyncClient, unreachable_ratelimit_storage: None
90+
) -> None:
91+
"""`/auth/login` is decorated with the rate limiter *and* gated by
92+
`login_throttle`. With only the rate limiter's storage broken, a wrong
93+
password must still come back as an ordinary 401 -- proof that the
94+
limiter let the request through rather than 500ing on the way in, and
95+
that `login_throttle`'s own (unbroken) Redis path is what actually
96+
handled the attempt.
97+
"""
98+
email = f"ratelimit-outage-login-{unique_suffix()}@example.com"
99+
password = strong_password()
100+
created = await client.post(
101+
"/auth/register",
102+
json={"email": email, "password": password, "full_name": "Outage Test"},
103+
)
104+
assert created.status_code == 201, created.text
105+
106+
wrong = await client.post(
107+
"/auth/login", json={"email": email, "password": "definitely not it 1"}
108+
)
109+
assert wrong.status_code == 401, wrong.text
110+
111+
right = await client.post("/auth/login", json={"email": email, "password": password})
112+
assert right.status_code == 200, right.text

0 commit comments

Comments
 (0)