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",