Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 38 additions & 13 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions i18n/zh-TW/docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()` 返回,就能從連線池中的任何連線讀到該值。
Expand Down
15 changes: 15 additions & 0 deletions i18n/zh-TW/docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}

快取鍵以下列格式產生,以避免衝突:
Expand Down
133 changes: 133 additions & 0 deletions tests/test_cache_backend_failure.py
Original file line number Diff line number Diff line change
@@ -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
Loading