Skip to content

Commit 8630414

Browse files
lesnik512claude
andcommitted
docs(resilience): promote _RetryPolicy into architecture truth
Document the shared stateless _RetryPolicy + thin-wrapper structure in architecture/resilience.md. Includes lint cleanups surfaced by just lint: drop a redundant PLR0912 noqa on decide, fix a docstring mood + magic value in the new seam tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1b63f6b commit 8630414

3 files changed

Lines changed: 8 additions & 4 deletions

File tree

architecture/resilience.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
`Retry` (and `AsyncRetry`) is a retry middleware backed by a Finagle-style `RetryBudget` — a token bucket that caps the proportion of traffic spent on retries so a degraded backend cannot be amplified into a retry storm. `RetryBudget` is a single thread-safe class shared by both worlds: all mutations go through a `threading.Lock`, so state is never torn. "Safe" here means no corruption, not non-blocking — when one budget is shared across a (sync `Client`, `AsyncClient`) pair, a sync thread holding the lock can briefly block the event-loop thread's acquisition. The critical section is intentionally tiny to bound that latency. Backoff between attempts uses full-jitter.
88

9+
The decision logic — status/method eligibility, streaming-body refusal, exhaustion, Retry-After handling, budget accounting, and the backoff delay — lives once in a stateless private `_RetryPolicy.decide`, the retry analog of how the circuit breaker keeps its transition logic in one shared state object. `Retry` and `AsyncRetry` are thin loop drivers over that policy: they own the attempt loop, the terminal call, and the sleep, and differ only in `await next` vs `next` and `asyncio.sleep` vs `time.sleep`. `decide` returns the delay to sleep before the next attempt, or raises the terminal exception (with its PEP 678 note and event already emitted); because it runs inside the wrapper's `except` block, exception chaining behaves as a direct raise. `_RetryPolicy` holds the immutable config plus the shared `RetryBudget`; per-attempt state stays as wrapper locals, so one instance is safe across the concurrent requests it serves.
10+
911
## Bulkhead
1012

1113
`Bulkhead` / `AsyncBulkhead` is a concurrency limiter. `AsyncBulkhead` uses `asyncio.Semaphore` with a bounded acquire wait; sync `Bulkhead` uses `threading.Semaphore`. A sync instance cannot share with an async one. Both are sharable across clients (one instance = one shared concurrency pool).

src/httpware/middleware/resilience/retry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def __init__( # noqa: PLR0913 — retry policy has many orthogonal knobs; a dat
115115
self.respect_retry_after = respect_retry_after
116116
self.budget = budget if budget is not None else RetryBudget()
117117

118-
def decide( # noqa: C901, PLR0912 — complexity budget: classification + streaming-body refusal + exhaustion + Retry-After branch + budget gate + backoff
118+
def decide( # noqa: C901 — complexity budget: classification + streaming-body refusal + exhaustion + Retry-After branch + budget gate + backoff
119119
self,
120120
*,
121121
attempt: int,

tests/test_retry_policy.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
_URL = "https://example.test/x"
3030
_BASE_DELAY = 0.1
3131
_MAX_DELAY = 5.0
32+
_RETRY_AFTER_HEADER = "2"
33+
_RETRY_AFTER_SECONDS = 2.0
3234

3335

3436
def _policy(
@@ -49,7 +51,7 @@ def _policy(
4951

5052

5153
def _zero_budget() -> RetryBudget:
52-
"""A budget that always refuses withdrawal (floor=0, percent=0)."""
54+
"""Return a budget that always refuses withdrawal (floor=0, percent=0)."""
5355
return RetryBudget(ttl=10.0, min_retries_per_sec=0.0, percent_can_retry=0.0)
5456

5557

@@ -149,9 +151,9 @@ def test_retry_after_exceeding_max_delay_gives_up() -> None:
149151

150152
def test_retry_after_within_max_delay_returned_exactly() -> None:
151153
request = _request("PUT")
152-
exc = _status_exc(503, request, retry_after="2") # <= max_delay
154+
exc = _status_exc(503, request, retry_after=_RETRY_AFTER_HEADER) # <= max_delay
153155
delay = _policy().decide(attempt=0, request=request, exc=exc)
154-
assert delay == 2.0
156+
assert delay == _RETRY_AFTER_SECONDS
155157

156158

157159
def test_respect_retry_after_false_ignores_header() -> None:

0 commit comments

Comments
 (0)