Skip to content

Commit e9af7d0

Browse files
authored
Merge pull request #325 from spoo-me/fix/web-risk-budget-fail-closed
fix(safety): fail the Web Risk budget closed
2 parents 507eee4 + 0c0f432 commit e9af7d0

2 files changed

Lines changed: 16 additions & 9 deletions

File tree

infrastructure/cache/web_risk_budget.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
Global, not per caller. Per-caller rate limits bound one IP; this bounds
1111
the bill and the analyzer's headroom.
1212
13-
Without Redis the cap is unenforced, matching every other cache here.
13+
Unlike the caches here, it fails closed. It shares its Redis with the
14+
expander's result cache, so an outage removes the cache that absorbs
15+
lookups and the cap that bounds them at the same moment. Unable to count
16+
means unable to spend.
1417
"""
1518

1619
from __future__ import annotations
@@ -39,18 +42,18 @@ def __init__(
3942
self._prefix = prefix
4043

4144
async def take(self) -> bool:
42-
"""Claim one lookup. False once the day's cap is spent."""
45+
"""Claim one lookup. False once the day's cap is spent, and false
46+
whenever the count is unavailable."""
4347
if self._redis is None:
44-
return True
48+
return False
4549
key = f"{self._prefix}:{datetime.now(timezone.utc):%Y-%m-%d}"
4650
try:
4751
used = await self._redis.incr(key)
4852
if used == 1:
4953
await self._redis.expire(key, _KEY_TTL_SECONDS)
5054
except Exception as exc:
51-
# A broken counter must not take the feature down with it.
5255
log.warning("web_risk_budget_error", error=str(exc))
53-
return True
56+
return False
5457
if used == self._limit + 1:
5558
log.warning("web_risk_budget_exhausted", limit=self._limit)
5659
return used <= self._limit

tests/unit/infrastructure/test_web_risk_budget.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,16 @@ async def test_the_daily_key_expires_so_it_self_clears():
4444

4545

4646
@pytest.mark.asyncio
47-
async def test_without_redis_the_cap_is_unenforced():
48-
assert await WebRiskBudget(None, limit=0).take() is True
47+
async def test_without_redis_nothing_is_spent():
48+
assert await WebRiskBudget(None, limit=1_000).take() is False
4949

5050

5151
@pytest.mark.asyncio
52-
async def test_a_broken_counter_does_not_take_the_feature_down():
52+
async def test_a_broken_counter_refuses_rather_than_waving_calls_through():
53+
"""The counter shares its Redis with the expander's result cache, so an
54+
outage drops the cache and the cap together. Unable to count means
55+
unable to spend, or the quota goes exactly when it is least affordable.
56+
"""
5357
redis = AsyncMock()
5458
redis.incr = AsyncMock(side_effect=RuntimeError("redis down"))
55-
assert await WebRiskBudget(redis, limit=1).take() is True
59+
assert await WebRiskBudget(redis, limit=1_000).take() is False

0 commit comments

Comments
 (0)