diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 7787eb1..3426c93 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -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 diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 60687e8..155d227 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -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, diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index 6c4dff7..bf9e929 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -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`。 diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 76b87f5..414e86c 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -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` 則會讓後端錯誤直接往外拋出,使該請求失敗: diff --git a/tests/backends/test_redis.py b/tests/backends/test_redis.py index e42818b..56e0eb2 100644 --- a/tests/backends/test_redis.py +++ b/tests/backends/test_redis.py @@ -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."""