Skip to content

Commit 67732fb

Browse files
authored
fix(decorators): key-generation failures no longer count toward the circuit breaker (LAB-5376) (#381)
1 parent 30127eb commit 67732fb

3 files changed

Lines changed: 192 additions & 1 deletion

File tree

‎src/cachekit/decorators/wrapper.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,6 +1352,7 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912
13521352
cache_key="<generation_failed>",
13531353
namespace=namespace or "default",
13541354
duration_ms=0.0,
1355+
count_toward_breaker=False, # pre-admission, never reached the backend
13551356
)
13561357
reset_current_function_stats(token)
13571358
return func(*args, **kwargs)
@@ -1773,6 +1774,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
17731774
cache_key="<generation_failed>",
17741775
namespace=namespace or "default",
17751776
duration_ms=0.0,
1777+
count_toward_breaker=False, # pre-admission, never reached the backend
17761778
)
17771779
return await func(*args, **kwargs)
17781780

‎tests/unit/test_circuit_breaker_decorator_recovery.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ async def test_steady_traffic_does_not_hold_the_breaker_open(self, is_async, res
303303
async def test_failures_recorded_while_open_do_not_extend_the_window(
304304
self, is_async, resolver, backend, live_breakers, clock
305305
):
306-
"""Key generation runs, and records its failures, before the admission check."""
306+
"""Calls whose key generation fails, before the admission check, do not extend the window."""
307307

308308
def key(x: str) -> str:
309309
if x.startswith("unkeyable"):
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""A cache key that cannot be generated never changes circuit-breaker state (LAB-5376).
2+
3+
Key generation runs before admission and never touches the backend, so its
4+
failure says nothing about backend health. It used to count as a breaker
5+
failure: five unkeyable calls opened the breaker in front of a healthy backend,
6+
and one reopened a HALF_OPEN breaker, switching caching off for every call to
7+
the function. The call still logs, emits its metric and runs uncached.
8+
9+
These tests drive a real ``@cache`` function with an unkeyable argument (a plain
10+
``object()``) on a frozen clock, and capture the live breaker by spying on the
11+
orchestrator's ``CircuitBreaker`` constructor.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import inspect
17+
import logging
18+
from collections.abc import Callable
19+
from datetime import timedelta
20+
from typing import Any, Optional
21+
22+
import pytest
23+
import time_machine
24+
25+
from cachekit import cache
26+
from cachekit.decorators import orchestrator as orchestrator_module
27+
from cachekit.decorators import wrapper as wrapper_module
28+
from cachekit.reliability.circuit_breaker import CircuitBreaker, CircuitBreakerConfig, CircuitState
29+
30+
_DEFAULTS = CircuitBreakerConfig()
31+
_PAST_TIMEOUT = timedelta(seconds=_DEFAULTS.timeout_seconds + 1)
32+
_EPOCH = 1_000_000.0 # Frozen wall clock; any fixed instant works
33+
34+
35+
class _Backend:
36+
"""Healthy in-memory backend that counts the operations reaching it."""
37+
38+
def __init__(self) -> None:
39+
self.store: dict[str, bytes] = {}
40+
self.gets = 0
41+
self.sets = 0
42+
43+
def get(self, key: str) -> Optional[bytes]:
44+
self.gets += 1
45+
return self.store.get(key)
46+
47+
def set(self, key: str, value: bytes, ttl: Optional[int] = None) -> None:
48+
self.sets += 1
49+
self.store[key] = bytes(value)
50+
51+
def delete(self, key: str) -> bool:
52+
return self.store.pop(key, None) is not None
53+
54+
55+
@pytest.fixture
56+
def backend(monkeypatch: pytest.MonkeyPatch) -> _Backend:
57+
healthy = _Backend()
58+
monkeypatch.setattr(wrapper_module, "_resolve_lazy_backend", lambda: healthy)
59+
return healthy
60+
61+
62+
@pytest.fixture
63+
def live_breakers(monkeypatch: pytest.MonkeyPatch) -> list[CircuitBreaker]:
64+
"""Capture every breaker a decorator builds (decorate AFTER requesting this)."""
65+
captured: list[CircuitBreaker] = []
66+
real = orchestrator_module.CircuitBreaker
67+
68+
def spy(*args, **kwargs) -> CircuitBreaker:
69+
breaker = real(*args, **kwargs)
70+
captured.append(breaker)
71+
return breaker
72+
73+
monkeypatch.setattr(orchestrator_module, "CircuitBreaker", spy)
74+
return captured
75+
76+
77+
@pytest.fixture
78+
def clock():
79+
with time_machine.travel(_EPOCH, tick=False) as traveller:
80+
yield traveller
81+
82+
83+
@pytest.fixture(params=[False, True], ids=["sync", "async"])
84+
def is_async(request: pytest.FixtureRequest) -> bool:
85+
return request.param
86+
87+
88+
def _decorate(namespace: str, *, is_async: bool, executions: list[Any]):
89+
"""``@cache`` a sync or async function returning ``v:<x>``, recording each execution."""
90+
91+
def body(x: Any) -> str:
92+
executions.append(x)
93+
return f"v:{x}"
94+
95+
async def async_body(x: Any) -> str:
96+
return body(x)
97+
98+
return cache(ttl=300, l1_enabled=False, namespace=namespace)(async_body if is_async else body)
99+
100+
101+
async def _call(fn: Callable[[Any], Any], x: Any) -> Any:
102+
result = fn(x)
103+
return await result if inspect.isawaitable(result) else result
104+
105+
106+
async def _unkeyable(fn: Callable[[Any], Any], executions: list[Any]) -> None:
107+
"""One call whose key cannot be generated: it runs the function, uncached, and raises nothing."""
108+
arg = object()
109+
assert await _call(fn, arg) == f"v:{arg}"
110+
assert executions[-1] is arg
111+
112+
113+
def _open(breaker: CircuitBreaker) -> None:
114+
for _ in range(breaker.config.failure_threshold):
115+
breaker.record_failure()
116+
assert breaker.state == CircuitState.OPEN
117+
118+
119+
class TestKeyGenerationFailureLeavesBreakerAlone:
120+
async def test_closed_stays_closed(self, is_async, backend, live_breakers, clock):
121+
executions: list[Any] = []
122+
fn = _decorate(f"lab5376-closed-{is_async}", is_async=is_async, executions=executions)
123+
(breaker,) = live_breakers
124+
125+
for _ in range(_DEFAULTS.failure_threshold * 3):
126+
await _unkeyable(fn, executions)
127+
128+
assert breaker.state == CircuitState.CLOSED
129+
assert breaker.failure_count == 0
130+
assert backend.gets == backend.sets == 0 # never reached the backend
131+
132+
assert await _call(fn, "keyable") == "v:keyable"
133+
assert backend.sets == 1 # caching still on for keyable calls
134+
135+
async def test_half_open_stays_half_open_and_still_closes(self, is_async, backend, live_breakers, clock):
136+
executions: list[Any] = []
137+
fn = _decorate(f"lab5376-half-open-{is_async}", is_async=is_async, executions=executions)
138+
(breaker,) = live_breakers
139+
_open(breaker)
140+
clock.shift(_PAST_TIMEOUT)
141+
142+
probes = [f"probe-{i}" for i in range(_DEFAULTS.success_threshold)] # cold keys: misses reach L2
143+
assert await _call(fn, probes[0]) == f"v:{probes[0]}"
144+
assert breaker.state == CircuitState.HALF_OPEN
145+
146+
await _unkeyable(fn, executions)
147+
assert breaker.state == CircuitState.HALF_OPEN # not reopened
148+
149+
for key in probes[1:]:
150+
assert await _call(fn, key) == f"v:{key}"
151+
assert backend.sets == len(probes)
152+
assert breaker.state == CircuitState.CLOSED
153+
154+
async def test_open_state_and_window_unchanged(self, is_async, backend, live_breakers, clock):
155+
executions: list[Any] = []
156+
fn = _decorate(f"lab5376-open-{is_async}", is_async=is_async, executions=executions)
157+
(breaker,) = live_breakers
158+
_open(breaker)
159+
opened_at = breaker._last_failure_time
160+
failures = breaker.failure_count
161+
162+
clock.shift(timedelta(seconds=1))
163+
await _unkeyable(fn, executions)
164+
165+
assert breaker.state == CircuitState.OPEN
166+
assert breaker._last_failure_time == opened_at
167+
assert breaker.failure_count == failures
168+
169+
170+
class TestKeyGenerationFailureStillReported:
171+
async def test_log_and_metric_unchanged(self, is_async, backend, live_breakers, clock, monkeypatch, caplog):
172+
recorded: list[dict[str, Any]] = []
173+
real_record = orchestrator_module.AsyncMetricsCollector.record_cache_operation
174+
175+
def spy(self, *args, **kwargs):
176+
recorded.append(kwargs)
177+
return real_record(self, *args, **kwargs)
178+
179+
monkeypatch.setattr(orchestrator_module.AsyncMetricsCollector, "record_cache_operation", spy)
180+
executions: list[Any] = []
181+
fn = _decorate(f"lab5376-report-{is_async}", is_async=is_async, executions=executions)
182+
183+
with caplog.at_level(logging.INFO):
184+
await _unkeyable(fn, executions)
185+
186+
structured = [getattr(r, "structured", {}) for r in caplog.records]
187+
assert any(s.get("operation") == "key_generation_failed" for s in structured)
188+
assert any("Cache operation 'key_generation' failed" in r.getMessage() for r in caplog.records)
189+
assert any(r.get("operation") == "key_generation" and r.get("success") is False for r in recorded)

0 commit comments

Comments
 (0)