diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f84e3..92ee699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,15 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. `Exception` keep working. A `try` block that lists `except CacheXError` before `except SessionError` now takes the `CacheXError` branch for session errors. ([#162](https://github.com/allen0099/FastAPI-CacheX/issues/162)) +- **`@cache` serves uncached responses when the backend fails.** A backend + error on read or write turned every cached route into a 500, even after the + handler had produced a good response; this includes a healthy Memcached + rejecting a response over its 1 MB item size. A failed read now counts as a + miss and a failed write leaves the response unstored, each logged as a + warning on `fastapi_cachex.cache`. `@cache(fail_open=False)` restores the + old behaviour. `invalidate()`, `CacheManager`, `StateManager`, `CacheLock` + and sessions still raise. + ([#228](https://github.com/allen0099/FastAPI-CacheX/issues/228)) ### Deprecated diff --git a/CLAUDE.md b/CLAUDE.md index ae357b7..3c5d02b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,7 @@ The library has four independent subsystems: **1. HTTP Caching (`fastapi_cachex/cache.py`, `proxy.py`, `backends/`)** - `@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. - Cache flow: check `no-store` → check `no-cache` → check ETag (`If-None-Match`) → check TTL-based cache hit → execute handler → store result. +- 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. - Only GET requests are cached; other methods bypass the cache entirely. - Cache keys follow the format `method|||host|||path|||query_params` (separator defined in `types.py`). - `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. diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 1ec21dc..d310951 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -142,6 +142,10 @@ BackendProxy.set(backend) - `clear()` issues `flush_all`, which wipes the whole Memcached server, not just this namespace - A key Memcached would reject (over 250 bytes, whitespace, non-ASCII) is stored under its SHA-256 digest +- Values larger than the server's item size limit (1 MB by default, `memcached -I`) + are rejected with an error. `@cache` logs it and serves the response unstored + (see [When the backend fails](HTTP_CACHING.md#when-the-backend-fails)); other + callers get the error - Consider using the Redis backend if you need pattern-based cache clearing The synchronous pymemcache client runs in worker threads and is connection-pooled, diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 02946ae..8be7451 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -96,6 +96,29 @@ route's response model (declared or inferred from the return annotation, with the `response_model_*` options), the route's `status_code` applies, and the status and headers set on an injected `response: Response` parameter are kept. +### When the backend fails + +`@cache` fails open. If the backend raises while reading, for example because +Redis or Memcached is unreachable, the request is treated as a cache miss and +the handler runs. If storing the response raises, for example because it is +larger than Memcached's item size limit (1 MB by default), the response is +served unstored. Either way a warning is logged on the `fastapi_cachex.cache` +logger, and a backend outage cannot turn cached routes into 500s. The load +goes to your handlers instead, so watch for those warnings. + +Pass `fail_open=False` to let the backend error propagate and fail the request +instead: + +```python +@app.get("/report") +@cache(ttl=300, fail_open=False) +async def report(): + return await build_report() +``` + +This only covers `@cache`. `invalidate()`, `CacheManager`, `StateManager`, +`CacheLock` and sessions still raise backend errors to the caller. + ## Cache keys Cache keys are generated in the following format to avoid collisions: diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index c07f476..5ec7e00 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -434,6 +434,7 @@ def cache( immutable: bool = False, must_revalidate: bool = False, key_builder: CacheKeyBuilder | None = None, + fail_open: bool = True, ) -> Callable[[HandlerCallable], AsyncResponseCallable]: """Cache decorator for FastAPI route handlers. @@ -469,6 +470,10 @@ def cache( must_revalidate: Send ``must-revalidate``. key_builder: Custom function to build cache keys. If None, uses ``default_key_builder``. + fail_open: When the backend raises, log a warning and answer without + the cache: a failed read counts as a miss and a failed write + leaves the response unstored. ``False`` lets the error propagate, + so the request fails. Returns: Decorator function that wraps route handlers with caching logic @@ -629,7 +634,17 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response: logger.debug("Bypassed the backend; key=%s", cache_key) return _with_cache_control(response, cache_control) - cached_data = await cache_backend.get(cache_key) + try: + cached_data = await cache_backend.get(cache_key) + except Exception as e: + if not fail_open: + raise + logger.warning( + "Cache backend read failed; serving uncached. key=%s error=%r", + cache_key, + e, + ) + cached_data = None current_response: Response | None = None current_body: bytes | None = None @@ -714,18 +729,28 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response: assert ( current_body is not None ) # guaranteed by early-return guards above - await cache_backend.set( - cache_key, - CacheEntry( - fingerprint=current_etag, - content=current_body, - media_type=_media_type_of(current_response), - status_code=current_response.status_code, - headers=_cacheable_headers(current_response), - ), - ttl=ttl, - ) - logger.debug("Updated cache entry; key=%s ttl=%s", cache_key, ttl) + try: + await cache_backend.set( + cache_key, + CacheEntry( + fingerprint=current_etag, + content=current_body, + media_type=_media_type_of(current_response), + status_code=current_response.status_code, + headers=_cacheable_headers(current_response), + ), + ttl=ttl, + ) + except Exception as e: + if not fail_open: + raise + logger.warning( + "Cache backend write failed; response not stored. key=%s error=%r", + cache_key, + e, + ) + else: + logger.debug("Updated cache entry; key=%s ttl=%s", cache_key, ttl) return _with_cache_control(current_response, cache_control) diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index 643a4c7..02c136e 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -107,6 +107,7 @@ BackendProxy.set(backend) - `clear_path()` 只會刪除完全相符的那個鍵;`include_params` 沒有作用 - `clear()` 會發出 `flush_all`,清空整台 Memcached 伺服器,而不只是這個命名空間 - Memcached 會拒絕的鍵(超過 250 位元組、含空白字元或非 ASCII 字元)會改以其 SHA-256 摘要儲存 +- 超過伺服器項目大小上限(預設 1 MB,可用 `memcached -I` 調整)的值會被拒絕並拋出錯誤。`@cache` 會記錄該錯誤,並照常送出不儲存的回應(見[後端發生錯誤時](HTTP_CACHING.md#when-the-backend-fails));其他呼叫端則會收到該錯誤 - 若需要依模式清除快取,請考慮使用 Redis 後端 同步的 pymemcache 用戶端在工作執行緒中執行,並使用連線池,因此並行的請求絕不會共用同一個 socket。寫入會等待伺服器確認(`default_noreply=False`),因此只要 `set()` 返回,就能從連線池中的任何連線讀到該值。 diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index ca1f5b3..aff01c8 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -72,6 +72,21 @@ async def non_store_endpoint(): handler 回傳一般資料而非 `Response` 時,得到的處理與沒有 `@cache` 時相同:回傳值會經過路由的 response model 驗證與過濾(明確宣告的,或由回傳型別註記推斷,並套用 `response_model_*` 選項),套用路由的 `status_code`,而在注入的 `response: Response` 參數上設定的狀態碼與標頭也會保留。 +### 後端發生錯誤時 {#when-the-backend-fails} + +`@cache` 採取 fail open。讀取時後端拋出錯誤(例如 Redis 或 Memcached 無法連線),該請求會被當成快取未命中,照常執行 handler。儲存回應時拋出錯誤(例如回應超過 Memcached 的項目大小上限,預設為 1 MB),回應會照常送出,只是不會被儲存。兩種情況都會在 `fastapi_cachex.cache` logger 記錄一則警告,因此後端中斷不會讓有快取的路由變成 500;負載會轉到你的 handler 上,請留意這些警告。 + +傳入 `fail_open=False` 則會讓後端錯誤直接往外拋出,使該請求失敗: + +```python +@app.get("/report") +@cache(ttl=300, fail_open=False) +async def report(): + return await build_report() +``` + +這只適用於 `@cache`。`invalidate()`、`CacheManager`、`StateManager`、`CacheLock` 與 Session 仍會把後端錯誤拋給呼叫端。 + ## 快取鍵 {#cache-keys} 快取鍵以下列格式產生,以避免衝突: diff --git a/tests/test_cache_backend_failure.py b/tests/test_cache_backend_failure.py new file mode 100644 index 0000000..36a2219 --- /dev/null +++ b/tests/test_cache_backend_failure.py @@ -0,0 +1,133 @@ +"""`@cache` when the backend raises (#228). + +A cache is an optimisation: by default a failing backend makes the route answer +uncached instead of turning every cached route into a 500. +""" + +import logging + +import pytest +from fastapi import FastAPI +from fastapi.responses import PlainTextResponse +from fastapi.testclient import TestClient + +from fastapi_cachex import BackendProxy +from fastapi_cachex import cache +from fastapi_cachex.backends import MemcachedBackend +from fastapi_cachex.backends.memory import MemoryBackend +from fastapi_cachex.types import CacheEntry +from tests.live_servers import MEMCACHED_SERVER +from tests.live_servers import requires_memcached + + +class FailingBackend(MemoryBackend): + """A memory backend whose reads and/or writes raise like a lost connection.""" + + def __init__(self, *, fail_get: bool, fail_set: bool) -> None: + super().__init__() + self.fail_get = fail_get + self.fail_set = fail_set + + async def get(self, key: str) -> CacheEntry | None: + if self.fail_get: + msg = "backend unreachable" + raise ConnectionError(msg) + return await super().get(key) + + async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None: + if self.fail_set: + msg = "backend unreachable" + raise ConnectionError(msg) + await super().set(key, value, ttl) + + +def _counting_app(*, fail_open: bool = True) -> tuple[FastAPI, list[int]]: + app = FastAPI() + calls: list[int] = [] + + @app.get("/item") + @cache(ttl=60, fail_open=fail_open) + async def item() -> dict[str, int]: + calls.append(1) + return {"n": len(calls)} + + return app, calls + + +@pytest.mark.parametrize( + ("fail_get", "fail_set"), + [(True, False), (False, True), (True, True)], + ids=["get", "set", "both"], +) +def test_failing_backend_serves_the_handler_response( + caplog: pytest.LogCaptureFixture, *, fail_get: bool, fail_set: bool +) -> None: + """A read or write error is logged and the handler's response is served.""" + BackendProxy.set(FailingBackend(fail_get=fail_get, fail_set=fail_set)) + app, calls = _counting_app() + client = TestClient(app) + + with caplog.at_level(logging.WARNING, logger="fastapi_cachex.cache"): + first = client.get("/item") + second = client.get("/item") + + assert first.status_code == 200 + assert first.json() == {"n": 1} + assert "ETag" in first.headers + assert first.headers["Cache-Control"] == "max-age=60" + assert second.status_code == 200 + # Nothing could be served from the backend, so the handler ran again. + assert len(calls) == 2 + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + if fail_get: + assert any("read failed" in m for m in warnings) + if fail_set: + assert any("write failed" in m for m in warnings) + + +def test_a_write_failure_does_not_hide_a_working_read() -> None: + """Only the failing half is skipped: entries already stored are still served.""" + backend = FailingBackend(fail_get=False, fail_set=False) + BackendProxy.set(backend) + app, calls = _counting_app() + client = TestClient(app) + client.get("/item") + + backend.fail_set = True + response = client.get("/item") + + assert response.json() == {"n": 1} + assert len(calls) == 1 + + +@pytest.mark.parametrize( + ("fail_get", "fail_set"), [(True, False), (False, True)], ids=["get", "set"] +) +def test_fail_open_false_propagates_the_error( + *, fail_get: bool, fail_set: bool +) -> None: + """Opting out lets the backend error fail the request.""" + BackendProxy.set(FailingBackend(fail_get=fail_get, fail_set=fail_set)) + app, _calls = _counting_app(fail_open=False) + client = TestClient(app) + + with pytest.raises(ConnectionError, match="backend unreachable"): + client.get("/item") + + +@requires_memcached +def test_response_over_the_memcached_item_size_is_served_unstored() -> None: + """Memcached refuses items over 1 MB by default; the route must still answer.""" + BackendProxy.set(MemcachedBackend(servers=[MEMCACHED_SERVER])) + app = FastAPI() + body = "x" * (2 * 1024 * 1024) + + @app.get("/large", response_class=PlainTextResponse) + @cache(ttl=60) + async def large() -> PlainTextResponse: + return PlainTextResponse(body) + + response = TestClient(app).get("/large") + + assert response.status_code == 200 + assert response.text == body