Skip to content

Commit 2e9e72d

Browse files
committed
fix(backends): recognise counters the same way on every backend
counter_value() parsed any entry whose content was a number, so on the memory backend and the base fallback increment() on a cached response with body "42" returned 43 and overwrote it, while Redis and Memcached raised CacheXError. It now also requires COUNTER_FINGERPRINT. A counter written with set(key, counter_entry(n)) was stored as a JSON document on Redis and Memcached, which INCR rejects. encode_entry now stores exactly what counter_entry builds as a bare integer. The codec read any value int() accepts as a counter, including " 7" and "1_0". parse_counter accepts only an optional minus sign and ASCII digits, plus the trailing spaces Memcached pads a value with when DECR shortens it. Closes #111
1 parent 44420b1 commit 2e9e72d

6 files changed

Lines changed: 211 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2.
5353
UTF-8; the parameter is removed in 0.4.0 (#126).
5454
([#122](https://github.com/allen0099/FastAPI-CacheX/issues/122))
5555

56+
- **Counters are recognised the same way on every backend.** On the memory
57+
backend and the base-class fallback, `increment()` on a cached response
58+
whose body was a number, such as `42`, returned 43 and overwrote the
59+
response. It now raises `CacheXError`, as Redis and Memcached already did. A
60+
counter written with `set(key, counter_entry(n))` can now be incremented on
61+
Redis and Memcached too, because it is stored as a bare integer. Stored
62+
values such as `" 7"` or `"1_0"`, which `int()` accepts, are no longer
63+
read as counters.
64+
([#111](https://github.com/allen0099/FastAPI-CacheX/issues/111))
65+
5666
## [0.3.7] - 2026-09-25
5767

5868
### Added

‎docs/BACKENDS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,9 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300):
156156
counter is visible through `get()` as a `CacheEntry` with fingerprint
157157
`COUNTER_FINGERPRINT` and the decimal value as content, so `delete`/`clear*`
158158
and the monitoring routes treat it like any other entry. Incrementing a key
159-
that holds a cached response raises `CacheXError`.
159+
that holds anything else raises `CacheXError` on every backend, even a cached
160+
response whose body is a number. A counter written with
161+
`set(key, counter_entry(n))` can be incremented on every backend.
160162
- `get_and_delete(key) -> CacheEntry | None` — Memory pops under its lock, Redis
161163
uses `GETDEL` (server 6.2+) and Memcached returns the value only when its own
162164
`DELETE` won. `StateManager.consume_state`, `StateManager.delete_state`,

‎fastapi_cachex/backends/codec.py‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
it is installed and the standard library ``json`` module otherwise.
55
"""
66

7+
from fastapi_cachex.types import COUNTER_FINGERPRINT
78
from fastapi_cachex.types import DEFAULT_STATUS_CODE
89
from fastapi_cachex.types import CacheEntry
910
from fastapi_cachex.types import counter_entry
11+
from fastapi_cachex.types import parse_counter
1012

1113
try:
1214
import orjson as json
@@ -27,7 +29,15 @@ def encode_entry(entry: CacheEntry) -> bytes:
2729
2830
The raw content bytes are passed through ``latin-1`` so that arbitrary
2931
bytes round-trip through JSON text.
32+
33+
A counter entry (exactly what ``counter_entry`` builds) is stored as a bare
34+
integer instead, the form the server-side ``INCR`` family works on, so a
35+
counter written with ``set()`` can still be passed to ``increment()``.
3036
"""
37+
if entry.fingerprint == COUNTER_FINGERPRINT:
38+
value = parse_counter(entry.content)
39+
if value is not None and entry == counter_entry(value):
40+
return entry.content
3141
serialized: str | bytes = json.dumps(
3242
{
3343
"fingerprint": entry.fingerprint,
@@ -41,14 +51,6 @@ def encode_entry(entry: CacheEntry) -> bytes:
4151
return serialized if isinstance(serialized, bytes) else serialized.encode("utf-8")
4252

4353

44-
def _as_counter(raw: str | bytes) -> int | None:
45-
"""The integer a bare counter value holds, or ``None`` for anything else."""
46-
try:
47-
return int(raw)
48-
except ValueError:
49-
return None
50-
51-
5254
def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
5355
"""Rebuild a ``CacheEntry`` from a stored value.
5456
@@ -63,7 +65,7 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
6365
"""
6466
if raw is None:
6567
return None
66-
counter = _as_counter(raw)
68+
counter = parse_counter(raw)
6769
if counter is not None:
6870
return counter_entry(counter)
6971
try:

‎fastapi_cachex/types.py‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Type definitions and type aliases for FastAPI-CacheX."""
22

3+
import re
34
from collections.abc import Callable
45
from dataclasses import dataclass
56

@@ -59,15 +60,31 @@ def counter_entry(value: int) -> CacheEntry:
5960
return CacheEntry(fingerprint=COUNTER_FINGERPRINT, content=str(value).encode())
6061

6162

63+
# Trailing spaces are allowed because Memcached pads a value that DECR made
64+
# shorter instead of resizing it (DECR on "10" leaves "9 ").
65+
_COUNTER_PATTERN = re.compile(rb"-?[0-9]+ *")
66+
67+
68+
def parse_counter(raw: str | bytes) -> int | None:
69+
"""The integer ``raw`` spells as a plain decimal, or ``None`` otherwise.
70+
71+
Stricter than ``int()``: leading whitespace, underscores, a ``+`` sign and
72+
non-ASCII digits are rejected. Only the shapes the server-side ``INCR``
73+
family of Redis and Memcached leaves behind are accepted.
74+
"""
75+
data = raw.encode() if isinstance(raw, str) else raw
76+
return int(data) if _COUNTER_PATTERN.fullmatch(data) else None
77+
78+
6279
def counter_value(entry: CacheEntry) -> int:
6380
"""Read the integer a counter entry holds.
6481
6582
Raises:
66-
CacheXError: If the content is not a decimal integer, i.e. the key
67-
holds a cached response rather than a counter.
83+
CacheXError: If the entry is not a counter, e.g. the key holds a
84+
cached response, even one whose body is a number.
6885
"""
69-
try:
70-
return int(entry.content)
71-
except ValueError as e:
86+
value = parse_counter(entry.content)
87+
if entry.fingerprint != COUNTER_FINGERPRINT or value is None:
7288
msg = "Cache key holds a value that is not a counter"
73-
raise CacheXError(msg) from e
89+
raise CacheXError(msg)
90+
return value

‎tests/backends/test_codec.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,39 @@ def test_decode_entry_treats_malformed_documents_as_a_miss(raw):
5555
assert codec.decode_entry(raw) is None
5656

5757

58-
@pytest.mark.parametrize("raw", ["7", b"7", b"7 ", " 7\n"])
58+
@pytest.mark.parametrize("raw", ["7", b"7", b"7 ", "7 "])
5959
def test_decode_entry_reads_a_bare_integer_as_a_counter(raw):
60+
"""Trailing spaces are Memcached's padding after a shrinking DECR."""
6061
assert codec.decode_entry(raw) == counter_entry(7)
6162

6263

64+
@pytest.mark.parametrize("raw", [b" 7", b" 7 ", "7\n", b"1_0", b"+7", chr(0x0667), b""])
65+
def test_decode_entry_rejects_what_int_accepts_but_no_server_writes(raw):
66+
"""``int()`` reads all of these; none of them is a counter (#111)."""
67+
assert codec.decode_entry(raw) is None
68+
69+
70+
def test_encode_entry_stores_a_counter_as_a_bare_integer():
71+
"""So ``increment()`` works on a counter written with ``set()`` (#111)."""
72+
assert codec.encode_entry(counter_entry(-5)) == b"-5"
73+
assert codec.decode_entry(codec.encode_entry(counter_entry(-5))) == counter_entry(
74+
-5
75+
)
76+
77+
78+
@pytest.mark.parametrize(
79+
"entry",
80+
[
81+
CacheEntry(fingerprint="etag", content=b"42"),
82+
CacheEntry(fingerprint="counter", content=b"42", media_type="text/plain"),
83+
CacheEntry(fingerprint="counter", content=b"042"),
84+
CacheEntry(fingerprint="counter", content=b"abc"),
85+
],
86+
)
87+
def test_encode_entry_keeps_anything_else_as_a_document(entry):
88+
"""Only exactly what ``counter_entry`` builds is stored bare."""
89+
assert codec.decode_entry(codec.encode_entry(entry)) == entry
90+
91+
6392
def test_decode_entry_reads_a_negative_counter():
6493
assert codec.decode_entry("-2") == counter_entry(-2)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""A counter is recognised the same way on every backend (#111).
2+
3+
The memory backend and the base fallback parsed any entry whose body was a
4+
number as a counter, so `increment()` on a cached response with body `42`
5+
returned 43 and overwrote it, while Redis and Memcached raised. And a counter
6+
written with `set(key, counter_entry(n))` could be incremented only on the
7+
memory backend, because the network backends stored it as a JSON document.
8+
"""
9+
10+
from collections.abc import AsyncIterator
11+
from collections.abc import Callable
12+
13+
import pytest
14+
import pytest_asyncio
15+
16+
from fastapi_cachex.backends import AsyncRedisCacheBackend
17+
from fastapi_cachex.backends import MemcachedBackend
18+
from fastapi_cachex.backends.base import BaseCacheBackend
19+
from fastapi_cachex.backends.memory import MemoryBackend
20+
from fastapi_cachex.exceptions import CacheXError
21+
from fastapi_cachex.types import COUNTER_FINGERPRINT
22+
from fastapi_cachex.types import CacheEntry
23+
from fastapi_cachex.types import counter_entry
24+
from fastapi_cachex.types import counter_value
25+
from fastapi_cachex.types import parse_counter
26+
from tests.backends.test_base import DictBackend
27+
from tests.live_servers import MEMCACHED_SERVER
28+
from tests.live_servers import REDIS_HOST
29+
from tests.live_servers import REDIS_PORT
30+
from tests.live_servers import memcached_skip_reason
31+
from tests.live_servers import redis_skip_reason
32+
33+
34+
def _redis() -> BaseCacheBackend:
35+
return AsyncRedisCacheBackend(
36+
host=REDIS_HOST, port=REDIS_PORT, key_prefix="cachex-test-counter:"
37+
)
38+
39+
40+
def _memcached() -> BaseCacheBackend:
41+
return MemcachedBackend(servers=[MEMCACHED_SERVER])
42+
43+
44+
FACTORIES: dict[
45+
str, tuple[Callable[[], BaseCacheBackend], Callable[[], str | None]]
46+
] = {
47+
"memory": (MemoryBackend, lambda: None),
48+
"fallback": (DictBackend, lambda: None),
49+
"redis": (_redis, redis_skip_reason),
50+
"memcached": (_memcached, memcached_skip_reason),
51+
}
52+
KEYS = ("counter-contract-page", "counter-contract-hits")
53+
54+
55+
@pytest_asyncio.fixture(params=list(FACTORIES))
56+
async def backend(request: pytest.FixtureRequest) -> AsyncIterator[BaseCacheBackend]:
57+
factory, skip_reason = FACTORIES[request.param]
58+
reason = skip_reason()
59+
if reason is not None:
60+
pytest.skip(reason)
61+
instance = factory()
62+
await instance.delete_many(list(KEYS))
63+
yield instance
64+
await instance.delete_many(list(KEYS))
65+
66+
67+
@pytest.mark.asyncio
68+
async def test_increment_rejects_a_cached_response_with_a_numeric_body(
69+
backend: BaseCacheBackend,
70+
) -> None:
71+
page = CacheEntry(fingerprint="etag", content=b"42")
72+
await backend.set(KEYS[0], page)
73+
74+
with pytest.raises(CacheXError, match="not a counter"):
75+
await backend.increment(KEYS[0])
76+
77+
assert await backend.get(KEYS[0]) == page
78+
79+
80+
@pytest.mark.asyncio
81+
async def test_increment_continues_a_counter_written_with_set(
82+
backend: BaseCacheBackend,
83+
) -> None:
84+
await backend.set(KEYS[1], counter_entry(5))
85+
86+
assert await backend.increment(KEYS[1]) == 6
87+
assert await backend.increment(KEYS[1], -2) == 4 # Memcached stops at 0
88+
assert await backend.get(KEYS[1]) == counter_entry(4)
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_a_counter_that_shrinks_reads_back_as_a_counter(
93+
backend: BaseCacheBackend,
94+
) -> None:
95+
"""Memcached pads the value DECR shortens ("10" -> "9 ")."""
96+
await backend.increment(KEYS[1], 10)
97+
await backend.increment(KEYS[1], -1)
98+
99+
assert await backend.get(KEYS[1]) == counter_entry(9)
100+
101+
102+
@pytest.mark.parametrize(
103+
("raw", "expected"),
104+
[
105+
("7", 7),
106+
(b"-7", -7),
107+
(b"9 ", 9),
108+
(b" 7", None),
109+
(b"7\n", None),
110+
(b"1_0", None),
111+
(b"+7", None),
112+
(chr(0x0667), None), # ARABIC-INDIC DIGIT SEVEN, which int() accepts
113+
(b"", None),
114+
(b"-", None),
115+
],
116+
)
117+
def test_parse_counter_accepts_only_what_the_servers_write(
118+
raw: str | bytes, expected: int | None
119+
) -> None:
120+
assert parse_counter(raw) == expected
121+
122+
123+
@pytest.mark.parametrize(
124+
"entry",
125+
[
126+
CacheEntry(fingerprint="etag", content=b"42"),
127+
CacheEntry(fingerprint=COUNTER_FINGERPRINT, content=b"1_0"),
128+
],
129+
)
130+
def test_counter_value_requires_the_counter_fingerprint_and_a_plain_integer(
131+
entry: CacheEntry,
132+
) -> None:
133+
with pytest.raises(CacheXError, match="not a counter"):
134+
counter_value(entry)

0 commit comments

Comments
 (0)