From 07c79e1753ee73ce77ddc314d8dfc6a8e3ffb930 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Wed, 14 Oct 2026 08:25:31 +0000 Subject: [PATCH] feat: raise BackendUnavailableError when the server cannot be reached (#260) Each network backend raised its own client's exceptions on a transport failure, so handling "the cache is down" meant knowing which backend is configured and importing redis or pymemcache exceptions. The Redis and Memcached backends' methods now re-raise a refused or reset connection, a timeout, and pymemcache's "All servers seem to be down" as BackendUnavailableError (a CacheXError), chained to the original. The class raised is built per original type and inherits from both, so it is also a redis.exceptions.ConnectionError, an OSError and so on: existing except clauses keep catching it. It pickles. Other errors (bad input, an item too large) pass through unchanged, and @cache still fails open on every backend error. Closes #260 --- changelog.d/260.added.md | 8 ++ docs/BACKENDS.md | 41 ++++++- fastapi_cachex/__init__.py | 2 + fastapi_cachex/backends/_unavailable.py | 100 ++++++++++++++++++ fastapi_cachex/backends/memcached.py | 38 +++++++ fastapi_cachex/backends/redis.py | 31 ++++++ fastapi_cachex/exceptions.py | 12 +++ i18n/zh-TW/docs/BACKENDS.md | 24 ++++- tests/backends/test_unavailable.py | 135 ++++++++++++++++++++++++ tests/test_exports.py | 1 + 10 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 changelog.d/260.added.md create mode 100644 fastapi_cachex/backends/_unavailable.py create mode 100644 tests/backends/test_unavailable.py diff --git a/changelog.d/260.added.md b/changelog.d/260.added.md new file mode 100644 index 0000000..fc2720f --- /dev/null +++ b/changelog.d/260.added.md @@ -0,0 +1,8 @@ +**The Redis and Memcached backends raise `BackendUnavailableError` when their +server cannot be reached.** A refused or reset connection, a timeout and a +Memcached server out of rotation all raise it, chained to the client's own +exception, so one `except BackendUnavailableError` handles "the cache is down" +around `CacheManager`, `CacheLock` or a backend call whatever the backend. The +error is also an instance of the client's exception type, so existing `except +redis.exceptions.ConnectionError` or `except OSError` clauses keep catching it. +`@cache` still fails open on it. diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index c90e337..66bfdcf 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -170,7 +170,8 @@ after about 0.25 s. The costs: - `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` and `CacheLock`, which raise backend errors instead of + `CacheManager` and `CacheLock`, which raise + [`BackendUnavailableError`](#backend-unavailable) instead of failing open, see those errors sooner. `RedisConfig` has no `retry` field, so pass it to the constructor. @@ -232,8 +233,9 @@ to a worker thread, including the multi-step of the keys existed; before 0.3.8 it took one thread trip per key and returned how many keys it was given. -When a server cannot be reached, every call that would go to it raises, and the -server is tried again after one second. Before 0.3.8, calls in the second after a +When a server cannot be reached, every call that would go to it raises +[`BackendUnavailableError`](#backend-unavailable), and the server is tried again +after one second. Before 0.3.8, calls in the second after a failure returned made-up results instead: `get()` a miss, `set()` nothing (the write was lost), `increment()` a fresh counter of 0. With several servers, a failed one is taken out of rotation at once and its keys go to the remaining servers until it @@ -276,6 +278,39 @@ when the block ends, including when the block raises. `MemoryBackend` had `aclose()`, and the Redis and Memcached backends were closed through `backend.client`. +## When the server is unavailable {#backend-unavailable} + +When the Redis or Memcached server cannot be reached, the backend raises +`BackendUnavailableError`, a `CacheXError`. That covers a refused or reset +connection, a timeout, and a Memcached server out of rotation ("All servers +seem to be down"). The memory backend never raises it. So code that wants to +handle "the cache is down" needs one `except`, whichever backend is set: + +```python +from fastapi_cachex import AppCache, BackendUnavailableError + + +@app.get("/report") +async def report(cache: AppCache) -> dict[str, int]: + try: + return await cache.get_or_set("report", build_report, ttl=300) + except BackendUnavailableError: + return build_report() # serve it uncached while the cache is down +``` + +The error is chained to the client's own exception (`__cause__`), and is also +an instance of that exception's type: a refused Memcached connection is a +`BackendUnavailableError` and a `ConnectionRefusedError`, a Redis one a +`BackendUnavailableError` and a `redis.exceptions.ConnectionError`. An +`except` clause naming the client's exception, written before 0.5.1, keeps +catching it. Errors that are not about reaching the server are left as they +are: an invalid argument, a value Memcached refuses as too large, a key that +holds something else. + +`@cache` is not affected: it [fails open](HTTP_CACHING.md#when-the-backend-fails) +on any backend error, this one included. `CacheManager`, `CacheLock` and direct +backend calls let it propagate. + ## Atomic backend primitives Every backend exposes atomic operations on top of `get`/`set`/`delete`, for diff --git a/fastapi_cachex/__init__.py b/fastapi_cachex/__init__.py index 8764d0f..61d5d0d 100644 --- a/fastapi_cachex/__init__.py +++ b/fastapi_cachex/__init__.py @@ -16,6 +16,7 @@ from .dependencies import get_app_cache as get_app_cache from .dependencies import get_cache_backend as get_cache_backend from .exceptions import BackendNotFoundError as BackendNotFoundError +from .exceptions import BackendUnavailableError as BackendUnavailableError from .exceptions import CacheXError as CacheXError from .exceptions import InvalidTTLError as InvalidTTLError from .exceptions import LockTimeoutError as LockTimeoutError @@ -55,6 +56,7 @@ def _read_version() -> str: "AppCache", "BackendNotFoundError", "BackendProxy", + "BackendUnavailableError", "CacheBackend", "CacheKey", "CacheKeyBuilder", diff --git a/fastapi_cachex/backends/_unavailable.py b/fastapi_cachex/backends/_unavailable.py new file mode 100644 index 0000000..fe46481 --- /dev/null +++ b/fastapi_cachex/backends/_unavailable.py @@ -0,0 +1,100 @@ +"""Turn a network backend's transport failures into ``BackendUnavailableError``. + +The Redis and Memcached clients each raise their own exceptions when the +server cannot be reached: ``redis.exceptions.ConnectionError`` and +``TimeoutError``, socket errors, pymemcache's "All servers seem to be down". +A caller that only wants to know "the cache is down" would have to know which +backend is configured and import that client's exceptions (#260). + +The backends' methods are wrapped so that such an error is raised again as a +``BackendUnavailableError`` chained to the original. The class raised is +built per original type and inherits from both, so it is also the original +type: an ``except redis.exceptions.ConnectionError`` or ``except OSError`` +written before keeps catching it, and ``except BackendUnavailableError`` now +works on every backend. +""" + +import functools +from collections.abc import Callable +from collections.abc import Coroutine +from typing import Any +from typing import ParamSpec +from typing import TypeVar +from typing import cast + +from fastapi_cachex.exceptions import BackendUnavailableError + +P = ParamSpec("P") +R = TypeVar("R") + +_SUBCLASSES: dict[type[Exception], type[BackendUnavailableError]] = {} + + +def _rebuild(error_type: type[Exception], args: tuple[Any, ...]) -> Exception: + """Unpickle a translated error: pickle cannot import a runtime class.""" + return _subclass_for(error_type)(*args) + + +def _reduce(self: BaseException) -> tuple[Any, ...]: + return _rebuild, (type(self).__bases__[1], self.args), self.__dict__ or None + + +def _subclass_for(error_type: type[Exception]) -> type[BackendUnavailableError]: + """The ``BackendUnavailableError`` that is also an ``error_type``.""" + subclass = _SUBCLASSES.get(error_type) + if subclass is None: + # Named like its base, so a traceback reads BackendUnavailableError; + # the chained original below it names the client's own exception. + subclass = cast( + "type[BackendUnavailableError]", + type( + "BackendUnavailableError", + (BackendUnavailableError, error_type), + { + "__module__": BackendUnavailableError.__module__, + "__qualname__": BackendUnavailableError.__qualname__, + "__doc__": BackendUnavailableError.__doc__, + "__reduce__": _reduce, + }, + ), + ) + _SUBCLASSES[error_type] = subclass + return subclass + + +def as_unavailable(error: Exception) -> BackendUnavailableError: + """``error`` as a ``BackendUnavailableError`` that is also its own type.""" + subclass = _subclass_for(type(error)) + try: + translated = subclass(*error.args) + except Exception: # noqa: BLE001 - a constructor that wants other arguments + translated = subclass(str(error)) + translated.__dict__.update(error.__dict__) + return translated + + +def translate_unavailable( + is_unavailable: Callable[[Exception], bool], +) -> Callable[ + [Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]] +]: + """A decorator raising the errors ``is_unavailable`` accepts as unavailable.""" + + def decorator( + func: Callable[P, Coroutine[Any, Any, R]], + ) -> Callable[P, Coroutine[Any, Any, R]]: + @functools.wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + try: + return await func(*args, **kwargs) + except BackendUnavailableError: + # Already translated by a wrapped method this one called. + raise + except Exception as e: + if not is_unavailable(e): + raise + raise as_unavailable(e) from e + + return wrapper + + return decorator diff --git a/fastapi_cachex/backends/memcached.py b/fastapi_cachex/backends/memcached.py index 018ba45..8fe1d7d 100644 --- a/fastapi_cachex/backends/memcached.py +++ b/fastapi_cachex/backends/memcached.py @@ -15,12 +15,35 @@ from fastapi_cachex.exceptions import TTLValueError from fastapi_cachex.types import CacheEntry +from ._unavailable import translate_unavailable from .base import BaseCacheBackend from .base import validate_delta from .base import validate_ttl logger = logging.getLogger(__name__) +# What pymemcache's HashClient raises while every server is out of rotation. +_ALL_SERVERS_DOWN = "All servers seem to be down" + + +def _is_unavailable(error: Exception) -> bool: + """Whether ``error`` means no Memcached server could be reached. + + A socket error (refused, reset, timed out), a connection the server + closed mid-reply, and pymemcache's "All servers seem to be down" while a + failed server is out of rotation. A ``MemcacheClientError`` (bad input, + an item over the size limit) is not about availability and is left as is. + """ + from pymemcache.exceptions import MemcacheError + from pymemcache.exceptions import MemcacheUnexpectedCloseError + + if isinstance(error, (OSError, MemcacheUnexpectedCloseError)): + return True + return type(error) is MemcacheError and _ALL_SERVERS_DOWN in str(error) + + +_unavailable = translate_unavailable(_is_unavailable) + # Default Memcached key prefix for fastapi-cachex DEFAULT_MEMCACHE_PREFIX = "fastapi_cachex:" @@ -202,6 +225,7 @@ def _make_key(self, key: str) -> str: logger.debug("Memcached key hashed; key=%s digest=%s", key, digest) return f"{self.key_prefix}{digest}" + @_unavailable async def get(self, key: str) -> CacheEntry | None: """Get value from cache. @@ -221,6 +245,7 @@ async def get(self, key: str) -> CacheEntry | None: logger.debug("Memcached HIT; key=%s", key) return value + @_unavailable async def set( self, key: str, value: CacheEntry, ttl: int | timedelta | None = None ) -> None: @@ -237,6 +262,7 @@ async def set( ) logger.debug("Memcached SET; key=%s ttl=%s", key, ttl) + @_unavailable async def get_and_delete(self, key: str) -> CacheEntry | None: """Atomically retrieve and remove a cached entry (see base class). @@ -275,6 +301,7 @@ def _get_and_delete(self, key: str) -> CacheEntry | None: msg = f"Memcached get_and_delete exceeded {_CAS_MAX_RETRIES} attempts on key {key!r}" raise CacheXError(msg) + @_unavailable async def set_if_absent( self, key: str, value: CacheEntry, ttl: int | timedelta | None = None ) -> bool: @@ -298,6 +325,7 @@ async def set_if_absent( ) return bool(stored) + @_unavailable async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool: """Atomically remove ``key`` while it holds ``expected`` (see base class). @@ -323,6 +351,7 @@ def _delete_if_equals(self, key: str, expected: CacheEntry) -> bool: ) return bool(deleted) + @_unavailable async def expire_if_equals( self, key: str, expected: CacheEntry, ttl: int | timedelta ) -> bool: @@ -356,6 +385,7 @@ def _expire_if_equals( ) return bool(updated) + @_unavailable async def set_if_equals( self, key: str, @@ -451,6 +481,7 @@ def _increment(self, prefixed_key: str, delta: int, exptime: int) -> int | None: logger.debug("Memcached INCREMENT RETRY; key=%s", prefixed_key) return None + @_unavailable async def increment( self, key: str, delta: int = 1, ttl: int | timedelta | None = None ) -> int: @@ -491,6 +522,7 @@ async def increment( logger.debug("Memcached INCREMENT; key=%s value=%s ttl=%s", key, value, ttl) return value + @_unavailable async def delete(self, key: str) -> bool: """Delete value from cache. @@ -507,6 +539,7 @@ async def delete(self, key: str) -> bool: logger.debug("Memcached DELETE; key=%s removed=%s", key, removed) return removed + @_unavailable async def delete_many(self, keys: Iterable[str]) -> int: """Remove every key in ``keys`` in one worker call; returns how many existed. @@ -531,6 +564,7 @@ def _delete_each(self, prefixed_keys: list[str]) -> int: bool(self.client.delete(key, noreply=False)) for key in prefixed_keys ) + @_unavailable async def clear(self) -> None: """Clear all values from cache. @@ -550,6 +584,7 @@ async def clear(self) -> None: await asyncio.to_thread(self.client.flush_all) logger.debug("Memcached CLEAR; flush_all issued") + @_unavailable async def clear_path(self, path: str, include_params: bool = False) -> int: """Delete the key that is exactly ``path``; warns on every call. @@ -588,6 +623,7 @@ async def clear_path(self, path: str, include_params: bool = False) -> int: ) return removed + @_unavailable async def clear_pattern(self, pattern: str) -> int: """Clear cached responses matching a pattern. @@ -611,6 +647,7 @@ async def clear_pattern(self, pattern: str) -> int: logger.debug("Memcached CLEAR_PATTERN unsupported; pattern=%s", pattern) return 0 + @_unavailable async def get_all_keys(self) -> list[str]: """Get all cache keys in the backend. @@ -633,6 +670,7 @@ async def get_all_keys(self) -> list[str]: logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list") return [] + @_unavailable async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]: """Get all cache data with expiry information. diff --git a/fastapi_cachex/backends/redis.py b/fastapi_cachex/backends/redis.py index ff40c07..6c14115 100644 --- a/fastapi_cachex/backends/redis.py +++ b/fastapi_cachex/backends/redis.py @@ -17,6 +17,7 @@ from fastapi_cachex.exceptions import CacheXError from fastapi_cachex.types import CacheEntry +from ._unavailable import translate_unavailable from .base import BaseCacheBackend from .base import validate_delta from .base import validate_ttl @@ -27,6 +28,21 @@ logger = logging.getLogger(__name__) + +def _is_unavailable(error: Exception) -> bool: + """Whether ``error`` means the Redis server could not be reached. + + redis-py's ``ConnectionError`` (its subclasses included) and + ``TimeoutError``, and a socket error that reaches the caller unwrapped. + """ + from redis.exceptions import ConnectionError as RedisConnectionError + from redis.exceptions import TimeoutError as RedisTimeoutError + + return isinstance(error, (RedisConnectionError, RedisTimeoutError, OSError)) + + +_unavailable = translate_unavailable(_is_unavailable) + # PTTL replies that are not a remaining lifetime. _PTTL_NO_EXPIRY = -1 _PTTL_MISSING = -2 @@ -300,12 +316,14 @@ async def _delete_keys(self, keys: list[str]) -> int: deleted += await self.client.delete(*keys[i : i + _BATCH_SIZE]) return deleted + @_unavailable async def get(self, key: str) -> CacheEntry | None: """Retrieve a cached response.""" value = decode_entry(await self.client.get(self._make_key(key))) logger.debug("Redis %s; key=%s", "HIT" if value else "MISS", key) return value + @_unavailable async def set( self, key: str, value: CacheEntry, ttl: int | timedelta | None = None ) -> None: @@ -314,18 +332,21 @@ async def set( await self.client.set(self._make_key(key), encode_entry(value), ex=ttl) logger.debug("Redis SET; key=%s ttl=%s", key, ttl) + @_unavailable async def delete(self, key: str) -> bool: """Remove a response from the cache; returns whether the key existed.""" removed = bool(await self.client.delete(self._make_key(key))) logger.debug("Redis DELETE; key=%s removed=%s", key, removed) return removed + @_unavailable async def delete_many(self, keys: Iterable[str]) -> int: """Remove every key in ``keys`` with batched DELs; returns how many existed.""" removed = await self._delete_keys([self._make_key(key) for key in keys]) logger.debug("Redis DELETE_MANY; removed=%s", removed) return removed + @_unavailable async def get_and_delete(self, key: str) -> CacheEntry | None: """Atomically retrieve and remove a cached entry (see base class). @@ -335,6 +356,7 @@ async def get_and_delete(self, key: str) -> CacheEntry | None: logger.debug("Redis GETDEL %s; key=%s", "HIT" if value else "MISS", key) return value + @_unavailable async def set_if_absent( self, key: str, value: CacheEntry, ttl: int | timedelta | None = None ) -> bool: @@ -354,6 +376,7 @@ async def set_if_absent( ) return bool(stored) + @_unavailable async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool: """Atomically remove ``key`` while it holds ``expected`` (see base class). @@ -372,6 +395,7 @@ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool: ) return bool(deleted) + @_unavailable async def expire_if_equals( self, key: str, expected: CacheEntry, ttl: int | timedelta ) -> bool: @@ -398,6 +422,7 @@ async def expire_if_equals( ) return bool(updated) + @_unavailable async def set_if_equals( self, key: str, @@ -428,6 +453,7 @@ async def set_if_equals( ) return bool(stored) + @_unavailable async def increment( self, key: str, delta: int = 1, ttl: int | timedelta | None = None ) -> int: @@ -455,6 +481,7 @@ async def increment( logger.debug("Redis INCREMENT; key=%s value=%s ttl=%s", key, value, ttl) return int(value) + @_unavailable async def clear(self) -> None: """Clear all cached responses for this namespace. @@ -463,6 +490,7 @@ async def clear(self) -> None: removed = await self._delete_matching(f"{self._prefix_pattern}*") logger.debug("Redis CLEAR; removed=%s", removed) + @_unavailable async def clear_path(self, path: str, include_params: bool = False) -> int: """Clear cached responses for a specific path. @@ -501,6 +529,7 @@ def matches(key: str) -> bool: ) return cleared_count + @_unavailable async def clear_pattern(self, pattern: str) -> int: """Clear cached responses matching a pattern. @@ -524,6 +553,7 @@ async def clear_pattern(self, pattern: str) -> int: ) return cleared_count + @_unavailable async def get_all_keys(self) -> list[str]: """Get all cache keys in the backend. @@ -535,6 +565,7 @@ async def get_all_keys(self) -> list[str]: logger.debug("Redis GET_ALL_KEYS; count=%s", len(logical_keys)) return logical_keys + @_unavailable async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]: """Get all cache data with expiry information. diff --git a/fastapi_cachex/exceptions.py b/fastapi_cachex/exceptions.py index f98c2cd..cc6cc60 100644 --- a/fastapi_cachex/exceptions.py +++ b/fastapi_cachex/exceptions.py @@ -22,6 +22,18 @@ class RequestNotFoundError(CacheXError): """Exception raised when a request is not found.""" +class BackendUnavailableError(CacheXError): + """The cache backend's server could not be reached. + + Raised by the Redis and Memcached backends for a transport failure: a + refused or reset connection, a timeout, or a server out of rotation. It is + chained to the client's own exception (``raise ... from``). The instance + raised is also of that exception's type, so an ``except`` clause naming + the client's exception (``redis.exceptions.ConnectionError``, + ``OSError``) keeps catching it (#260). The memory backend never raises it. + """ + + class InvalidTTLError(CacheXError): """An invalid ``ttl``, wherever it is passed. diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index c36c283..74a063a 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -113,7 +113,7 @@ BackendProxy.set(backend) - 不重試時,單一暫時性錯誤(例如指令執行到一半時連線被重設)會讓該指令失敗。`Retry(NoBackoff(), 1)` 會立即重試一次。 - `socket_timeout` 也會限制較慢的回覆。兩個逾時都應設定得比你平常的 Redis 延遲高,包括快取最大項目時的延遲。 -- 這些設定適用於後端送出的所有指令,而不只是 `@cache`。`CacheManager` 與 `CacheLock` 不會 fail open,而是拋出後端錯誤,它們也會更早收到這些錯誤。 +- 這些設定適用於後端送出的所有指令,而不只是 `@cache`。`CacheManager` 與 `CacheLock` 不會 fail open,而是拋出 [`BackendUnavailableError`](#backend-unavailable),它們也會更早收到這些錯誤。 `RedisConfig` 沒有 `retry` 欄位,因此請將它傳給建構函式。 @@ -142,7 +142,7 @@ BackendProxy.set(backend) 同步的 pymemcache 用戶端在工作執行緒中執行,並使用連線池,因此並行的請求絕不會共用同一個 socket。寫入會等待伺服器確認(`default_noreply=False`),因此只要 `set()` 返回,就能從連線池中的任何連線讀到該值。每次呼叫只切換到工作執行緒一次,多步驟的[原子操作](#atomic-backend-primitives)也是如此。`delete_many()` 在這一次呼叫中逐一對每個鍵送出 `DELETE`,並回傳實際存在的鍵數;0.3.8 之前它每個鍵都要切換一次執行緒,回傳的是傳入的鍵數。 -伺服器無法連線時,所有要送往它的呼叫都會拋出錯誤,一秒後會再嘗試連線。0.3.8 之前,失敗後一秒內的呼叫會回傳虛構的結果:`get()` 當成未命中、`set()` 沒有任何反應(寫入遺失)、`increment()` 當成新的計數器並回傳 0。設定多台伺服器時,失敗的那台會立即移出輪替,它的鍵會改由其餘伺服器處理,直到它恢復回應。 +伺服器無法連線時,所有要送往它的呼叫都會拋出 [`BackendUnavailableError`](#backend-unavailable),一秒後會再嘗試連線。0.3.8 之前,失敗後一秒內的呼叫會回傳虛構的結果:`get()` 當成未命中、`set()` 沒有任何反應(寫入遺失)、`increment()` 當成新的計數器並回傳 0。設定多台伺服器時,失敗的那台會立即移出輪替,它的鍵會改由其餘伺服器處理,直到它恢復回應。 ## 關閉後端 {#closing-a-backend} @@ -169,6 +169,26 @@ BackendProxy.set(backend) - 自訂後端會從 `BaseCacheBackend` 繼承一個什麼都不做的 `aclose()`;若後端持有連線或背景工作,請覆寫它。 - 若不呼叫 `aclose()`,開啟中的 socket 只會由垃圾回收器關閉,關閉應用程式時可能會產生 `ResourceWarning`。0.3.9 之前只有 `MemoryBackend` 有 `aclose()`,Redis 與 Memcached 後端要透過 `backend.client` 關閉。 +## 伺服器無法使用時 {#backend-unavailable} + +Redis 或 Memcached 伺服器無法連線時,後端會拋出 `BackendUnavailableError`(它是 `CacheXError` 的子類別)。這包括連線被拒絕或被重設、逾時,以及 Memcached 伺服器被移出輪替(「All servers seem to be down」)。記憶體後端永遠不會拋出它。因此想處理「快取掛了」的程式碼,不論設定的是哪種後端,只需要一個 `except`: + +```python +from fastapi_cachex import AppCache, BackendUnavailableError + + +@app.get("/report") +async def report(cache: AppCache) -> dict[str, int]: + try: + return await cache.get_or_set("report", build_report, ttl=300) + except BackendUnavailableError: + return build_report() # 快取無法使用時,直接產生不經快取的結果 +``` + +這個錯誤會串接用戶端原本的例外(`__cause__`),而且同時也是該例外型別的實例:Memcached 的連線被拒絕時,它既是 `BackendUnavailableError` 也是 `ConnectionRefusedError`;Redis 的則既是 `BackendUnavailableError` 也是 `redis.exceptions.ConnectionError`。0.5.1 之前寫好、直接捕捉用戶端例外的 `except` 仍然有效。與連線無關的錯誤維持原樣:無效的參數、Memcached 因過大而拒絕的值、存放了其他內容的鍵。 + +`@cache` 不受影響:遇到任何後端錯誤(包括這個)都會 [fail open](HTTP_CACHING.md#when-the-backend-fails)。`CacheManager`、`CacheLock` 與直接呼叫後端時,則會把它往外拋出。 + ## 後端的原子操作 {#atomic-backend-primitives} 每個後端都在 `get`/`set`/`delete` 之上提供原子操作,供會被許多並行請求讀寫的值使用: diff --git a/tests/backends/test_unavailable.py b/tests/backends/test_unavailable.py new file mode 100644 index 0000000..ecfb4d3 --- /dev/null +++ b/tests/backends/test_unavailable.py @@ -0,0 +1,135 @@ +"""A backend that cannot reach its server raises ``BackendUnavailableError`` (#260). + +The error raised is also the client's own exception type and is chained to the +original, so code that caught ``redis.exceptions.ConnectionError`` or +``OSError`` before keeps working. These tests need no server: they point the +backends at a port nothing listens on. +""" + +import pickle +import socket + +import pytest + +from fastapi_cachex import BackendUnavailableError +from fastapi_cachex import CacheManager +from fastapi_cachex import CacheXError +from fastapi_cachex.backends import MemcachedBackend +from fastapi_cachex.backends._unavailable import as_unavailable +from fastapi_cachex.backends._unavailable import translate_unavailable +from fastapi_cachex.types import CacheEntry +from tests.live_servers import REDIS_HOST +from tests.live_servers import UNCONNECTED_PORT + +ENTRY = CacheEntry(fingerprint="f", content=b"x") + + +def _closed_port() -> int: + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port: int = probe.getsockname()[1] + return port + + +def _assert_unavailable(error: BaseException, original: type[BaseException]) -> None: + assert isinstance(error, BackendUnavailableError) + assert isinstance(error, CacheXError) + assert isinstance(error, original) + assert type(error).__name__ == "BackendUnavailableError" + assert isinstance(error.__cause__, original) + assert not isinstance(error.__cause__, BackendUnavailableError) + + +async def test_redis_refused_connection() -> None: + redis_exceptions = pytest.importorskip("redis.exceptions") + from redis.asyncio.retry import Retry + from redis.backoff import NoBackoff + + from fastapi_cachex.backends import AsyncRedisCacheBackend + + backend = AsyncRedisCacheBackend( + host=REDIS_HOST, port=UNCONNECTED_PORT, retry=Retry(NoBackoff(), 0) + ) + try: + with pytest.raises(BackendUnavailableError) as excinfo: + await backend.set("k", ENTRY) + finally: + await backend.aclose() + _assert_unavailable(excinfo.value, redis_exceptions.ConnectionError) + + +async def test_memcached_refused_connection_then_server_out_of_rotation() -> None: + pytest.importorskip("pymemcache") + from pymemcache.exceptions import MemcacheError + + backend = MemcachedBackend(servers=[f"127.0.0.1:{_closed_port()}"]) + try: + with pytest.raises(BackendUnavailableError) as refused: + await backend.get("k") + # The failed server is now out of rotation for a second. + with pytest.raises(BackendUnavailableError) as down: + await backend.set("k", ENTRY) + finally: + await backend.aclose() + _assert_unavailable(refused.value, ConnectionRefusedError) + assert isinstance(refused.value, OSError) + assert refused.value.errno == refused.value.__cause__.errno # type: ignore[union-attr] + _assert_unavailable(down.value, MemcacheError) + assert "All servers seem to be down" in str(down.value) + + +async def test_cache_manager_lets_it_through() -> None: + """The case #260 is about: one except clause, whatever the backend.""" + pytest.importorskip("pymemcache") + backend = MemcachedBackend(servers=[f"127.0.0.1:{_closed_port()}"]) + try: + with pytest.raises(BackendUnavailableError): + await CacheManager(backend).get("report") + finally: + await backend.aclose() + + +async def test_other_errors_pass_through_unchanged() -> None: + error = ValueError("bad input") + + @translate_unavailable(lambda e: isinstance(e, OSError)) + async def fails() -> None: + raise error + + with pytest.raises(ValueError, match="bad input") as excinfo: + await fails() + assert excinfo.value is error + + +async def test_a_nested_call_is_translated_once() -> None: + translate = translate_unavailable(lambda e: isinstance(e, OSError)) + + @translate + async def inner() -> None: + raise ConnectionResetError(104, "reset") + + @translate + async def outer() -> None: + await inner() + + with pytest.raises(BackendUnavailableError) as excinfo: + await outer() + assert type(excinfo.value.__cause__) is ConnectionResetError + + +def test_the_subclass_is_built_once_per_type() -> None: + first = as_unavailable(TimeoutError("slow")) + second = as_unavailable(TimeoutError("slower")) + assert type(first) is type(second) + assert type(first) is not type(as_unavailable(ConnectionResetError("reset"))) + assert str(second) == "slower" + + +def test_it_survives_pickling() -> None: + """The per-type classes are built at runtime; pickling rebuilds them.""" + error = as_unavailable(ConnectionResetError(104, "reset")) + restored = pickle.loads(pickle.dumps(error)) # noqa: S301 - our own bytes + assert type(restored) is type(error) + assert isinstance(restored, ConnectionResetError) + assert restored.errno == 104 + assert str(restored) == str(error) diff --git a/tests/test_exports.py b/tests/test_exports.py index ddfdf12..5d0509c 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -12,6 +12,7 @@ "name", [ "BackendNotFoundError", + "BackendUnavailableError", "CacheXError", "InvalidTTLError", "ProxyNotSetError",