|
| 1 | +# httpware 0.4.0 — Retry middleware + RetryBudget |
| 2 | + |
| 3 | +**0.4.0 is additive. No breaking changes.** Code written against 0.3.0 continues to work unchanged. |
| 4 | + |
| 5 | +This release ships the first slice of Epic 3 (Resilience): a `Retry` middleware with sensible defaults, a Finagle-style `RetryBudget` token bucket that prevents retry storms, and a refinement to the exception tree (`NetworkError`) that lets callers tell transient network failures apart from non-retryable transport failures. |
| 6 | + |
| 7 | +## New features |
| 8 | + |
| 9 | +- **`httpware.Retry`** — middleware that automatically retries transient failures on idempotent methods. Defaults: |
| 10 | + - `max_attempts=3`, `base_delay=0.1s`, `max_delay=5.0s`, full-jitter exponential backoff (AWS formulation) |
| 11 | + - Retries on `408`, `429`, `502`, `503`, `504` for `GET / HEAD / OPTIONS / PUT / DELETE` (non-idempotent methods like `POST` and `PATCH` are not retried by default — pass `retry_methods=` to opt in per client) |
| 12 | + - Retries on `httpware.NetworkError` and `httpware.TimeoutError` for the same method set |
| 13 | + - Honors `Retry-After` (seconds + HTTP-date forms, capped at `max_delay`); `respect_retry_after=False` disables |
| 14 | + - Optional `attempt_timeout=` wall-clock cap per attempt via `asyncio.timeout()` |
| 15 | + - On exhaustion, re-raises the original `StatusError` subclass unwrapped with a PEP 678 `__notes__` entry (`"httpware: gave up after N attempts"`) |
| 16 | +- **`httpware.RetryBudget`** — Finagle-style token bucket bounding retry rate to prevent retry storms when downstream services degrade. Defaults: `ttl=10s`, `min_retries_per_sec=10`, `percent_can_retry=0.2` (match Finagle / AWS SDK / Envoy). Per `Retry`-instance by default; pass an explicit `RetryBudget` to share across multiple `Retry` middlewares (e.g., several `AsyncClient`s hitting the same downstream). |
| 17 | +- **`httpware.RetryBudgetExhaustedError`** — distinct `ClientError` raised when the budget refuses a retry. Carries `last_response: httpx2.Response | None`, `last_exception: BaseException | None`, and `attempts: int`. Picklable across process boundaries. |
| 18 | +- **`httpware.NetworkError(TransportError)`** — refines the `AsyncClient` terminal mapping so transient `httpx2.NetworkError`-family exceptions (`ConnectError`, `ReadError`, `WriteError`, `CloseError`) raise `httpware.NetworkError`. `InvalidURL` and `CookieConflict` continue to raise bare `TransportError`. Pool-acquisition timeouts (`httpx2.PoolTimeout`) continue to raise `httpware.TimeoutError`. |
| 19 | + |
| 20 | +## Backwards compatibility |
| 21 | + |
| 22 | +Subclassing keeps existing catch-blocks working unchanged: |
| 23 | + |
| 24 | +- `except TransportError` still catches all transient + permanent transport-layer failures (`NetworkError` is a subclass). |
| 25 | +- `except ClientError` still catches everything in the httpware exception tree, including the new `RetryBudgetExhaustedError`. |
| 26 | + |
| 27 | +The terminal mapping change only narrows what callers see when they check the *exact* type. Catch-by-isinstance behaves the same. |
| 28 | + |
| 29 | +## Usage |
| 30 | + |
| 31 | +```python |
| 32 | +from httpware import AsyncClient, Retry, RetryBudget |
| 33 | + |
| 34 | +async with AsyncClient( |
| 35 | + base_url="https://api.example.com", |
| 36 | + middleware=[Retry()], # default: 3 attempts, full-jitter backoff, fresh RetryBudget |
| 37 | +) as client: |
| 38 | + user = await client.get("/users/1", response_model=User) |
| 39 | +``` |
| 40 | + |
| 41 | +Share a budget across several clients hitting the same downstream: |
| 42 | + |
| 43 | +```python |
| 44 | +from httpware import AsyncClient, Retry, RetryBudget |
| 45 | + |
| 46 | +shared_budget = RetryBudget() # one bucket, shared |
| 47 | + |
| 48 | +async with AsyncClient( |
| 49 | + base_url="https://upstream-a.example.com", |
| 50 | + middleware=[Retry(budget=shared_budget)], |
| 51 | +) as client_a, AsyncClient( |
| 52 | + base_url="https://upstream-b.example.com", |
| 53 | + middleware=[Retry(budget=shared_budget)], |
| 54 | +) as client_b: |
| 55 | + ... |
| 56 | +``` |
| 57 | + |
| 58 | +Catch budget exhaustion specifically: |
| 59 | + |
| 60 | +```python |
| 61 | +from httpware import RetryBudgetExhaustedError |
| 62 | + |
| 63 | +try: |
| 64 | + response = await client.get("/users/1") |
| 65 | +except RetryBudgetExhaustedError as exc: |
| 66 | + # Budget refused a retry; the prior failure is preserved. |
| 67 | + logger.warning( |
| 68 | + "retry budget exhausted after %d attempts; last status %s", |
| 69 | + exc.attempts, |
| 70 | + exc.last_response.status_code if exc.last_response else "n/a", |
| 71 | + ) |
| 72 | +``` |
| 73 | + |
| 74 | +Tune for tighter SLAs: |
| 75 | + |
| 76 | +```python |
| 77 | +Retry( |
| 78 | + max_attempts=5, |
| 79 | + base_delay=0.05, |
| 80 | + max_delay=1.0, |
| 81 | + attempt_timeout=0.5, # cap each attempt at 500ms wall-clock |
| 82 | + retry_methods=frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE", "POST"}), |
| 83 | + budget=RetryBudget(percent_can_retry=0.1), # tighter cap |
| 84 | +) |
| 85 | +``` |
| 86 | + |
| 87 | +## What's still ahead |
| 88 | + |
| 89 | +The rest of Epic 3 — `Bulkhead` (concurrency limiter) and extension-slot documentation — ships in subsequent releases. Epic 5 (observability hooks + OTel middleware) is also unstarted; logging of retry decisions will plumb through then. |
| 90 | + |
| 91 | +Out of scope for this release (per the spec, may revisit on real-user pain): per-call retry override via `extensions`, a `Backoff` protocol abstraction, `retry_on_exception=` configuration, and retrying streamed request bodies (the latter waits for `AsyncClient.stream` in Epic 4). |
| 92 | + |
| 93 | +## References |
| 94 | + |
| 95 | +- Spec: [`planning/specs/2026-06-05-retry-and-retry-budget-design.md`](../specs/2026-06-05-retry-and-retry-budget-design.md) |
| 96 | +- Plan: [`planning/plans/2026-06-05-retry-and-retry-budget-plan.md`](../plans/2026-06-05-retry-and-retry-budget-plan.md) |
| 97 | +- Roadmap: [`planning/engineering.md`](../engineering.md) §8 |
0 commit comments