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
45 changes: 45 additions & 0 deletions docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,51 @@ hiredis will fail to negotiate it.

Complete runnable example: [`examples/redis_backend.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/redis_backend.py).

### Failing fast when Redis is down

`@cache` [fails open](HTTP_CACHING.md#when-the-backend-fails), but only once
the backend has given up. By default redis-py 8 retries a command that fails
with a connection error or timeout 10 times, with exponential backoff capped at
1 s between attempts. With Redis refusing connections, each `get` or `set` then
takes about 3 to 4 s to fail, so a cached request, which reads and then writes,
takes about 7 s. When the host does not answer at all, each of the 11 attempts
also waits for `socket_connect_timeout` (1 s by default), about 15 s per
command and 30 s per request.

Every keyword argument the backend does not name itself goes to the redis-py
client, so the retry policy and the timeouts can be set there:

```python
from redis.asyncio.retry import Retry
from redis.backoff import NoBackoff

from fastapi_cachex import BackendProxy
from fastapi_cachex.backends import AsyncRedisCacheBackend

backend = AsyncRedisCacheBackend(
host="127.0.0.1",
port=6379,
retry=Retry(NoBackoff(), 0), # no retries: the first error is final
socket_connect_timeout=0.25, # seconds to open a connection
socket_timeout=0.5, # seconds to wait for a reply
)
BackendProxy.set(backend)
```

With these settings a refused connection fails at once and an unreachable host
after about 0.25 s. The costs:

- Without retries, a single transient error, such as a connection reset in
the middle of a command, fails that command. `Retry(NoBackoff(), 1)` retries
once, immediately.
- `socket_timeout` also bounds slow replies. Set both timeouts above your
normal Redis latency, including for the largest entries you cache.
- The settings apply to every command the backend sends, not only to `@cache`.
`CacheManager`, `StateManager`, `CacheLock` and sessions, which raise backend
errors instead of failing open, see those errors sooner.

`RedisConfig` has no `retry` field, so pass it to the constructor.

## Memcached

Install the extra with `uv add "fastapi-cachex[memcached]"`. Before 0.3.8 it was
Expand Down
28 changes: 28 additions & 0 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,34 @@ 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.

Failing open is only as fast as the backend's error. By default redis-py 8
retries a failed Redis command 10 times with exponential backoff, so while
Redis refuses connections each read and each write takes about 3 to 4 s to
fail, and a cached request, which does both, about 7 s. When the Redis host
does not answer at all, every attempt also waits out the connect timeout, and a
request can take about 30 s. To fail within the timeouts, turn the retries off
and shorten the timeouts through the backend's keyword arguments:

```python
from redis.asyncio.retry import Retry
from redis.backoff import NoBackoff

from fastapi_cachex import BackendProxy
from fastapi_cachex.backends import AsyncRedisCacheBackend

backend = AsyncRedisCacheBackend(
host="127.0.0.1",
port=6379,
retry=Retry(NoBackoff(), 0), # no retries: the first error is final
socket_connect_timeout=0.25, # seconds to open a connection
socket_timeout=0.5, # seconds to wait for a reply
)
BackendProxy.set(backend)
```

See [Failing fast when Redis is down](BACKENDS.md#failing-fast-when-redis-is-down)
for the trade-offs.

The warning names the request's method and path and a `key_ref`, a short
SHA-256 digest of the cache key, but not the key itself: the key holds the
raw query string, `vary` header values and any `build_cache_key` components,
Expand Down
31 changes: 31 additions & 0 deletions i18n/zh-TW/docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,37 @@ BackendProxy.set(backend)

完整可執行範例(英文):[`examples/redis_backend.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/redis_backend.py)。

### Redis 停止運作時快速失敗 {#failing-fast-when-redis-is-down}

