Skip to content

Commit 2e5738b

Browse files
committed
fix(cache): serve uncached instead of 500 when the backend fails
@cache now fails open: a backend error on read is logged and treated as a miss, one on write is logged and the response is served unstored. This also covers Memcached rejecting a response over its item size limit. @cache(fail_open=False) lets the error propagate as before. Closes #228
1 parent a0f1ab4 commit 2e5738b

8 files changed

Lines changed: 224 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2.
5050
`Exception` keep working. A `try` block that lists `except CacheXError`
5151
before `except SessionError` now takes the `CacheXError` branch for session
5252
errors. ([#162](https://github.com/allen0099/FastAPI-CacheX/issues/162))
53+
- **`@cache` serves uncached responses when the backend fails.** A backend
54+
error on read or write turned every cached route into a 500, even after the
55+
handler had produced a good response; this includes a healthy Memcached
56+
rejecting a response over its 1 MB item size. A failed read now counts as a
57+
miss and a failed write leaves the response unstored, each logged as a
58+
warning on `fastapi_cachex.cache`. `@cache(fail_open=False)` restores the
59+
old behaviour. `invalidate()`, `CacheManager`, `StateManager`, `CacheLock`
60+
and sessions still raise.
61+
([#228](https://github.com/allen0099/FastAPI-CacheX/issues/228))
5362

5463
### Deprecated
5564

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ The library has four independent subsystems:
5151
**1. HTTP Caching (`fastapi_cachex/cache.py`, `proxy.py`, `backends/`)**
5252
- `@cache(...)` decorator wraps FastAPI route handlers. It injects a `Request` parameter into the handler signature if not already present, so the handler does not need to declare it.
5353
- Cache flow: check `no-store` → check `no-cache` → check ETag (`If-None-Match`) → check TTL-based cache hit → execute handler → store result.
54+
- Fails open by default (`fail_open=True`): a backend error on `get` is logged and treated as a miss, one on `set` is logged and the response served unstored. `fail_open=False` propagates the error.
5455
- Only GET requests are cached; other methods bypass the cache entirely.
5556
- Cache keys follow the format `method|||host|||path|||query_params` (separator defined in `types.py`).
5657
- `BackendProxy` is a non-instantiable class-level singleton (via `ProxyMeta`). Call `BackendProxy.set(backend)` at app startup; `BackendProxy.get()` raises `BackendNotFoundError` if unset. Falls back to `MemoryBackend` automatically inside `@cache` if no backend is set.

‎docs/BACKENDS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ BackendProxy.set(backend)
142142
- `clear()` issues `flush_all`, which wipes the whole Memcached server, not just this namespace
143143
- A key Memcached would reject (over 250 bytes, whitespace, non-ASCII) is stored
144144
under its SHA-256 digest
145+
- Values larger than the server's item size limit (1 MB by default, `memcached -I`)
146+
are rejected with an error. `@cache` logs it and serves the response unstored
147+
(see [When the backend fails](HTTP_CACHING.md#when-the-backend-fails)); other
148+
callers get the error
145149
- Consider using the Redis backend if you need pattern-based cache clearing
146150

147151
The synchronous pymemcache client runs in worker threads and is connection-pooled,

‎docs/HTTP_CACHING.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,29 @@ route's response model (declared or inferred from the return annotation, with
9696
the `response_model_*` options), the route's `status_code` applies, and the
9797
status and headers set on an injected `response: Response` parameter are kept.
9898

99+
### When the backend fails
100+
101+
`@cache` fails open. If the backend raises while reading, for example because
102+
Redis or Memcached is unreachable, the request is treated as a cache miss and
103+
the handler runs. If storing the response raises, for example because it is
104+
larger than Memcached's item size limit (1 MB by default), the response is
105+
served unstored. Either way a warning is logged on the `fastapi_cachex.cache`
106+
logger, and a backend outage cannot turn cached routes into 500s. The load
107+
goes to your handlers instead, so watch for those warnings.
108+
109+
Pass `fail_open=False` to let the backend error propagate and fail the request
110+
instead:
111+
112+
```python
113+
@app.get("/report")
114+
@cache(ttl=300, fail_open=False)
115+
async def report():
116+
return await build_report()
117+
```
118+
119+
This only covers `@cache`. `invalidate()`, `CacheManager`, `StateManager`,
120+
`CacheLock` and sessions still raise backend errors to the caller.
121+
99122
## Cache keys
100123

101124
Cache keys are generated in the following format to avoid collisions:

‎fastapi_cachex/cache.py‎

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ def cache(
434434
immutable: bool = False,
435435
must_revalidate: bool = False,
436436
key_builder: CacheKeyBuilder | None = None,
437+
fail_open: bool = True,
437438
) -> Callable[[HandlerCallable], AsyncResponseCallable]:
438439
"""Cache decorator for FastAPI route handlers.
439440
@@ -469,6 +470,10 @@ def cache(
469470
must_revalidate: Send ``must-revalidate``.
470471
key_builder: Custom function to build cache keys. If None, uses
471472
``default_key_builder``.
473+
fail_open: When the backend raises, log a warning and answer without
474+
the cache: a failed read counts as a miss and a failed write
475+
leaves the response unstored. ``False`` lets the error propagate,
476+
so the request fails.
472477
473478
Returns:
474479
Decorator function that wraps route handlers with caching logic
@@ -629,7 +634,17 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response:
629634
logger.debug("Bypassed the backend; key=%s", cache_key)
630635
return _with_cache_control(response, cache_control)
631636

632-
cached_data = await cache_backend.get(cache_key)
637+
try:
638+
cached_data = await cache_backend.get(cache_key)
639+
except Exception as e:
640+
if not fail_open:
641+
raise
642+
logger.warning(
643+
"Cache backend read failed; serving uncached. key=%s error=%r",
644+
cache_key,
645+
e,
646+
)
647+
cached_data = None
633648

634649
current_response: Response | None = None
635650
current_body: bytes | None = None
@@ -714,18 +729,28 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response:
714729
assert (
715730
current_body is not None
716731
) # guaranteed by early-return guards above
717-
await cache_backend.set(
718-
cache_key,
719-
CacheEntry(
720-
fingerprint=current_etag,
721-
content=current_body,
722-
media_type=_media_type_of(current_response),
723-
status_code=current_response.status_code,
724-
headers=_cacheable_headers(current_response),
725-
),
726-
ttl=ttl,
727-
)
728-
logger.debug("Updated cache entry; key=%s ttl=%s", cache_key, ttl)
732+
try:
733+
await cache_backend.set(
734+
cache_key,
735+
CacheEntry(
736+
fingerprint=current_etag,
737+
content=current_body,
738+
media_type=_media_type_of(current_response),
739+
status_code=current_response.status_code,
740+
headers=_cacheable_headers(current_response),
741+
),
742+
ttl=ttl,
743+
)
744+
except Exception as e:
745+
if not fail_open:
746+
raise
747+
logger.warning(
748+
"Cache backend write failed; response not stored. key=%s error=%r",
749+
cache_key,
750+
e,
751+
)
752+
else:
753+
logger.debug("Updated cache entry; key=%s ttl=%s", cache_key, ttl)
729754

730755
return _with_cache_control(current_response, cache_control)
731756

‎i18n/zh-TW/docs/BACKENDS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ BackendProxy.set(backend)
107107
- `clear_path()` 只會刪除完全相符的那個鍵;`include_params` 沒有作用
108108
- `clear()` 會發出 `flush_all`,清空整台 Memcached 伺服器,而不只是這個命名空間
109109
- Memcached 會拒絕的鍵(超過 250 位元組、含空白字元或非 ASCII 字元)會改以其 SHA-256 摘要儲存
110+
- 超過伺服器項目大小上限(預設 1 MB,可用 `memcached -I` 調整)的值會被拒絕並拋出錯誤。`@cache` 會記錄該錯誤,並照常送出不儲存的回應(見[後端發生錯誤時](HTTP_CACHING.md#when-the-backend-fails));其他呼叫端則會收到該錯誤
110111
- 若需要依模式清除快取,請考慮使用 Redis 後端
111112

112113
同步的 pymemcache 用戶端在工作執行緒中執行,並使用連線池,因此並行的請求絕不會共用同一個 socket。寫入會等待伺服器確認(`default_noreply=False`),因此只要 `set()` 返回,就能從連線池中的任何連線讀到該值。

‎i18n/zh-TW/docs/HTTP_CACHING.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ async def non_store_endpoint():
7272

7373
handler 回傳一般資料而非 `Response` 時,得到的處理與沒有 `@cache` 時相同:回傳值會經過路由的 response model 驗證與過濾(明確宣告的,或由回傳型別註記推斷,並套用 `response_model_*` 選項),套用路由的 `status_code`,而在注入的 `response: Response` 參數上設定的狀態碼與標頭也會保留。
7474

75+
### 後端發生錯誤時 {#when-the-backend-fails}
76+
77+
`@cache` 採取 fail open。讀取時後端拋出錯誤(例如 Redis 或 Memcached 無法連線),該請求會被當成快取未命中,照常執行 handler。儲存回應時拋出錯誤(例如回應超過 Memcached 的項目大小上限,預設為 1 MB),回應會照常送出,只是不會被儲存。兩種情況都會在 `fastapi_cachex.cache` logger 記錄一則警告,因此後端中斷不會讓有快取的路由變成 500;負載會轉到你的 handler 上,請留意這些警告。
78+
79+
傳入 `fail_open=False` 則會讓後端錯誤直接往外拋出,使該請求失敗:
80+
81+
```python
82+
@app.get("/report")
83+
@cache(ttl=300, fail_open=False)
84+
async def report():
85+
return await build_report()
86+
```
87+
88+
這只適用於 `@cache`。`invalidate()`、`CacheManager`、`StateManager`、`CacheLock` 與 Session 仍會把後端錯誤拋給呼叫端。
89+
7590
## 快取鍵 {#cache-keys}
7691

7792
快取鍵以下列格式產生,以避免衝突:
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""`@cache` when the backend raises (#228).
2+
3+
A cache is an optimisation: by default a failing backend makes the route answer
4+
uncached instead of turning every cached route into a 500.
5+
"""
6+
7+
import logging
8+
9+
import pytest
10+
from fastapi import FastAPI
11+
from fastapi.responses import PlainTextResponse
12+
from fastapi.testclient import TestClient
13+
14+
from fastapi_cachex import BackendProxy
15+
from fastapi_cachex import cache
16+
from fastapi_cachex.backends import MemcachedBackend
17+
from fastapi_cachex.backends.memory import MemoryBackend
18+
from fastapi_cachex.types import CacheEntry
19+
from tests.live_servers import MEMCACHED_SERVER
20+
from tests.live_servers import requires_memcached
21+
22+
23+
class FailingBackend(MemoryBackend):
24+
"""A memory backend whose reads and/or writes raise like a lost connection."""
25+
26+
def __init__(self, *, fail_get: bool, fail_set: bool) -> None:
27+
super().__init__()
28+
self.fail_get = fail_get
29+
self.fail_set = fail_set
30+
31+
async def get(self, key: str) -> CacheEntry | None:
32+
if self.fail_get:
33+
msg = "backend unreachable"
34+
raise ConnectionError(msg)
35+
return await super().get(key)
36+
37+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
38+
if self.fail_set:
39+
msg = "backend unreachable"
40+
raise ConnectionError(msg)
41+
await super().set(key, value, ttl)
42+
43+
44+
def _counting_app(*, fail_open: bool = True) -> tuple[FastAPI, list[int]]:
45+
app = FastAPI()
46+
calls: list[int] = []
47+
48+
@app.get("/item")
49+
@cache(ttl=60, fail_open=fail_open)
50+
async def item() -> dict[str, int]:
51+
calls.append(1)
52+
return {"n": len(calls)}
53+
54+
return app, calls
55+
56+
57+
@pytest.mark.parametrize(
58+
("fail_get", "fail_set"),
59+
[(True, False), (False, True), (True, True)],
60+
ids=["get", "set", "both"],
61+
)
62+
def test_failing_backend_serves_the_handler_response(
63+
caplog: pytest.LogCaptureFixture, *, fail_get: bool, fail_set: bool
64+
) -> None:
65+
"""A read or write error is logged and the handler's response is served."""
66+
BackendProxy.set(FailingBackend(fail_get=fail_get, fail_set=fail_set))
67+
app, calls = _counting_app()
68+
client = TestClient(app)
69+
70+
with caplog.at_level(logging.WARNING, logger="fastapi_cachex.cache"):
71+
first = client.get("/item")
72+
second = client.get("/item")
73+
74+
assert first.status_code == 200
75+
assert first.json() == {"n": 1}
76+
assert "ETag" in first.headers
77+
assert first.headers["Cache-Control"] == "max-age=60"
78+
assert second.status_code == 200
79+
# Nothing could be served from the backend, so the handler ran again.
80+
assert len(calls) == 2
81+
warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
82+
if fail_get:
83+
assert any("read failed" in m for m in warnings)
84+
if fail_set:
85+
assert any("write failed" in m for m in warnings)
86+
87+
88+
def test_a_write_failure_does_not_hide_a_working_read() -> None:
89+
"""Only the failing half is skipped: entries already stored are still served."""
90+
backend = FailingBackend(fail_get=False, fail_set=False)
91+
BackendProxy.set(backend)
92+
app, calls = _counting_app()
93+
client = TestClient(app)
94+
client.get("/item")
95+
96+
backend.fail_set = True
97+
response = client.get("/item")
98+
99+
assert response.json() == {"n": 1}
100+
assert len(calls) == 1
101+
102+
103+
@pytest.mark.parametrize(
104+
("fail_get", "fail_set"), [(True, False), (False, True)], ids=["get", "set"]
105+
)
106+
def test_fail_open_false_propagates_the_error(
107+
*, fail_get: bool, fail_set: bool
108+
) -> None:
109+
"""Opting out lets the backend error fail the request."""
110+
BackendProxy.set(FailingBackend(fail_get=fail_get, fail_set=fail_set))
111+
app, _calls = _counting_app(fail_open=False)
112+
client = TestClient(app)
113+
114+
with pytest.raises(ConnectionError, match="backend unreachable"):
115+
client.get("/item")
116+
117+
118+
@requires_memcached
119+
def test_response_over_the_memcached_item_size_is_served_unstored() -> None:
120+
"""Memcached refuses items over 1 MB by default; the route must still answer."""
121+
BackendProxy.set(MemcachedBackend(servers=[MEMCACHED_SERVER]))
122+
app = FastAPI()
123+
body = "x" * (2 * 1024 * 1024)
124+
125+
@app.get("/large", response_class=PlainTextResponse)
126+
@cache(ttl=60)
127+
async def large() -> PlainTextResponse:
128+
return PlainTextResponse(body)
129+
130+
response = TestClient(app).get("/large")
131+
132+
assert response.status_code == 200
133+
assert response.text == body

0 commit comments

Comments
 (0)