Skip to content

Commit 96e9a02

Browse files
authored
perf: count RetryBudget and the rate-mode window in time buckets (#181)
* perf: keep running totals in the rate-mode circuit breaker window * perf: count RetryBudget deposits and withdrawals in time buckets The window now moves in steps of ttl / 10, so a deposit or withdrawal stops counting between 0.9 * ttl and ttl after it was made. Memory stays constant and no call has to drop a backlog of timestamps under the lock.
1 parent 9336c45 commit 96e9a02

10 files changed

Lines changed: 282 additions & 76 deletions

‎docs/resilience.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ ceiling = ceil(len(deposits_in_window) * percent_can_retry) + int(min_retries_pe
8484

8585
The percent term rounds up (`math.ceil`); the floor term truncates (`int`). A withdrawal fails when `len(withdrawn_in_window) >= ceiling`.
8686

87+
The window moves in steps of `ttl / 10`, so a deposit or withdrawal stops counting between `0.9 * ttl` and `ttl` after it was made.
88+
8789
### Why a floor matters
8890

8991
With no traffic yet, the percent term is zero, and without the floor the first retry would be refused. The floor lets low-traffic clients retry occasional failures. For high-traffic clients the percent term is much larger and the floor stops mattering.
@@ -226,6 +228,8 @@ breaker = AsyncCircuitBreaker(
226228

227229
Once the window holds at least `minimum_calls` outcomes, the rate is checked after every outcome, successes included. A success can therefore be the call that opens the circuit. With `minimum_calls=4` and a threshold of `0.5`, three failures leave the circuit closed because the window holds only three outcomes; a fourth call that succeeds brings it to four, the rate is 3/4, and the circuit opens. That call still returns its response to the caller, and the next request is rejected with `CircuitOpenError`.
228230

231+
The window moves in steps of `window_seconds / 10`, so an outcome stops counting between `0.9 * window_seconds` and `window_seconds` after it was recorded.
232+
229233
In rate mode `failure_threshold` is ignored. Recovery through HALF_OPEN works the same in both modes, and the sync `CircuitBreaker` takes the same parameters.
230234

231235
### State introspection
Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
"""Finagle-style token-bucket retry budget.
22
3-
Thread-safe and asyncio-safe: all mutations go through a threading.Lock,
4-
which ensures no torn state across concurrent accesses. When a RetryBudget
5-
is shared between a sync Client (pool thread) and an AsyncClient (event-loop
6-
thread), a sync thread holding the lock can briefly block the loop thread's
7-
acquisition; the critical section (purge + append/compare) is intentionally
8-
tiny to bound this latency. Safe to share across threads, across coroutines
9-
on one event loop, and across (sync Client, AsyncClient) pairs in the same
3+
Thread-safe and asyncio-safe: all mutations go through a threading.Lock held for a
4+
constant-time update of per-bucket counters, so a sync thread holding it delays an
5+
event loop sharing the budget only briefly. Safe to share across threads, across
6+
coroutines on one event loop, and across (sync Client, AsyncClient) pairs in the same
107
process.
118
"""
129

1310
import math
1411
import threading
1512
import time
16-
from collections import deque
1713
from collections.abc import Callable
1814

1915

16+
_BUCKET_COUNT = 10
17+
2018
_TTL_INVALID = "ttl must be a finite number > 0"
2119
_MIN_RETRIES_PER_SEC_INVALID = "min_retries_per_sec must be a finite number >= 0"
2220
_PERCENT_CAN_RETRY_INVALID = "percent_can_retry must be a finite number >= 0"
@@ -36,7 +34,9 @@ class RetryBudget:
3634
3735
Each request deposits a token; each retry attempts to withdraw one.
3836
Available retries are bounded by `percent_can_retry` of recent deposits,
39-
plus a `min_retries_per_sec * ttl` floor.
37+
plus a `min_retries_per_sec * ttl` floor. Deposits and withdrawals are counted in
38+
ten buckets of `ttl / 10` each, so one stops counting between 0.9 * ttl and ttl
39+
after it was recorded.
4040
"""
4141

4242
def __init__(
@@ -53,37 +53,54 @@ def __init__(
5353
self._percent_can_retry = percent_can_retry
5454
self._now = _now
5555
self._lock = threading.Lock()
56-
self._deposits: deque[float] = deque()
57-
self._withdrawn: deque[float] = deque()
56+
self._bucket_width = ttl / _BUCKET_COUNT
57+
self._floor = int(min_retries_per_sec * ttl)
58+
self._slot: float = -math.inf
59+
self._index = 0
60+
self._bucket_deposits = [0] * _BUCKET_COUNT
61+
self._bucket_withdrawn = [0] * _BUCKET_COUNT
62+
self._deposits = 0
63+
self._withdrawn = 0
5864

59-
def _purge(self, now: float) -> None:
60-
# Caller must hold self._lock.
61-
# Strict `< cutoff` keeps entries at exactly `now - ttl`: window is [now - ttl, now].
62-
cutoff = now - self._ttl
63-
while self._deposits and self._deposits[0] < cutoff:
64-
self._deposits.popleft()
65-
while self._withdrawn and self._withdrawn[0] < cutoff:
66-
self._withdrawn.popleft()
65+
def _rotate(self, slot: float) -> None:
66+
"""Make `slot` the current bucket, dropping buckets that fell out of the window. The caller holds self._lock."""
67+
current = int(slot)
68+
if current - self._slot >= _BUCKET_COUNT:
69+
self._bucket_deposits = [0] * _BUCKET_COUNT
70+
self._bucket_withdrawn = [0] * _BUCKET_COUNT
71+
self._deposits = 0
72+
self._withdrawn = 0
73+
else:
74+
for stale in range(int(self._slot) + 1, current + 1):
75+
index = stale % _BUCKET_COUNT
76+
self._deposits -= self._bucket_deposits[index]
77+
self._withdrawn -= self._bucket_withdrawn[index]
78+
self._bucket_deposits[index] = 0
79+
self._bucket_withdrawn[index] = 0
80+
self._slot = current
81+
self._index = current % _BUCKET_COUNT
6782

6883
def deposit(self) -> None:
6984
"""Record a request (success or failure attempt). Adds one token."""
70-
now = self._now()
85+
slot = self._now() // self._bucket_width
7186
with self._lock:
72-
self._purge(now)
73-
self._deposits.append(now)
87+
if slot > self._slot:
88+
self._rotate(slot)
89+
self._bucket_deposits[self._index] += 1
90+
self._deposits += 1
7491

7592
def try_withdraw(self) -> bool:
7693
"""Atomically attempt to spend one retry token.
7794
7895
Returns True if a retry is permitted, False if the budget is exhausted.
7996
Never blocks.
8097
"""
81-
now = self._now()
98+
slot = self._now() // self._bucket_width
8299
with self._lock:
83-
self._purge(now)
84-
floor = int(self._min_retries_per_sec * self._ttl)
85-
ceiling = math.ceil(len(self._deposits) * self._percent_can_retry) + floor
86-
if len(self._withdrawn) >= ceiling:
100+
if slot > self._slot:
101+
self._rotate(slot)
102+
if self._withdrawn >= math.ceil(self._deposits * self._percent_can_retry) + self._floor:
87103
return False
88-
self._withdrawn.append(now)
104+
self._bucket_withdrawn[self._index] += 1
105+
self._withdrawn += 1
89106
return True

‎src/httpware/middleware/resilience/circuit_breaker.py‎

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -114,51 +114,58 @@ class CircuitState(enum.Enum):
114114
class _RollingWindow:
115115
"""Time-bucketed success/failure counters over a rolling window.
116116
117-
`window_seconds` is split into `_BUCKET_COUNT` buckets. Each bucket holds
118-
[successes, failures] tagged with the integer time-slot it represents; a
119-
bucket whose slot is stale is reset on write, and `totals` filters to the
120-
live slot range so data older than the window never counts. Every method is
121-
synchronous and reads `now` from its caller (so the breaker's critical
122-
section owns the clock read).
117+
`window_seconds` is split into `_BUCKET_COUNT` buckets, one per integer time-slot; the
118+
window is the current slot and the `_BUCKET_COUNT - 1` before it. Running totals are
119+
kept beside the buckets, so recording and reading are constant-time. Every method is
120+
synchronous and reads `now` from its caller (so the breaker's critical section owns
121+
the clock read).
123122
"""
124123

125124
def __init__(self, window_seconds: float) -> None:
126125
self._bucket_width = window_seconds / _BUCKET_COUNT
127-
self._slot = [-1] * _BUCKET_COUNT
126+
self._slot: float = -math.inf
127+
self._index = 0
128128
self._success = [0] * _BUCKET_COUNT
129129
self._failure = [0] * _BUCKET_COUNT
130-
131-
def _current_slot(self, now: float) -> int:
132-
return int(now // self._bucket_width)
130+
self._total = 0
131+
self._failures = 0
132+
133+
def _advance(self, now: float) -> None:
134+
slot = now // self._bucket_width
135+
if slot <= self._slot:
136+
return
137+
current = int(slot)
138+
if current - self._slot >= _BUCKET_COUNT:
139+
self.clear()
140+
else:
141+
for stale in range(int(self._slot) + 1, current + 1):
142+
index = stale % _BUCKET_COUNT
143+
self._total -= self._success[index] + self._failure[index]
144+
self._failures -= self._failure[index]
145+
self._success[index] = 0
146+
self._failure[index] = 0
147+
self._slot = current
148+
self._index = current % _BUCKET_COUNT
133149

134150
def record(self, now: float, *, failed: bool) -> None:
135-
slot = self._current_slot(now)
136-
index = slot % _BUCKET_COUNT
137-
if self._slot[index] != slot: # bucket reused for a new slot — evict
138-
self._slot[index] = slot
139-
self._success[index] = 0
140-
self._failure[index] = 0
151+
self._advance(now)
141152
if failed:
142-
self._failure[index] += 1
153+
self._failure[self._index] += 1
154+
self._failures += 1
143155
else:
144-
self._success[index] += 1
156+
self._success[self._index] += 1
157+
self._total += 1
145158

146159
def totals(self, now: float) -> tuple[int, int]:
147160
"""Return (total, failures) across buckets still inside the window at `now`."""
148-
slot = self._current_slot(now)
149-
oldest = slot - _BUCKET_COUNT + 1
150-
total = 0
151-
failures = 0
152-
for i in range(_BUCKET_COUNT):
153-
if oldest <= self._slot[i] <= slot:
154-
total += self._success[i] + self._failure[i]
155-
failures += self._failure[i]
156-
return total, failures
161+
self._advance(now)
162+
return self._total, self._failures
157163

158164
def clear(self) -> None:
159-
self._slot = [-1] * _BUCKET_COUNT
160165
self._success = [0] * _BUCKET_COUNT
161166
self._failure = [0] * _BUCKET_COUNT
167+
self._total = 0
168+
self._failures = 0
162169

163170

164171
_Event = tuple[str, int, str, dict[str, typing.Any]]

‎tests/test_budget.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,48 @@ def test_boundary_budget_values_accepted() -> None:
136136
assert budget.try_withdraw() is True
137137
assert budget.try_withdraw() is True
138138
assert budget.try_withdraw() is False
139+
140+
141+
def test_deposit_counts_until_its_bucket_leaves_the_window() -> None:
142+
# ttl=10 gives 1 s buckets; a deposit at 0.5 lives in bucket [0, 1) and stops counting at 10.0
143+
clock = _Clock(0.5)
144+
budget = RetryBudget(ttl=10.0, min_retries_per_sec=0.0, percent_can_retry=1.0, _now=clock.now)
145+
budget.deposit()
146+
clock.advance(9.49)
147+
assert budget.try_withdraw() is True
148+
clock.advance(0.01)
149+
assert budget.try_withdraw() is False
150+
151+
152+
def test_expired_buckets_release_only_their_own_counts() -> None:
153+
clock = _Clock()
154+
budget = RetryBudget(ttl=10.0, min_retries_per_sec=0.0, percent_can_retry=1.0, _now=clock.now)
155+
budget.deposit()
156+
clock.advance(5.0)
157+
budget.deposit()
158+
budget.deposit()
159+
clock.advance(5.0)
160+
# the first deposit has expired, the two later ones still count
161+
assert budget.try_withdraw() is True
162+
assert budget.try_withdraw() is True
163+
assert budget.try_withdraw() is False
164+
165+
166+
def test_withdrawal_expires_with_its_bucket() -> None:
167+
clock = _Clock()
168+
budget = RetryBudget(ttl=10.0, min_retries_per_sec=0.1, percent_can_retry=0.0, _now=clock.now)
169+
assert budget.try_withdraw() is True
170+
clock.advance(9.0)
171+
assert budget.try_withdraw() is False
172+
clock.advance(1.0)
173+
assert budget.try_withdraw() is True
174+
175+
176+
def test_earlier_timestamp_counts_in_the_current_bucket() -> None:
177+
times = iter([5.0, 4.0, 14.5])
178+
budget = RetryBudget(ttl=10.0, min_retries_per_sec=0.0, percent_can_retry=1.0, _now=lambda: next(times))
179+
budget.deposit()
180+
budget.deposit()
181+
# both deposits sit in the bucket for [5, 6), which is still live at 14.5
182+
assert budget._deposits == 2 # noqa: SLF001, PLR2004
183+
assert budget.try_withdraw() is True

‎tests/test_budget_props.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
beyond the floor).
77
3. `deposit()` is monotonically non-decreasing in permitted retries (more deposits cannot
88
reduce the budget).
9+
4. Against a reference that keeps every timestamp, each `try_withdraw()` answer is one the
10+
reference allows when events stop counting anywhere between 0.9 * ttl and ttl after
11+
they were recorded.
912
"""
1013

1114
import math
@@ -114,3 +117,58 @@ def test_more_deposits_never_decreases_budget(
114117
budget2.deposit()
115118
new_permitted = sum(1 for _ in range(base_deposits + extra_deposits + 200) if budget2.try_withdraw())
116119
assert new_permitted >= initial_permitted
120+
121+
122+
_EDGE = 1e-9
123+
124+
125+
def _live(timestamps: list[float], now: float, max_age: float) -> int:
126+
return sum(1 for t in timestamps if now - t <= max_age)
127+
128+
129+
def _ceiling(deposits: int, percent: float, floor: int) -> int:
130+
return math.ceil(deposits * percent) + floor
131+
132+
133+
@given(
134+
ttl=st.floats(min_value=0.1, max_value=60.0, allow_nan=False, allow_infinity=False),
135+
min_rps=st.floats(min_value=0.0, max_value=5.0, allow_nan=False, allow_infinity=False),
136+
percent=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
137+
start=st.floats(min_value=0.0, max_value=1e6, allow_nan=False, allow_infinity=False),
138+
ops=st.lists(
139+
st.tuples(st.booleans(), st.floats(min_value=0.0, max_value=0.5, allow_nan=False, allow_infinity=False)),
140+
max_size=300,
141+
),
142+
)
143+
@settings(max_examples=200, deadline=None)
144+
def test_matches_timestamp_reference_within_one_bucket(
145+
ttl: float,
146+
min_rps: float,
147+
percent: float,
148+
start: float,
149+
ops: list[tuple[bool, float]],
150+
) -> None:
151+
clock = _Clock()
152+
clock.advance(start)
153+
budget = _budget(ttl=ttl, min_retries_per_sec=min_rps, percent_can_retry=percent, now=clock.now)
154+
floor = int(min_rps * ttl)
155+
shortest = 0.9 * ttl * (1 - _EDGE)
156+
longest = ttl * (1 + _EDGE)
157+
deposits: list[float] = []
158+
withdrawn: list[float] = []
159+
for is_deposit, step in ops:
160+
clock.advance(step * ttl)
161+
now = clock.now()
162+
if is_deposit:
163+
budget.deposit()
164+
deposits.append(now)
165+
continue
166+
permitted = budget.try_withdraw()
167+
must_permit = _live(withdrawn, now, longest) < _ceiling(_live(deposits, now, shortest), percent, floor)
168+
must_refuse = _live(withdrawn, now, shortest) >= _ceiling(_live(deposits, now, longest), percent, floor)
169+
if must_permit:
170+
assert permitted
171+
if must_refuse:
172+
assert not permitted
173+
if permitted:
174+
withdrawn.append(now)

‎tests/test_retry.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -569,8 +569,8 @@ async def streamed_body() -> typing.AsyncIterator[bytes]:
569569
await client.put("https://example.test/upload", content=streamed_body())
570570

571571
# Budget should be untouched: deposits OK (every attempt deposits), but no withdrawals.
572-
# Check via _withdrawn deque emptiness.
573-
assert len(budget._withdrawn) == 0 # noqa: SLF001 — implementation-detail access for invariant
572+
# Check via the withdrawn counter.
573+
assert budget._withdrawn == 0 # noqa: SLF001 — implementation-detail access for invariant
574574

575575

576576
async def test_retry_refuses_streamed_body_network_error_idempotent() -> None:
@@ -776,7 +776,7 @@ async def test_retry_after_exceeds_max_delay_does_not_consume_budget_token() ->
776776
with pytest.raises(ServiceUnavailableError):
777777
await client.get("https://example.test/x")
778778
# Retry-After > max_delay: give up without spending a budget token.
779-
assert len(budget._withdrawn) == 0 # noqa: SLF001 — implementation-detail access for invariant
779+
assert budget._withdrawn == 0 # noqa: SLF001 — implementation-detail access for invariant
780780

781781

782782
async def test_retry_after_huge_digit_string_gives_up() -> None:

0 commit comments

Comments
 (0)