`@cache` 會 [fail open](HTTP_CACHING.md#when-the-backend-fails),但要等後端放棄之後才會。redis-py 8 預設會將因連線錯誤或逾時而失敗的指令重試 10 次,並採用指數退避,每次嘗試之間最多等待 1 秒。在 Redis 拒絕連線時,每次 `get` 或 `set` 因此要約 3 到 4 秒才會失敗,而先讀取再寫入的快取請求約需 7 秒。若主機完全沒有回應,11 次嘗試中的每一次還要等待 `socket_connect_timeout`(預設 1 秒),每個指令約 15 秒,每個請求約 30 秒。

後端本身沒有具名的關鍵字引數都會傳給 redis-py 用戶端,因此可以在這裡設定重試策略與逾時:

```python
from redis.asyncio.retry import Retry
from redis.backoff import NoBackoff

from fastapi_cachex import BackendProxy
from fastapi_cachex.backends import AsyncRedisCacheBackend

backend = AsyncRedisCacheBackend(
host="127.0.0.1",
port=6379,
retry=Retry(NoBackoff(), 0), # 不重試:第一次錯誤就是最終結果
socket_connect_timeout=0.25, # 建立連線的秒數上限
socket_timeout=0.5, # 等待回覆的秒數上限
)
BackendProxy.set(backend)
```

使用這些設定時,被拒絕的連線會立即失敗,無法連線的主機則約在 0.25 秒後失敗。代價如下:

- 不重試時,單一暫時性錯誤(例如指令執行到一半時連線被重設)會讓該指令失敗。`Retry(NoBackoff(), 1)` 會立即重試一次。
- `socket_timeout` 也會限制較慢的回覆。兩個逾時都應設定得比你平常的 Redis 延遲高,包括快取最大項目時的延遲。
- 這些設定適用於後端送出的所有指令,而不只是 `@cache`。`CacheManager`、`StateManager`、`CacheLock` 與 Session 不會 fail open,而是拋出後端錯誤,它們也會更早收到這些錯誤。

`RedisConfig` 沒有 `retry` 欄位,因此請將它傳給建構函式。

## Memcached {#memcached}

以 `uv add "fastapi-cachex[memcached]"` 安裝此 extra。0.3.8 以前這個 extra 名為 `memcache`;舊名稱仍可使用但已棄用,將於 0.4.0 移除。安裝時遇到不存在的 extra 只會顯示警告,因此 0.4.0 之後 `fastapi-cachex[memcache]` 會裝好套件但不含 `pymemcache`。
Expand Down
21 changes: 21 additions & 0 deletions i18n/zh-TW/docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,27 @@ handler 回傳一般資料而非 `Response` 時,得到的處理與沒有 `@cac

`@cache` 採取 fail open。讀取時後端拋出錯誤(例如 Redis 或 Memcached 無法連線),該請求會被當成快取未命中,照常執行 handler。儲存回應時拋出錯誤(例如回應超過 Memcached 的項目大小上限,預設為 1 MB),回應會照常送出,只是不會被儲存。兩種情況都會在 `fastapi_cachex.cache` logger 記錄一則警告,因此後端中斷不會讓有快取的路由變成 500;負載會轉到你的 handler 上,請留意這些警告。

fail open 的速度取決於後端多快回報錯誤。redis-py 8 預設會以指數退避重試失敗的 Redis 指令 10 次,因此在 Redis 拒絕連線時,每次讀取與寫入都要約 3 到 4 秒才會失敗,而同時進行兩者的快取請求約需 7 秒。若 Redis 主機完全沒有回應,每次嘗試還得等到連線逾時,一個請求可能需要約 30 秒。若要在逾時設定內就失敗,可透過後端的關鍵字引數關閉重試並縮短逾時:

```python
from redis.asyncio.retry import Retry
from redis.backoff import NoBackoff

from fastapi_cachex import BackendProxy
from fastapi_cachex.backends import AsyncRedisCacheBackend

backend = AsyncRedisCacheBackend(
host="127.0.0.1",
port=6379,
retry=Retry(NoBackoff(), 0), # 不重試:第一次錯誤就是最終結果
socket_connect_timeout=0.25, # 建立連線的秒數上限
socket_timeout=0.5, # 等待回覆的秒數上限
)
BackendProxy.set(backend)
```

取捨請見[Redis 停止運作時快速失敗](BACKENDS.md#failing-fast-when-redis-is-down)。

警告會列出請求的 method、路徑與 `key_ref`(快取鍵的簡短 SHA-256 摘要),但不會列出快取鍵本身:快取鍵含有原始查詢字串、`vary` 標頭值以及任何 `build_cache_key` 元件,可能是 token 或個人資料。完整的快取鍵會以 `DEBUG` 等級連同相同的 `key_ref` 記錄,因此排查問題時在 `fastapi_cachex.cache` 開啟 `DEBUG`,即可將警告對應到其快取鍵。

傳入 `fail_open=False` 則會讓後端錯誤直接往外拋出,使該請求失敗:
Expand Down
36 changes: 36 additions & 0 deletions tests/backends/test_redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,42 @@ def test_redis_protocol_default_is_resp2() -> None:
assert hasattr(backend.client, "connection_pool")


@requires_redis_package
async def test_redis_fail_fast_settings_reach_the_client() -> None:
"""The documented fail-fast example's kwargs reach redis-py (#325).

Built exactly as in docs/BACKENDS.md "Failing fast when Redis is down",
against a port with nothing listening: the retry policy and both timeouts
land in the connection kwargs, and a read fails without redis-py's default
retries (several seconds on redis-py 8).
"""
from redis.asyncio.retry import Retry
from redis.backoff import NoBackoff

backend = AsyncRedisCacheBackend(
host=REDIS_HOST,
port=UNCONNECTED_PORT,
retry=Retry(NoBackoff(), 0),
socket_connect_timeout=0.25,
socket_timeout=0.5,
)
kwargs = backend.client.connection_pool.connection_kwargs
assert kwargs["socket_connect_timeout"] == 0.25
assert kwargs["socket_timeout"] == 0.5
retry = kwargs["retry"]
assert isinstance(retry, Retry)
# Private attributes (missing from the stubs): redis-py 5 has no public getter.
assert isinstance(retry._backoff, NoBackoff) # type: ignore[attr-defined]
assert retry._retries == 0 # type: ignore[attr-defined]

from redis.exceptions import ConnectionError as RedisConnectionError

start = time.perf_counter()
with pytest.raises(RedisConnectionError):
await backend.get("key")
assert time.perf_counter() - start < 1.0


@requires_redis_package
def test_redis_load_from_config_forwards_protocol() -> None:
"""load_from_config must forward the protocol field from RedisConfig."""
Expand Down
Loading