diff --git a/changelog.d/337.added.md b/changelog.d/337.added.md new file mode 100644 index 0000000..f567d07 --- /dev/null +++ b/changelog.d/337.added.md @@ -0,0 +1,8 @@ +**An invalid `ttl` raises `TTLTypeError` or `TTLValueError` wherever it is +passed.** Both derive from the new `InvalidTTLError`, a `CacheXError`, and are +raised by the backends, `CacheManager`, `CacheLock`, `@cached` and `@cache` +(`ttl` and `stale_ttl`), so one `except InvalidTTLError` handles all of them. +`TTLTypeError` is also a `TypeError` and `TTLValueError` a `ValueError`, which +the backends, `CacheManager` and `CacheLock` raised before, so existing +`except TypeError`, `except ValueError` and `except CacheXError` clauses keep +working. diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 753b0e5..c90e337 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -377,16 +377,35 @@ the entry never expires, or a number of seconds from 1 up to `MAX_TTL` seconds (`timedelta(minutes=5)` is stored as `300`). The checks run before any backend I/O: -- Zero, negative and larger values raise `ValueError`. The underlying stores +- Zero, negative and larger values raise `TTLValueError`. The underlying stores disagree on what `0` means: Memcached reads it as "never expire", Redis rejects `EX 0`, and an in-process dict would expire the entry at once. -- A `float`, a `bool` or any other type raises `TypeError`. A float worked +- A `float`, a `bool` or any other type raises `TTLTypeError`. A float worked only on the memory backend, and `True` was taken as one second. A `timedelta` with a fraction of a second (`timedelta(seconds=1.5)`) raises - `ValueError`, since every backend keeps time in whole seconds. + `TTLValueError`, since every backend keeps time in whole seconds. - Memcached cannot store an expiry after 2038-01-19 (its exptime is a signed - 32-bit timestamp), so the Memcached backend raises `ValueError` for a `ttl` - that reaches past it instead of accepting a write it would drop at once. + 32-bit timestamp), so the Memcached backend raises `TTLValueError` for a + `ttl` that reaches past it instead of accepting a write it would drop at once. + +Both classes derive from `InvalidTTLError`, a `CacheXError`, and are raised +for an invalid `ttl` wherever it is passed: the backends, `CacheManager`, +`CacheLock`, `@cached` and `@cache` (`ttl` and `stale_ttl`). Catch +`InvalidTTLError` to handle all of them. `TTLTypeError` is also a `TypeError` +and `TTLValueError` a `ValueError`, which is what the backends, `CacheManager` +and `CacheLock` raised before 0.5.1, so an existing `except TypeError` or +`except ValueError` keeps working, as does an `except CacheXError` around +`@cache`: + +```python +from fastapi_cachex import InvalidTTLError + + +try: + await manager.set("report", data, ttl=user_supplied_ttl) +except InvalidTTLError as e: + raise HTTPException(422, detail=str(e)) from e +``` A third-party backend should call `fastapi_cachex.backends.base.validate_ttl(ttl)` in its `set` and store the `int` it returns, to follow the same rules and diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index ad38ba0..9cc66e2 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -110,7 +110,7 @@ When a cached entry is valid (within TTL): - **With `no-cache` directive**: Forces revalidation with fresh content before deciding on 304 - **With `private=True`**: Nothing is read from or written to the shared backend; the handler runs every time and only `If-None-Match` revalidation applies - **Without `ttl`** (`ttl=None`): Nothing is read from or written to the backend, as with `private=True`. The handler runs on every request, and `If-None-Match` gets a 304 only when it matches the freshly rendered response, so an old ETag never gets a 304 once the content has changed -- **With `ttl=0`**: Sends `max-age=0` and otherwise behaves like `ttl=None`. A negative `ttl`, a non-`int` one (such as `1.5` or `True`) and one above `MAX_TTL` (see [TTL values](BACKENDS.md#ttl-values)) are rejected with `CacheXError` when the decorator is applied +- **With `ttl=0`**: Sends `max-age=0` and otherwise behaves like `ttl=None`. A negative `ttl`, a non-`int` one (such as `1.5` or `True`) and one above `MAX_TTL` (see [TTL values](BACKENDS.md#ttl-values)) are rejected with `TTLTypeError` or `TTLValueError` when the decorator is applied; both are `CacheXError` subclasses, the same ones the backends raise (see [TTL values](BACKENDS.md#ttl-values)) - **With a `timedelta`**: `ttl=timedelta(minutes=5)` is the same as `ttl=300`, and so is `stale_ttl`; a `timedelta` with a fraction of a second is rejected like a `float` ### What still runs on a hit diff --git a/fastapi_cachex/__init__.py b/fastapi_cachex/__init__.py index bbd249a..8764d0f 100644 --- a/fastapi_cachex/__init__.py +++ b/fastapi_cachex/__init__.py @@ -17,9 +17,12 @@ from .dependencies import get_cache_backend as get_cache_backend from .exceptions import BackendNotFoundError as BackendNotFoundError from .exceptions import CacheXError as CacheXError +from .exceptions import InvalidTTLError as InvalidTTLError from .exceptions import LockTimeoutError as LockTimeoutError from .exceptions import ProxyNotSetError as ProxyNotSetError from .exceptions import RequestNotFoundError as RequestNotFoundError +from .exceptions import TTLTypeError as TTLTypeError +from .exceptions import TTLValueError as TTLValueError from .lock import CacheLock as CacheLock from .manager import CacheManager as CacheManager from .manager_proxy import CacheManagerProxy as CacheManagerProxy @@ -60,9 +63,12 @@ def _read_version() -> str: "CacheManagerProxy", "CacheXError", "CachedFunction", + "InvalidTTLError", "LockTimeoutError", "ProxyNotSetError", "RequestNotFoundError", + "TTLTypeError", + "TTLValueError", "__version__", "add_routes", "build_cache_key", diff --git a/fastapi_cachex/backends/base.py b/fastapi_cachex/backends/base.py index 1b23f28..09e1707 100644 --- a/fastapi_cachex/backends/base.py +++ b/fastapi_cachex/backends/base.py @@ -11,6 +11,8 @@ from typing import overload from fastapi_cachex.exceptions import CacheXError +from fastapi_cachex.exceptions import TTLTypeError +from fastapi_cachex.exceptions import TTLValueError from fastapi_cachex.types import CACHE_KEY_SEPARATOR from fastapi_cachex.types import HTTP_KEY_FORMAT_TAG from fastapi_cachex.types import CacheEntry @@ -75,10 +77,11 @@ def validate_ttl(ttl: int | timedelta | None) -> int | None: backend stores. Raises: - TypeError: If ``ttl`` is not an ``int`` or a ``timedelta`` (``bool`` - included) - ValueError: If ``ttl`` is zero, negative, larger than ``MAX_TTL`` or - a ``timedelta`` that is not a whole number of seconds + TTLTypeError: If ``ttl`` is not an ``int`` or a ``timedelta`` + (``bool`` included); also a ``TypeError`` + TTLValueError: If ``ttl`` is zero, negative, larger than ``MAX_TTL`` + or a ``timedelta`` that is not a whole number of seconds; also a + ``ValueError`` """ if ttl is None: return None @@ -89,13 +92,13 @@ def validate_ttl(ttl: int | timedelta | None) -> int | None: "ttl must be an int number of seconds, a timedelta or None, " f"got {type(ttl).__name__}" ) - raise TypeError(msg) + raise TTLTypeError(msg) if ttl <= 0: msg = f"ttl must be a positive number of seconds or None, got {ttl!r}" - raise ValueError(msg) + raise TTLValueError(msg) if ttl > MAX_TTL: msg = f"ttl must be at most {MAX_TTL} seconds (about 68 years)" - raise ValueError(msg) + raise TTLValueError(msg) return ttl @@ -103,11 +106,11 @@ def _whole_seconds(value: timedelta, name: str) -> int: """The whole number of seconds in ``value``. Raises: - ValueError: If ``value`` has a fraction of a second + TTLValueError: If ``value`` has a fraction of a second """ if value.microseconds: msg = f"{name} must be a whole number of seconds, got {value!r}" - raise ValueError(msg) + raise TTLValueError(msg) return int(value.total_seconds()) diff --git a/fastapi_cachex/backends/memcached.py b/fastapi_cachex/backends/memcached.py index 2417105..018ba45 100644 --- a/fastapi_cachex/backends/memcached.py +++ b/fastapi_cachex/backends/memcached.py @@ -12,6 +12,7 @@ from fastapi_cachex.backends.codec import decode_entry from fastapi_cachex.backends.codec import encode_entry from fastapi_cachex.exceptions import CacheXError +from fastapi_cachex.exceptions import TTLValueError from fastapi_cachex.types import CacheEntry from .base import BaseCacheBackend @@ -88,8 +89,9 @@ def _expiry(ttl: int | None) -> int: in 1970 and expire the entry immediately. ``None`` means no expiry. Raises: - ValueError: If the expiry would fall after 2038-01-19, which Memcached - cannot represent: it would accept the write and drop the item. + TTLValueError: If the expiry would fall after 2038-01-19, which + Memcached cannot represent: it would accept the write and drop the + item. Also a ``ValueError``. """ if ttl is None: return 0 @@ -100,7 +102,7 @@ def _expiry(ttl: int | None) -> int: f"ttl {ttl!r} expires after 2038-01-19, which Memcached cannot " "store; use a shorter ttl or None" ) - raise ValueError(msg) + raise TTLValueError(msg) return expires_at return ttl diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index 8d7b21e..99f938c 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -58,6 +58,8 @@ from .exceptions import BackendNotFoundError from .exceptions import CacheXError from .exceptions import RequestNotFoundError +from .exceptions import TTLTypeError +from .exceptions import TTLValueError from .headers import add_vary from .proxy import BackendProxy from .proxy import get_backend_or_fallback @@ -378,16 +380,19 @@ def _seconds(name: str, value: int | timedelta | None) -> int | None: does not use ``validate_ttl``; the type and range rules are the same. Raises: - CacheXError: If ``value`` is not an ``int`` or a ``timedelta``, is - negative, is larger than ``MAX_TTL`` or is a ``timedelta`` that - is not a whole number of seconds + TTLTypeError: If ``value`` is not an ``int`` or a ``timedelta`` + TTLValueError: If ``value`` is negative, is larger than ``MAX_TTL`` or + is a ``timedelta`` that is not a whole number of seconds + + Both are ``CacheXError`` subclasses, which ``@cache`` raised before, and + the same classes the backends raise (#337). """ if value is None: return None if isinstance(value, timedelta): if value.microseconds: msg = f"{name} must be a whole number of seconds, got {value!r}" - raise CacheXError(msg) + raise TTLValueError(msg) value = int(value.total_seconds()) elif isinstance(value, bool) or not isinstance(value, int): # Checked here: at request time the backend would reject it, and @@ -396,13 +401,13 @@ def _seconds(name: str, value: int | timedelta | None) -> int | None: f"{name} must be an int number of seconds or a timedelta, " f"got {type(value).__name__}" ) - raise CacheXError(msg) + raise TTLTypeError(msg) if value < 0: msg = f"{name} must not be negative" - raise CacheXError(msg) + raise TTLValueError(msg) if value > MAX_TTL: msg = f"{name} must be at most {MAX_TTL} seconds" - raise CacheXError(msg) + raise TTLValueError(msg) return value diff --git a/fastapi_cachex/cached.py b/fastapi_cachex/cached.py index 9d816c0..69afcb0 100644 --- a/fastapi_cachex/cached.py +++ b/fastapi_cachex/cached.py @@ -232,12 +232,13 @@ def cached( A decorator turning the function into a ``CachedFunction`` Raises: - TypeError: When the decorator is applied, if ``ttl`` is not an - ``int``, a ``timedelta`` or ``None``, or ``lock`` is not a - ``bool`` or ``None`` - ValueError: When the decorator is applied, if ``ttl`` is zero, + TTLTypeError: When the decorator is applied, if ``ttl`` is not an + ``int``, a ``timedelta`` or ``None`` (also a ``TypeError``) + TTLValueError: When the decorator is applied, if ``ttl`` is zero, negative, larger than ``MAX_TTL`` or not a whole number of - seconds + seconds (also a ``ValueError``) + TypeError: When the decorator is applied, if ``lock`` is not a + ``bool`` or ``None`` CacheXError: When the decorator is applied, if ``key`` is not a ``str``, a callable or ``None``; and when the function is called, if the key cannot be built (see ``cache_key``) diff --git a/fastapi_cachex/exceptions.py b/fastapi_cachex/exceptions.py index 1018b20..f98c2cd 100644 --- a/fastapi_cachex/exceptions.py +++ b/fastapi_cachex/exceptions.py @@ -22,5 +22,31 @@ class RequestNotFoundError(CacheXError): """Exception raised when a request is not found.""" +class InvalidTTLError(CacheXError): + """An invalid ``ttl``, wherever it is passed. + + Raised for a ``ttl`` (or ``stale_ttl``, ``default_ttl``, ``lock_ttl``) + that ``@cache``, ``@cached``, a backend, ``CacheManager`` or ``CacheLock`` + refuses. Catch this class to handle all of them; the subclasses keep the + built-in exception each place raised before (#337). + """ + + +class TTLTypeError(InvalidTTLError, TypeError): + """A ``ttl`` that is not an ``int`` or a ``timedelta`` (``bool`` included). + + Also a ``TypeError``, which the backends, ``CacheManager`` and + ``CacheLock`` raised before 0.5.1. + """ + + +class TTLValueError(InvalidTTLError, ValueError): + """A ``ttl`` out of range, or a ``timedelta`` with a fraction of a second. + + Also a ``ValueError``, which the backends, ``CacheManager`` and + ``CacheLock`` raised before 0.5.1. + """ + + class LockTimeoutError(CacheXError, TimeoutError): """Exception raised when acquiring a lock or waiting for stampede protection times out.""" diff --git a/fastapi_cachex/lock.py b/fastapi_cachex/lock.py index 7b47791..28a83e9 100644 --- a/fastapi_cachex/lock.py +++ b/fastapi_cachex/lock.py @@ -48,9 +48,10 @@ class CacheLock: key_prefix: Prefix for lock keys (default: "lock:") Raises: - TypeError: If ``ttl`` is not an ``int`` or a ``timedelta`` - ValueError: If ``ttl`` is zero, negative, larger than ``MAX_TTL`` or - not a whole number of seconds + TTLTypeError: If ``ttl`` is not an ``int`` or a ``timedelta`` (also + a ``TypeError``) + TTLValueError: If ``ttl`` is zero, negative, larger than ``MAX_TTL`` + or not a whole number of seconds (also a ``ValueError``) """ def __init__( diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index 272e75c..c36c283 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -213,9 +213,21 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): 每個 `ttl` 參數(`set`、`set_if_absent`、`set_if_equals`、`increment`,以及建立在它們之上的 `CacheManager` 與 `CacheLock` 方法和預設值)只能是 `None`(表示項目永不過期),或介於 1 到 `MAX_TTL`(2**31 - 1,約 68 年)之間的秒數,型別可以是 `int` 或整數秒的 `datetime.timedelta`(`timedelta(minutes=5)` 會存成 `300`)。這些檢查都在存取後端之前進行: -- 零、負值與更大的值會拋出 `ValueError`。底層儲存對 `0` 的解讀各不相同:Memcached 把 exptime `0` 視為「永不過期」,Redis 拒絕 `EX 0`,而行程內的 dict 則會立即讓項目過期。 -- `float`、`bool` 或其他型別會拋出 `TypeError`。float 過去只在記憶體後端上有效,而 `True` 會被當成一秒。帶有小數秒的 `timedelta`(`timedelta(seconds=1.5)`)會拋出 `ValueError`,因為每個後端都以整秒計時。 -- Memcached 無法儲存 2038-01-19 之後的過期時間(它的 exptime 是 signed 32 位元時間戳),因此 Memcached 後端遇到超過這個時間點的 `ttl` 會拋出 `ValueError`,而不是接受一筆會立即被丟棄的寫入。 +- 零、負值與更大的值會拋出 `TTLValueError`。底層儲存對 `0` 的解讀各不相同:Memcached 把 exptime `0` 視為「永不過期」,Redis 拒絕 `EX 0`,而行程內的 dict 則會立即讓項目過期。 +- `float`、`bool` 或其他型別會拋出 `TTLTypeError`。float 過去只在記憶體後端上有效,而 `True` 會被當成一秒。帶有小數秒的 `timedelta`(`timedelta(seconds=1.5)`)會拋出 `TTLValueError`,因為每個後端都以整秒計時。 +- Memcached 無法儲存 2038-01-19 之後的過期時間(它的 exptime 是 signed 32 位元時間戳),因此 Memcached 後端遇到超過這個時間點的 `ttl` 會拋出 `TTLValueError`,而不是接受一筆會立即被丟棄的寫入。 + +這兩個類別都繼承自 `InvalidTTLError`(它是 `CacheXError` 的子類別),無論 `ttl` 傳給哪裡,只要無效就會拋出:後端、`CacheManager`、`CacheLock`、`@cached` 與 `@cache`(`ttl` 和 `stale_ttl`)。捕捉 `InvalidTTLError` 就能處理所有情況。`TTLTypeError` 同時也是 `TypeError`,`TTLValueError` 同時也是 `ValueError`,也就是 0.5.1 之前後端、`CacheManager` 與 `CacheLock` 拋出的例外,因此既有的 `except TypeError` 或 `except ValueError` 仍然有效,包在 `@cache` 外的 `except CacheXError` 也一樣: + +```python +from fastapi_cachex import InvalidTTLError + + +try: + await manager.set("report", data, ttl=user_supplied_ttl) +except InvalidTTLError as e: + raise HTTPException(422, detail=str(e)) from e +``` 第三方後端應在其 `set` 中呼叫 `fastapi_cachex.backends.base.validate_ttl(ttl)` 並儲存它回傳的 `int`,以遵循相同規則並接受 `timedelta`;並在 `increment` 中呼叫 `validate_delta(delta)`。(`@cache(ttl=0)` 是另一回事:它會送出 `max-age=0`,且絕不會把 `0` 傳給後端;見 [HTTP 快取](HTTP_CACHING.md)。) diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 4b899d4..2459ab5 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -76,7 +76,7 @@ async def items(): ... - **使用 `no-cache` 指令**:先以新產生的內容強制重新驗證,再決定是否回 304 - **使用 `private=True`**:不從共用後端讀取,也不寫入;每次都執行 handler,只有 `If-None-Match` 重新驗證有效 - **未設定 `ttl`**(`ttl=None`):與 `private=True` 相同,不從後端讀取,也不寫入。每個請求都會執行 handler,只有當 `If-None-Match` 與新產生的回應相符時才回 304,因此內容變更後,舊的 ETag 永遠不會得到 304 -- **使用 `ttl=0`**:送出 `max-age=0`,其餘行為與 `ttl=None` 相同。負數、非 `int`(例如 `1.5` 或 `True`)或超過 `MAX_TTL`(見 [TTL 值](BACKENDS.md#ttl-values))的 `ttl`,都會在套用裝飾器時以 `CacheXError` 拒絕 +- **使用 `ttl=0`**:送出 `max-age=0`,其餘行為與 `ttl=None` 相同。負數、非 `int`(例如 `1.5` 或 `True`)或超過 `MAX_TTL`(見 [TTL 值](BACKENDS.md#ttl-values))的 `ttl`,都會在套用裝飾器時以 `TTLTypeError` 或 `TTLValueError` 拒絕;兩者都是 `CacheXError` 的子類別,也與後端拋出的例外相同(見 [TTL 值](BACKENDS.md#ttl-values)) - **使用 `timedelta`**:`ttl=timedelta(minutes=5)` 等同於 `ttl=300`,`stale_ttl` 也一樣;帶有小數秒的 `timedelta` 會像 `float` 一樣被拒絕 ### 命中時仍會執行的部分 {#what-still-runs-on-a-hit} diff --git a/tests/test_exports.py b/tests/test_exports.py index d7e160c..ddfdf12 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -10,7 +10,15 @@ @pytest.mark.parametrize( "name", - ["BackendNotFoundError", "CacheXError", "ProxyNotSetError", "RequestNotFoundError"], + [ + "BackendNotFoundError", + "CacheXError", + "InvalidTTLError", + "ProxyNotSetError", + "RequestNotFoundError", + "TTLTypeError", + "TTLValueError", + ], ) def test_core_exception_is_exported(name: str) -> None: assert name in fastapi_cachex.__all__ diff --git a/tests/test_ttl_errors.py b/tests/test_ttl_errors.py new file mode 100644 index 0000000..ab3c6e3 --- /dev/null +++ b/tests/test_ttl_errors.py @@ -0,0 +1,136 @@ +"""An invalid ``ttl`` raises the same exceptions wherever it is passed (#337). + +``TTLTypeError`` and ``TTLValueError`` are ``InvalidTTLError``, a +``CacheXError``, and also the ``TypeError`` / ``ValueError`` the backends, +``CacheManager`` and ``CacheLock`` raised before, so every existing ``except`` +clause keeps catching them. +""" + +from collections.abc import Callable +from datetime import timedelta +from typing import Any + +import pytest + +from fastapi_cachex import CacheLock +from fastapi_cachex import CacheManager +from fastapi_cachex import CacheXError +from fastapi_cachex import InvalidTTLError +from fastapi_cachex import TTLTypeError +from fastapi_cachex import TTLValueError +from fastapi_cachex import cache +from fastapi_cachex import cached +from fastapi_cachex.backends.base import MAX_TTL +from fastapi_cachex.backends.memcached import _expiry +from fastapi_cachex.backends.memory import MemoryBackend +from fastapi_cachex.types import CacheEntry + +ENTRY = CacheEntry(fingerprint="e", content=b"v") + +BAD_TTLS = [ + pytest.param(1.5, TTLTypeError, TypeError, id="float"), + pytest.param(True, TTLTypeError, TypeError, id="bool"), + pytest.param("60", TTLTypeError, TypeError, id="str"), + pytest.param(-1, TTLValueError, ValueError, id="negative"), + pytest.param(MAX_TTL + 1, TTLValueError, ValueError, id="too-large"), + pytest.param(timedelta(seconds=1.5), TTLValueError, ValueError, id="fraction"), +] + + +def _assert_ttl_error( + excinfo: pytest.ExceptionInfo[BaseException], + error: type[InvalidTTLError], + builtin: type[Exception], +) -> None: + assert type(excinfo.value) is error + assert isinstance(excinfo.value, InvalidTTLError) + assert isinstance(excinfo.value, CacheXError) + assert isinstance(excinfo.value, builtin) + + +def test_the_classes_keep_the_built_in_bases() -> None: + assert issubclass(InvalidTTLError, CacheXError) + assert issubclass(TTLTypeError, InvalidTTLError) + assert issubclass(TTLTypeError, TypeError) + assert issubclass(TTLValueError, InvalidTTLError) + assert issubclass(TTLValueError, ValueError) + + +@pytest.mark.parametrize(("ttl", "error", "builtin"), BAD_TTLS) +async def test_backend_set( + ttl: Any, error: type[InvalidTTLError], builtin: type[Exception] +) -> None: + backend = MemoryBackend() + try: + with pytest.raises(builtin) as excinfo: + await backend.set("k", ENTRY, ttl=ttl) + finally: + await backend.aclose() + _assert_ttl_error(excinfo, error, builtin) + + +@pytest.mark.parametrize(("ttl", "error", "builtin"), BAD_TTLS) +async def test_cache_manager( + ttl: Any, error: type[InvalidTTLError], builtin: type[Exception] +) -> None: + backend = MemoryBackend() + try: + with pytest.raises(builtin) as built: + CacheManager(backend, default_ttl=ttl) + _assert_ttl_error(built, error, builtin) + with pytest.raises(builtin) as stored: + await CacheManager(backend).set("k", 1, ttl=ttl) + _assert_ttl_error(stored, error, builtin) + finally: + await backend.aclose() + + +@pytest.mark.parametrize(("ttl", "error", "builtin"), BAD_TTLS) +async def test_cache_lock( + ttl: Any, error: type[InvalidTTLError], builtin: type[Exception] +) -> None: + backend = MemoryBackend() + try: + with pytest.raises(builtin) as excinfo: + CacheLock("job", ttl=ttl, backend=backend) + finally: + await backend.aclose() + _assert_ttl_error(excinfo, error, builtin) + + +@pytest.mark.parametrize(("ttl", "error", "builtin"), BAD_TTLS) +def test_cached_decorator( + ttl: Any, error: type[InvalidTTLError], builtin: type[Exception] +) -> None: + with pytest.raises(builtin) as excinfo: + cached(ttl=ttl) + _assert_ttl_error(excinfo, error, builtin) + + +# @cache allows 0 (max-age=0), so a zero ttl is not among its bad values. +@pytest.mark.parametrize(("ttl", "error", "builtin"), BAD_TTLS) +@pytest.mark.parametrize( + "apply", + [ + pytest.param(lambda ttl: cache(ttl=ttl), id="ttl"), + pytest.param( + lambda ttl: cache(ttl=60, stale="revalidate", stale_ttl=ttl), + id="stale_ttl", + ), + ], +) +def test_cache_decorator( + apply: Callable[[Any], object], + ttl: Any, + error: type[InvalidTTLError], + builtin: type[Exception], +) -> None: + with pytest.raises(CacheXError) as excinfo: + apply(ttl) + _assert_ttl_error(excinfo, error, builtin) + + +def test_memcached_expiry_past_2038() -> None: + with pytest.raises(ValueError, match="2038") as excinfo: + _expiry(MAX_TTL) + _assert_ttl_error(excinfo, TTLValueError, ValueError)