fastapi-cachex 0.3.8__tar.gz → 0.3.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/PKG-INFO +26 -8
  2. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/README.md +25 -7
  3. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/__init__.py +2 -0
  4. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/base.py +36 -2
  5. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/codec.py +13 -1
  6. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/config.py +8 -1
  7. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/memcached.py +84 -25
  8. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/memory.py +3 -1
  9. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/redis.py +115 -27
  10. fastapi_cachex-0.3.9/fastapi_cachex/cache.py +1458 -0
  11. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/exceptions.py +2 -2
  12. fastapi_cachex-0.3.9/fastapi_cachex/headers.py +27 -0
  13. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/lock.py +4 -1
  14. fastapi_cachex-0.3.9/fastapi_cachex/manager.py +581 -0
  15. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/proxy.py +12 -1
  16. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/routes.py +71 -26
  17. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/__init__.py +2 -0
  18. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/config.py +82 -4
  19. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/dependencies.py +159 -18
  20. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/manager.py +22 -4
  21. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/middleware.py +235 -28
  22. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/token_serializers.py +23 -6
  23. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/state/exceptions.py +1 -1
  24. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/state/manager.py +25 -9
  25. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/types.py +20 -0
  26. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/pyproject.toml +1 -1
  27. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/pyproject.toml.orig +1 -1
  28. fastapi_cachex-0.3.8/fastapi_cachex/cache.py +0 -810
  29. fastapi_cachex-0.3.8/fastapi_cachex/manager.py +0 -253
  30. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/LICENSE +0 -0
  31. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/backends/__init__.py +0 -0
  32. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/dependencies.py +0 -0
  33. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/directives.py +0 -0
  34. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/manager_proxy.py +0 -0
  35. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/py.typed +0 -0
  36. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/exceptions.py +0 -0
  37. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/models.py +0 -0
  38. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/proxy.py +0 -0
  39. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/session/security.py +0 -0
  40. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/state/__init__.py +0 -0
  41. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/state/dependencies.py +0 -0
  42. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/state/models.py +0 -0
  43. {fastapi_cachex-0.3.8 → fastapi_cachex-0.3.9}/fastapi_cachex/state/proxy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-cachex
3
- Version: 0.3.8
3
+ Version: 0.3.9
4
4
  Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
5
5
  Keywords: fastapi,cache,etag,cache-control,redis,memcached,in-memory
6
6
  Author: allen0099
@@ -114,27 +114,45 @@ def build_report() -> dict:
114
114
 
115
115
  @app.get("/report")
116
116
  async def report(cache: AppCache):
117
- # Cache any JSON value in your own code.
118
- return await cache.get_or_set("report", build_report, ttl=300)
117
+ # Cache any JSON value in your own code. lock=True runs build_report once
118
+ # for concurrent misses (the default from 0.4.0).
119
+ return await cache.get_or_set("report", build_report, ttl=300, lock=True)
119
120
  ```
120
121
 
122
+ > [!IMPORTANT]
123
+ > Put `@cache` **below** the route decorator. FastAPI registers whatever function
124
+ > reaches `@app.get(...)`; with `@cache` on top, FastAPI registers the
125
+ > undecorated handler, so the route works but nothing is cached and nothing warns
126
+ > (see [Decorator order](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#decorator-order)).
127
+ >
128
+ > ```python
129
+ > @app.get("/items") # ✅ route decorator first,
130
+ > @cache(ttl=60) # @cache directly above the function
131
+ > async def items(): ...
132
+ >
133
+ > @cache(ttl=60) # ❌ never called: nothing is cached
134
+ > @app.get("/items")
135
+ > async def items(): ...
136
+ > ```
137
+
121
138
  > [!WARNING]
122
139
  > The default cache key carries no user identity. Cache authenticated endpoints
123
- > with `private=True` or a per-user key builder — see
140
+ > with `private=True` or a per-user key builder plus `cache_authorized=True`
141
+ > (requests with `Authorization` or a session otherwise bypass the backend) — see
124
142
  > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
125
143
 
126
144
  ## Documentation
127
145
 
146
+ - [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/) — what 0.4.0 changes and how to act on the 0.3.9 warnings now
128
147
  - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
129
- - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
130
148
  - [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
131
149
  - [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
132
- - [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
133
- - [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) — one-shot OAuth/CSRF state tokens
134
150
  - [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion
151
+ - [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/), [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) (one-shot OAuth/CSRF state tokens) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
152
+ - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
135
153
  - [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
136
154
  - [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
137
- - [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)
155
+ - [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/) · [Security policy](https://github.com/allen0099/FastAPI-CacheX/blob/master/SECURITY.md) — report vulnerabilities privately, not in public issues
138
156
  - [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
139
157
 
140
158
  ## License
@@ -71,27 +71,45 @@ def build_report() -> dict:
71
71
 
72
72
  @app.get("/report")
73
73
  async def report(cache: AppCache):
74
- # Cache any JSON value in your own code.
75
- return await cache.get_or_set("report", build_report, ttl=300)
74
+ # Cache any JSON value in your own code. lock=True runs build_report once
75
+ # for concurrent misses (the default from 0.4.0).
76
+ return await cache.get_or_set("report", build_report, ttl=300, lock=True)
76
77
  ```
