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
8 changes: 8 additions & 0 deletions changelog.d/337.added.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 24 additions & 5 deletions docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions fastapi_cachex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,9 +63,12 @@ def _read_version() -> str:
"CacheManagerProxy",
"CacheXError",
"CachedFunction",
"InvalidTTLError",
"LockTimeoutError",
"ProxyNotSetError",
"RequestNotFoundError",
"TTLTypeError",
"TTLValueError",
"__version__",
"add_routes",
"build_cache_key",
Expand Down
21 changes: 12 additions & 9 deletions fastapi_cachex/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -89,25 +92,25 @@ 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


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())


Expand Down
8 changes: 5 additions & 3 deletions fastapi_cachex/backends/memcached.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
19 changes: 12 additions & 7 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand Down
11 changes: 6 additions & 5 deletions fastapi_cachex/cached.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``)
Expand Down
26 changes: 26 additions & 0 deletions fastapi_cachex/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
7 changes: 4 additions & 3 deletions fastapi_cachex/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand Down
18 changes: 15 additions & 3 deletions i18n/zh-TW/docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。)

Expand Down
2 changes: 1 addition & 1 deletion i18n/zh-TW/docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
10 changes: 9 additions & 1 deletion tests/test_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand Down
Loading
Loading