Skip to content

Commit 2774640

Browse files
authored
Merge pull request #22 from modern-python/feat/v0.4-retry-and-budget
feat: Retry middleware + Finagle RetryBudget (0.4.0 slice 1)
2 parents f9d3483 + 1884b53 commit 2774640

20 files changed

Lines changed: 1305 additions & 15 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ __pycache__/*
1111
.env
1212
.pytest_cache
1313
.ruff_cache
14+
.hypothesis/
1415
.coverage
1516
htmlcov/
1617
coverage.xml

planning/deferred-work.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ Items raised in reviews that are real but not actionable now.
44

55
## Open
66

7+
### Retry + streaming bodies (Epic 4 interaction)
8+
9+
- **`Retry` re-invokes `next(request)` with the same `httpx2.Request` on each attempt.** Safe for in-memory bytes/JSON bodies; unsafe for streaming/async-iterable bodies (consumed iterator can't replay). When Epic 4 ships `AsyncClient.stream` (`4-3`), Retry needs to refuse to retry streamed-body requests (or document that callers supply a body factory). Spec: `planning/specs/2026-06-05-retry-and-retry-budget-design.md` §"Open questions".
10+
711
### Decoder-side
812

913
- **`_get_adapter` `lru_cache` is module-global, not per-decoder instance** — keyed by `model` only; two `PydanticDecoder()` instances with different configurations (none today) would share adapters, and the cache survives across tests unless explicitly cleared. Revisit if/when a configurable `PydanticDecoder(mode=..., strict=...)` lands. (`src/httpware/decoders/pydantic.py:12-14`)

planning/engineering.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ Post-pivot, the roadmap has three categories. Topic slugs in `planning/specs/` a
121121

122122
### Surviving (land in subsequent PRs)
123123

124-
- **Epic 3 — Resilience:** `3-1` per-attempt timeout, `3-2` retry, `3-3` `RetryBudget`, `3-4` `RetryBudget` middleware integration, `3-5` `Bulkhead`, `3-6` extension-slot docs.
124+
- **Epic 3 — Resilience:**
125+
- **Shipped in v0.4 slice 1:** `Retry` middleware + Finagle-style `RetryBudget` token bucket + `attempt_timeout=` parameter (folded-in 3-1). See [`planning/specs/2026-06-05-retry-and-retry-budget-design.md`](specs/2026-06-05-retry-and-retry-budget-design.md) and [`planning/plans/2026-06-05-retry-and-retry-budget-plan.md`](plans/2026-06-05-retry-and-retry-budget-plan.md).
126+
- **Remaining:** `3-5` `Bulkhead`, `3-6` extension-slot docs.
125127
- **Epic 4 — Streaming:** `4-3` `AsyncClient.stream` context manager (forwards to `httpx2.AsyncClient.stream`; no `StreamResponse` type).
126128
- **Epic 5 — Observability:** `5-1` Layer 1 middleware hooks, `5-2` wire into resilience middlewares, `5-4` OpenTelemetry middleware (`otel` extra), `5-5` logging policy CI grep.
127129
- **Epic 6 — Ship v1.0:** `6-2` docs site (`mkdocs`), `6-3` benchmarks, `6-5` release flow (Trusted Publishers + Sigstore).

planning/plans/2026-06-05-retry-and-retry-budget-plan.md

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,14 @@ mkdir -p src/httpware/middleware/resilience
6363

6464
Then create each file with the contents below. Use the Write tool, not bash heredocs.
6565

66-
`src/httpware/middleware/resilience/__init__.py`:
66+
`src/httpware/middleware/resilience/__init__.py` (docstring-only — re-exports defer to Task 7 so intermediate tasks can `import httpware.middleware.resilience.budget` without tripping an import-time `ImportError` from this `__init__.py`):
6767
```python
68-
"""Resilience primitives: Retry middleware and RetryBudget token bucket."""
68+
"""Resilience primitives: Retry middleware and RetryBudget token bucket.
6969
70-
from httpware.middleware.resilience.budget import RetryBudget
71-
from httpware.middleware.resilience.retry import Retry
70+
Re-exports land in Task 7 once both classes exist; until then this file is
71+
docstring-only so that importing ``httpware.middleware.resilience.budget``
72+
during the intermediate tasks does not trip an import-time ``ImportError``.
73+
"""
7274
```
7375

7476
`src/httpware/middleware/resilience/budget.py`:
@@ -137,7 +139,11 @@ Expected: FAIL (`ImportError: cannot import name 'NetworkError'`).
137139
Edit `src/httpware/errors.py`. Add a new class immediately after the existing `class TransportError`:
138140
```python
139141
class NetworkError(TransportError):
140-
"""Transient network-layer failure (connect/read/write/pool). Safe to retry."""
142+
"""Transient network-layer failure (connect/read/write/close). Safe to retry.
143+
144+
Pool-acquisition timeouts are NOT under this class; they raise ``TimeoutError``
145+
via ``httpx2.PoolTimeout`` (a ``TimeoutException`` subclass).
146+
"""
141147
```
142148

143149
Run: `uv run pytest tests/test_errors.py::test_network_error_is_transport_error -v`
@@ -218,7 +224,7 @@ Becomes:
218224

219225
The `httpx2.NetworkError` branch must come BEFORE `httpx2.HTTPError` (HTTPError is the broader base). `httpx2.NetworkError` is httpx's documented base for `ConnectError`, `ReadError`, `WriteError`, `PoolTimeout` — if `httpx2`'s symbol name differs (e.g., `httpx2.exceptions.NetworkError`), use whichever import path mirrors the existing `httpx2.ConnectError` import in `tests/test_error_mapping_terminal.py` (which works via top-level `httpx2`).
220226

221-
If `httpx2.NetworkError` does not exist, fall back to enumerating the transient subset explicitly: `except (httpx2.ConnectError, httpx2.ReadError, httpx2.WriteError, httpx2.PoolTimeout) as exc:`. The plan author has confirmed `httpx2.ConnectError` and `httpx2.ReadTimeout` already work in the existing tests; the enumeration fallback is safe.
227+
If `httpx2.NetworkError` does not exist, fall back to enumerating the transient subset explicitly: `except (httpx2.ConnectError, httpx2.ReadError, httpx2.WriteError, httpx2.CloseError) as exc:`. (`PoolTimeout` is NOT in this list — it inherits from `httpx2.TimeoutException` and is already caught by the timeout branch above.) The plan author has confirmed `httpx2.ConnectError` and `httpx2.ReadTimeout` already work in the existing tests; the enumeration fallback is safe.
222228

223229
- [ ] **Step 6: Run the new terminal-mapping test**
224230

@@ -265,7 +271,7 @@ git add src/httpware/errors.py src/httpware/client.py tests/test_errors.py tests
265271
git commit -m "feat(errors): add NetworkError(TransportError) for transient httpx2 failures
266272
267273
Refines _terminal so httpx2.NetworkError-family exceptions (ConnectError, ReadError,
268-
WriteError, PoolTimeout) map to httpware.NetworkError. InvalidURL and CookieConflict
274+
WriteError, CloseError) map to httpware.NetworkError. InvalidURL and CookieConflict
269275
stay bare TransportError. Prerequisite for the Retry middleware so it can retry
270276
transient failures without retrying typos."
271277
```
@@ -1029,15 +1035,30 @@ class Retry:
10291035
Run: `uv run pytest tests/test_retry.py -v`
10301036
Expected: all PASS.
10311037

1032-
- [ ] **Step 4: Lint**
1038+
- [ ] **Step 4: Wire `Retry` + `RetryBudget` into `resilience/__init__.py`**
10331039

1034-
Run: `uv run ruff check src/httpware/middleware/resilience/retry.py tests/test_retry.py && uv run ty check src/httpware/middleware/resilience/retry.py`
1040+
Now that both classes exist, replace `src/httpware/middleware/resilience/__init__.py` with:
1041+
```python
1042+
"""Resilience primitives: Retry middleware and RetryBudget token bucket."""
1043+
1044+
from httpware.middleware.resilience.budget import RetryBudget
1045+
from httpware.middleware.resilience.retry import Retry
1046+
1047+
1048+
__all__ = ["Retry", "RetryBudget"]
1049+
```
1050+
1051+
The `__all__` is required to silence ruff F401 ("imported but unused") and matches the pattern used by `httpware/__init__.py` and `httpware/decoders/__init__.py`.
1052+
1053+
- [ ] **Step 5: Lint**
1054+
1055+
Run: `uv run ruff check src/httpware/middleware/resilience/ tests/test_retry.py && uv run ty check src/httpware/middleware/resilience/`
10351056
Expected: clean. If ruff flags `Callable` / `Awaitable` import paths, adjust per existing project pattern (see `middleware/__init__.py` which uses `from collections.abc import Awaitable, Callable`).
10361057

1037-
- [ ] **Step 5: Stage and commit**
1058+
- [ ] **Step 6: Stage and commit**
10381059

10391060
```bash
1040-
git add src/httpware/middleware/resilience/retry.py tests/test_retry.py
1061+
git add src/httpware/middleware/resilience/retry.py src/httpware/middleware/resilience/__init__.py tests/test_retry.py
10411062
git commit -m "feat(resilience): Retry middleware — status-code retry + exhaustion
10421063
10431064
Covers: happy path, 503-then-200, max_attempts exhaustion with PEP-678 note,

planning/specs/2026-06-05-retry-and-retry-budget-design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ The current `AsyncClient._terminal` maps every non-timeout `httpx2.HTTPError` (i
2828
```python
2929
# In src/httpware/errors.py:
3030
class NetworkError(TransportError):
31-
"""Transient network-layer failure (connect / read / write / pool). Safe to retry."""
31+
"""Transient network-layer failure (connect / read / write / close). Safe to retry."""
3232
```
3333

3434
And refines the terminal mapping so that `httpx2`'s transient-network exception family (`httpx2.NetworkError` per httpx convention, or whichever symbols httpx2 exposes for the same hierarchy) raises `httpware.NetworkError` rather than the broader `TransportError`. `InvalidURL` and `CookieConflict` continue to raise `TransportError` directly so they are NOT retried. Existing tests catching `TransportError` keep working (`NetworkError` is a subclass).

src/httpware/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
ConflictError,
1111
ForbiddenError,
1212
InternalServerError,
13+
NetworkError,
1314
NotFoundError,
1415
RateLimitedError,
16+
RetryBudgetExhaustedError,
1517
ServerStatusError,
1618
ServiceUnavailableError,
1719
StatusError,
@@ -21,6 +23,7 @@
2123
UnprocessableEntityError,
2224
)
2325
from httpware.middleware import Middleware, Next, after_response, before_request, on_error
26+
from httpware.middleware.resilience import Retry, RetryBudget
2427

2528

2629
__all__ = [
@@ -33,10 +36,14 @@
3336
"ForbiddenError",
3437
"InternalServerError",
3538
"Middleware",
39+
"NetworkError",
3640
"Next",
3741
"NotFoundError",
3842
"RateLimitedError",
3943
"ResponseDecoder",
44+
"Retry",
45+
"RetryBudget",
46+
"RetryBudgetExhaustedError",
4047
"ServerStatusError",
4148
"ServiceUnavailableError",
4249
"StatusError",

src/httpware/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from httpware.errors import (
1212
STATUS_TO_EXCEPTION,
1313
ClientStatusError,
14+
NetworkError,
1415
ServerStatusError,
1516
TimeoutError, # noqa: A004
1617
TransportError,
@@ -110,6 +111,8 @@ async def _terminal(self, request: httpx2.Request) -> httpx2.Response:
110111
raise TimeoutError(str(exc)) from exc
111112
except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
112113
raise TransportError(str(exc)) from exc
114+
except httpx2.NetworkError as exc:
115+
raise NetworkError(str(exc)) from exc
113116
except httpx2.HTTPError as exc:
114117
raise TransportError(str(exc)) from exc
115118
except RuntimeError as exc:

src/httpware/errors.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ class TransportError(ClientError):
4040
"""Connection / network / protocol failure raised before a response was received."""
4141

4242

43+
class NetworkError(TransportError):
44+
"""Transient network-layer failure (connect/read/write/close). Safe to retry.
45+
46+
Pool-acquisition timeouts are NOT under this class; they raise ``TimeoutError``
47+
via ``httpx2.PoolTimeout`` (a ``TimeoutException`` subclass).
48+
"""
49+
50+
4351
class TimeoutError(ClientError, builtins.TimeoutError): # noqa: A001
4452
"""Client-side timeout (connect / read / write / pool).
4553
@@ -136,3 +144,42 @@ class ServiceUnavailableError(ServerStatusError):
136144
500: InternalServerError,
137145
503: ServiceUnavailableError,
138146
}
147+
148+
149+
def _reconstruct_budget_exhausted(
150+
cls: "type[RetryBudgetExhaustedError]",
151+
last_response: httpx2.Response | None,
152+
last_exception: BaseException | None,
153+
attempts: int,
154+
) -> "RetryBudgetExhaustedError":
155+
return cls(last_response=last_response, last_exception=last_exception, attempts=attempts)
156+
157+
158+
class RetryBudgetExhaustedError(ClientError):
159+
"""Raised when a retry was needed but the RetryBudget refused to permit it.
160+
161+
Carries the last response and/or exception observed before the budget refused,
162+
plus the number of attempts already completed.
163+
"""
164+
165+
last_response: httpx2.Response | None
166+
last_exception: BaseException | None
167+
attempts: int
168+
169+
def __init__(
170+
self,
171+
*,
172+
last_response: httpx2.Response | None,
173+
last_exception: BaseException | None,
174+
attempts: int,
175+
) -> None:
176+
self.last_response = last_response
177+
self.last_exception = last_exception
178+
self.attempts = attempts
179+
super().__init__(f"retry budget exhausted after {attempts} attempt(s)")
180+
181+
def __reduce__(self) -> tuple[Any, ...]:
182+
return (
183+
_reconstruct_budget_exhausted,
184+
(type(self), self.last_response, self.last_exception, self.attempts),
185+
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Resilience primitives: Retry middleware and RetryBudget token bucket."""
2+
3+
from httpware.middleware.resilience.budget import RetryBudget
4+
from httpware.middleware.resilience.retry import Retry
5+
6+
7+
__all__ = ["Retry", "RetryBudget"]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Full-jitter exponential backoff helper (private)."""
2+
3+
import random
4+
from collections.abc import Callable
5+
6+
7+
def full_jitter_delay(
8+
attempt_index: int,
9+
*,
10+
base_delay: float,
11+
max_delay: float,
12+
_random_uniform: Callable[[float, float], float] = random.uniform,
13+
) -> float:
14+
"""Return a backoff delay using AWS's "full jitter" formulation.
15+
16+
sleep = uniform(0, min(max_delay, base_delay * 2.0 ** attempt_index))
17+
18+
`attempt_index` is 0 for the first retry, 1 for the second, etc.
19+
20+
Uses ``2.0 **`` (float exponentiation) rather than ``2 **`` so that
21+
``attempt_index >= 1024`` saturates to ``math.inf`` and ``min`` clamps to
22+
``max_delay`` — ``2 ** 1024`` would raise ``OverflowError`` during the
23+
int→float conversion.
24+
"""
25+
ceiling = min(max_delay, base_delay * (2.0**attempt_index))
26+
return _random_uniform(0.0, ceiling)

0 commit comments

Comments
 (0)