77
78
 
79
+ > [!IMPORTANT]
80
+ > Put `@cache` **below** the route decorator. FastAPI registers whatever function
81
+ > reaches `@app.get(...)`; with `@cache` on top, FastAPI registers the
82
+ > undecorated handler, so the route works but nothing is cached and nothing warns
83
+ > (see [Decorator order](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#decorator-order)).
84
+ >
85
+ > ```python
86
+ > @app.get("/items") # ✅ route decorator first,
87
+ > @cache(ttl=60) # @cache directly above the function
88
+ > async def items(): ...
89
+ >
90
+ > @cache(ttl=60) # ❌ never called: nothing is cached
91
+ > @app.get("/items")
92
+ > async def items(): ...
93
+ > ```
94
+
78
95
  > [!WARNING]
79
96
  > The default cache key carries no user identity. Cache authenticated endpoints
80
- > with `private=True` or a per-user key builder — see
97
+ > with `private=True` or a per-user key builder plus `cache_authorized=True`
98
+ > (requests with `Authorization` or a session otherwise bypass the backend) — see
81
99
  > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
82
100
 
83
101
  ## Documentation
84
102
 
103
+ - [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/) — what 0.4.0 changes and how to act on the 0.3.9 warnings now
85
104
  - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
86
- - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
87
105
  - [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
88
106
  - [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
89
- - [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
90
- - [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) — one-shot OAuth/CSRF state tokens
91
107
  - [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion
108
+ - [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/), [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) (one-shot OAuth/CSRF state tokens) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
109
+ - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
92
110
  - [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
93
111
  - [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
94
- - [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)
112
+ - [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/) · [Security policy](https://github.com/allen0099/FastAPI-CacheX/blob/master/SECURITY.md) — report vulnerabilities privately, not in public issues
95
113
  - [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
96
114
 
97
115
  ## License
@@ -4,6 +4,7 @@ import logging
4
4
  from importlib.metadata import PackageNotFoundError
5
5
  from importlib.metadata import version
6
6
 
7
+ from .cache import build_cache_key as build_cache_key
7
8
  from .cache import cache as cache
8
9
  from .cache import default_key_builder as default_key_builder
9
10
  from .cache import invalidate as invalidate
@@ -107,6 +108,7 @@ __all__ = [
107
108
  "StateManagerProxy",
108
109
  "__version__",
109
110
  "add_routes",
111
+ "build_cache_key",
110
112
  "cache",
111
113
  "default_key_builder",
112
114
  "get_app_cache",
@@ -4,6 +4,8 @@ import warnings
4
4
  from abc import ABC
5
5
  from abc import abstractmethod
6
6
  from collections.abc import Iterable
7
+ from types import TracebackType
8
+ from typing import TYPE_CHECKING
7
9
  from typing import Any
8
10
 
9
11
  from fastapi_cachex.types import CACHE_KEY_SEPARATOR
@@ -11,6 +13,11 @@ from fastapi_cachex.types import CacheEntry
11
13
  from fastapi_cachex.types import counter_entry
12
14
  from fastapi_cachex.types import counter_value
13
15
 
16
+ if TYPE_CHECKING:
17
+ # Type-only: typing.Self is 3.11+, and typing_extensions is not a runtime
18
+ # dependency.
19
+ from typing_extensions import Self
20
+
14
21
 
15
22
  def warn_if_path_shaped(pattern: str, cleared: int) -> None:
16
23
  """Warn when a ``clear_pattern`` that cleared nothing was written as a path.
@@ -89,7 +96,33 @@ def validate_delta(delta: int) -> int:
89
96
 
90
97
 
91
98
  class BaseCacheBackend(ABC):
92
- """Base class for all cache backends."""
99
+ """Base class for all cache backends.
100
+
101
+ Every backend is an async context manager: ``async with`` returns the
102
+ backend itself and calls ``aclose()`` on the way out.
103
+ """
104
+
105
+ async def aclose(self) -> None: # noqa: B027 - a no-op default, not abstract
106
+ """Release what the backend holds open: connections, background tasks.
107
+
108
+ Call it once on shutdown, typically at the end of a FastAPI lifespan.
109
+ It is safe to call more than once. The base implementation does
110
+ nothing, for backends with nothing to release; the built-in backends
111
+ override it.
112
+ """
113
+
114
+ async def __aenter__(self) -> "Self":
115
+ """Return the backend itself."""
116
+ return self
117
+
118
+ async def __aexit__(
119
+ self,
120
+ exc_type: type[BaseException] | None,
121
+ exc_value: BaseException | None,
122
+ traceback: TracebackType | None,
123
+ ) -> None:
124
+ """Close the backend with ``aclose()``."""
125
+ await self.aclose()
93
126
 
94
127
  @abstractmethod
95
128
  async def get(self, key: str) -> CacheEntry | None:
@@ -246,7 +279,8 @@ class BaseCacheBackend(ABC):
246
279
  Raises:
247
280
  CacheXError: If ``key`` holds a cached response instead of a counter
248
281
  TypeError: If ``delta`` or ``ttl`` is not an ``int``
249
- ValueError: If ``ttl`` is out of range
282
+ ValueError: If ``ttl`` is out of range, or ``delta`` does not fit
283
+ in a signed 64-bit integer
250
284
  """
251
285
  validate_delta(delta)
252
286
  validate_ttl(ttl)
@@ -4,6 +4,8 @@ Both backends store a ``CacheEntry`` as a JSON document; ``orjson`` is used when
4
4
  it is installed and the standard library ``json`` module otherwise.
5
5
  """
6
6
 
7
+ import math
8
+
7
9
  from fastapi_cachex.types import COUNTER_FINGERPRINT
8
10
  from fastapi_cachex.types import DEFAULT_STATUS_CODE
9
11
  from fastapi_cachex.types import CacheEntry
@@ -45,12 +47,20 @@ def encode_entry(entry: CacheEntry) -> bytes:
45
47
  "media_type": entry.media_type,
46
48
  "status_code": entry.status_code,
47
49
  "headers": entry.headers,
50
+ "stored_at": entry.stored_at,
48
51
  },
49
52
  )
50
53
  # orjson returns bytes, stdlib json returns str
51
54
  return serialized if isinstance(serialized, bytes) else serialized.encode("utf-8")
52
55
 
53
56
 
57
+ def _stored_at(value: object) -> float | None:
58
+ """``stored_at`` from a document; anything but a finite number is unknown."""
59
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
60
+ return None
61
+ return float(value) if math.isfinite(value) else None
62
+
63
+
54
64
  def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
55
65
  """Rebuild a ``CacheEntry`` from a stored value.
56
66
 
@@ -61,7 +71,8 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
61
71
  cache miss.
62
72
 
63
73
  Documents written before entries carried a status code and headers simply
64
- lack those keys and decode to a plain ``200`` with no extra headers.
74
+ lack those keys and decode to a plain ``200`` with no extra headers; those
75
+ written before entries carried ``stored_at`` decode with ``None``.
65
76
  """
66
77
  if raw is None:
67
78
  return None
@@ -76,6 +87,7 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
76
87
  media_type=data.get("media_type"),
77
88
  status_code=data.get("status_code", DEFAULT_STATUS_CODE),
78
89
  headers=data.get("headers"),
90
+ stored_at=_stored_at(data.get("stored_at")),
79
91
  )
80
92
  except _DECODE_ERRORS:
81
93
  return None
@@ -16,7 +16,14 @@ class RedisConfig(BaseModel):
16
16
  default=None, description="Redis server password"
17
17
  )
18
18
  db: int = Field(default=0, ge=0, description="Redis database number")
19
- encoding: str = Field(default="utf-8", description="Character encoding to use")
19
+ encoding: str = Field(
20
+ default="utf-8",
21
+ description=(
22
+ "Deprecated, removed in 0.4.0: leave it unset. Character encoding "
23
+ "the client decodes replies with; setting it emits a "
24
+ "DeprecationWarning in load_from_config()."
25
+ ),
26
+ )
20
27
  socket_timeout: float = Field(
21
28
  default=1.0, description="Timeout for socket operations in seconds"
22
29
  )
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import hashlib
5
+ import inspect
5
6
  import logging
6
7
  import time
7
8
  import warnings
@@ -68,6 +69,26 @@ def _expiry(ttl: int | None) -> int:
68
69
  return ttl
69
70
 
70
71
 
72
+ def _caller_stacklevel() -> int:
73
+ """Return the ``stacklevel`` of the first frame outside fastapi_cachex.
74
+
75
+ For a ``warnings.warn`` in the method that calls this, so a warning raised
76
+ through ``CacheManager`` names the application's line, not manager.py.
77
+ """
78
+ frame = inspect.currentframe()
79
+ if frame is None or frame.f_back is None: # no frame support
80
+ return 2
81
+ # Level 1 is the method that warns; start at its caller.
82
+ level, frame = 2, frame.f_back.f_back
83
+ while frame is not None:
84
+ module = frame.f_globals.get("__name__", "")
85
+ if module != "fastapi_cachex" and not module.startswith("fastapi_cachex."):
86
+ break
87
+ frame = frame.f_back
88
+ level += 1
89
+ return level
90
+
91
+
71
92
  class MemcachedBackend(BaseCacheBackend):
72
93
  """Memcached backend implementation.
73
94
 
@@ -78,7 +99,10 @@ class MemcachedBackend(BaseCacheBackend):
78
99
  conflicts with other applications.
79
100
 
80
101
  Limitations:
81
- - Pattern-based clearing (clear_pattern) is not supported by Memcached protocol
102
+ - Memcached cannot enumerate keys: clear_pattern clears nothing (returns
103
+ 0) and get_all_keys/get_cache_data return empty results, each with a
104
+ RuntimeWarning; clear_path only deletes a key named exactly as the path
105
+ - clear() issues flush_all, which empties the whole server
82
106
  - Operations are wrapped to appear async but use blocking sync client internally
83
107
  """
84
108
 
@@ -126,6 +150,15 @@ class MemcachedBackend(BaseCacheBackend):
126
150
  )
127
151
  self.key_prefix = key_prefix
128
152
 
153
+ async def aclose(self) -> None:
154
+ """Close every pooled connection to every server.
155
+
156
+ Safe to call more than once. The pymemcache client reconnects on the
157
+ next call, so a backend used after ``aclose()`` opens new sockets that
158
+ need another ``aclose()``.
159
+ """
160
+ await asyncio.to_thread(self.client.close)
161
+
129
162
  def _make_key(self, key: str) -> str:
130
163
  """Namespace a cache key, hashing it when Memcached would refuse it.
131
164
 
@@ -307,20 +340,42 @@ class MemcachedBackend(BaseCacheBackend):
307
340
  return None if result is None else int(result)
308
341
 
309
342
  def _increment(self, prefixed_key: str, delta: int, exptime: int) -> int | None:
310
- """Run ``increment``'s INCR, ADD and retried INCR in one worker thread."""
343
+ """Run ``increment``'s INCR, ADD and retried INCR in one worker thread.
344
+
345
+ Returns ``None`` only when the counter vanished after every one of
346
+ ``_CAS_MAX_RETRIES`` ADD + INCR attempts.
347
+ """
311
348
  value = self._add_delta(prefixed_key, delta)
312
- if value is None:
349
+ if value is not None:
350
+ return value
351
+ for _ in range(_CAS_MAX_RETRIES):
313
352
  # No counter yet: ADD is atomic and a no-op when a concurrent
314
- # call created it first, so the retry always finds a counter.
353
+ # call created it first.
315
354
  self.client.add(prefixed_key, b"0", exptime, noreply=False)
316
355
  value = self._add_delta(prefixed_key, delta)
317
- return value
356
+ if value is not None:
357
+ return value
358
+ # Memcached keeps time in whole seconds, so a counter created with
359
+ # a short ttl can expire before the INCR that follows its ADD. It
360
+ # expired inside its own window, so the next window starts again
361
+ # at ``delta``.
362
+ logger.debug("Memcached INCREMENT RETRY; key=%s", prefixed_key)
363
+ return None
318
364
 
319
365
  async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
320
366
  """Atomically add ``delta`` to the counter at ``key`` (see base class).
321
367
 
322
368
  Memcached counters are unsigned, so a negative ``delta`` uses DECR,
323
369
  which stops at 0 instead of going negative.
370
+
371
+ Creating a counter takes an ADD and then an INCR. If the new counter
372
+ expires in between (Memcached's clock has one-second resolution, so a
373
+ ``ttl=1`` counter can live for well under a second), the ADD + INCR
374
+ pair is retried, starting a new window at ``delta``.
375
+
376
+ Raises:
377
+ CacheXError: If the key holds a value that is not a counter, or
378
+ the counter vanished after every ADD + INCR attempt.
324
379
  """
325
380
  validate_delta(delta)
326
381
  validate_ttl(ttl)
@@ -339,7 +394,7 @@ class MemcachedBackend(BaseCacheBackend):
339
394
  msg = "Cache key holds a value that is not a counter"
340
395
  raise CacheXError(msg) from e
341
396
  if value is None:
342
- msg = "Counter vanished between ADD and INCR"
397
+ msg = f"Counter vanished between ADD and INCR on each of {_CAS_MAX_RETRIES} attempts"
343
398
  raise CacheXError(msg)
344
399
  logger.debug("Memcached INCREMENT; key=%s value=%s ttl=%s", key, value, ttl)
345
400
  return value
@@ -392,36 +447,38 @@ class MemcachedBackend(BaseCacheBackend):
392
447
  "this namespace cannot be cleared on its own; delete known keys "
393
448
  "with delete() or delete_many() instead.",
394
449
  RuntimeWarning,
395
- stacklevel=2,
450
+ stacklevel=_caller_stacklevel(),
396
451
  )
397
452
  await asyncio.to_thread(self.client.flush_all)
398
453
  logger.debug("Memcached CLEAR; flush_all issued")
399
454
 
400
455
  async def clear_path(self, path: str, include_params: bool = False) -> int:
401
- """Clear cached responses for a specific path.
456
+ """Delete the key that is exactly ``path``; warns on every call.
402
457
 
403
- Note: Memcached does not support pattern-based queries, so this
404
- only deletes the key that is exactly ``path``. HTTP route keys
405
- (``method|||host|||path|||query``) are not matched. For path-based
406
- clearing, use the Redis or memory backend.
458
+ Memcached cannot enumerate keys, so this cannot find HTTP route keys
459
+ (``method|||host|||path|||query``): it only deletes a key stored under
460
+ the literal name ``path``, and ``include_params`` has no effect. It
461
+ warns every time, because on this backend ``clear_path()`` after a write
462
+ would otherwise leave the cached response in place without a sign. Use
463
+ ``invalidate(request)`` to drop a ``@cache`` route's entry, or the Redis
464
+ or memory backend for path-based clearing.
407
465
 
408
466
  Args:
409
467
  path: The exact key to delete
410
- include_params: Unsupported; emits a ``RuntimeWarning`` and is
411
- otherwise ignored
468
+ include_params: Unsupported; ignored
412
469
 
413
470
  Returns:
414
471
  Number of cache entries cleared (0 or 1 for exact match only)
415
472
  """
416
- if include_params:
417
- warnings.warn(
418
- "Memcached backend does not support pattern-based key clearing. "
419
- "Only exact key matches can be deleted. "
420
- "The include_params option has no effect. "
421
- "Consider using Redis backend for pattern support.",
422
- RuntimeWarning,
423
- stacklevel=2,
424
- )
473
+ warnings.warn(
474
+ "Memcached backend does not support pattern-based key clearing, so "
475
+ "clear_path() cannot remove HTTP cache entries "
476
+ "(method|||host|||path|||query): it only deletes a key named "
477
+ "exactly as the path, and include_params has no effect. Use "
478
+ "invalidate(request) to drop a cached route's entry.",
479
+ RuntimeWarning,
480
+ stacklevel=_caller_stacklevel(),
481
+ )
425
482
 
426
483
  # Try to delete the prefixed key (exact match only)
427
484
  prefixed_key = self._make_key(path)
@@ -454,7 +511,7 @@ class MemcachedBackend(BaseCacheBackend):
454
511
  "Consider using Redis backend for pattern support, "
455
512
  "or track keys manually in your application logic.",
456
513
  RuntimeWarning,
457
- stacklevel=2,
514
+ stacklevel=_caller_stacklevel(),
458
515
  )
459
516
  logger.debug("Memcached CLEAR_PATTERN unsupported; pattern=%s", pattern)
460
517
  return 0
@@ -476,7 +533,7 @@ class MemcachedBackend(BaseCacheBackend):
476
533
  "Consider using Redis backend if you need cache monitoring, "
477
534
  "or track keys manually in your application.",
478
535
  RuntimeWarning,
479
- stacklevel=2,
536
+ stacklevel=_caller_stacklevel(),
480
537
  )
481
538
  logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list")
482
539
  return []
@@ -495,6 +552,8 @@ class MemcachedBackend(BaseCacheBackend):
495
552
  "get_cache_data() returns an empty dictionary. "
496
553
  "Consider using Redis backend if you need cache monitoring.",
497
554
  RuntimeWarning,
555
+ # Called by the monitoring route, whose caller is FastAPI itself:
556
+ # routes.py names the source better than any frame outside it.
498
557
  stacklevel=2,
499
558
  )
500
559
  logger.debug("Memcached GET_CACHE_DATA unsupported; returning empty dict")
@@ -31,8 +31,10 @@ def _split_http_key(key: str) -> tuple[str, bool] | None:
31
31
 
32
32
  Keys without separators (CacheManager/StateManager keys or custom key
33
33
  builders) are not HTTP keys and are matched on their raw value instead.
34
+ Components after the query string (see ``build_cache_key``) do not count
35
+ as query params.
34
36
  """
35
- parts = key.split(CACHE_KEY_SEPARATOR, _QUERY_INDEX)
37
+ parts = key.split(CACHE_KEY_SEPARATOR)
36
38
  if len(parts) <= _PATH_INDEX:
37
39
  return None
38
40
  has_params = len(parts) > _QUERY_INDEX and bool(parts[_QUERY_INDEX])