Skip to content

Commit a24630e

Browse files
lesnik512claude
andcommitted
chore(release): draft 0.4.0 release notes
Slice 1 of Epic 3: Retry middleware, Finagle-style RetryBudget, RetryBudgetExhaustedError, and the NetworkError(TransportError) refinement. Purely additive — no breaking changes from 0.3.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2774640 commit a24630e

1 file changed

Lines changed: 97 additions & 0 deletions

File tree

planning/releases/0.4.0.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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

Comments
 (0)