Skip to content

Commit 5d023d2

Browse files
committed
docs(engineering): note Async*/async_* rename + attempt_timeout removal
1 parent b495240 commit 5d023d2

6 files changed

Lines changed: 20 additions & 18 deletions

File tree

planning/engineering.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ This doc is the single distilled reference for `httpware` design rationale, prot
66

77
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; pydantic and msgspec are both opt-in extras as of 0.3.0), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx. `AsyncClient(decoder=None)` defaults to constructing a `PydanticDecoder` and so requires the `pydantic` extra; callers can supply an explicit `decoder=` argument to escape the default. As of 0.4.0, the package ships a small resilience suite under `httpware.middleware.resilience` — a `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — composed via the standard middleware chain. As of 0.5.0, `AsyncClient.stream()` provides a context-manager API for chunked response bodies; it bypasses the middleware chain by design (see planning/archive/specs/2026-06-05-streaming-design.md). As of 0.6.0, `Retry` and `Bulkhead` emit operational events via stdlib `logging` records (`httpware.retry` / `httpware.bulkhead` loggers) and — when `opentelemetry-api` is installed — OpenTelemetry span events on the active span. As of 0.7.0, the first-cut user-docs surface is live at <https://httpware.readthedocs.io/> (Middleware, Resilience, Errors, Testing guides) and Epic 3 is closed.
88

9+
The next release renames the async middleware surface to use the `Async*`/`async_*` prefix (aligning with httpx2's convention) and removes the seldom-used `attempt_timeout=` kwarg from `AsyncRetry` — see `planning/specs/2026-06-07-sync-client-design.md` for the rationale.
10+
911
The 0.1.0 release attempted to own a full abstraction over the underlying HTTP client. v0.2 walks that back: `httpx2` is part of the public surface.
1012

1113
## 2. Architectural invariants (CI-enforced)
@@ -26,10 +28,10 @@ A protocol seam is a documented internal boundary. AI agents and contributors mu
2628

2729
The 0.1.0 seams numbered 1 (Middleware↔Transport) and 4 (Transport↔httpx2) have collapsed into the `AsyncClient` terminal — there is no transport abstraction in v0.2.
2830

29-
### Seam A: `AsyncClient ↔ Middleware`
31+
### Seam A: `AsyncClient ↔ AsyncMiddleware`
3032

3133
- **Where:** `src/httpware/client.py``src/httpware/middleware/`.
32-
- **Contract:** the middleware chain is composed once at `AsyncClient.__init__` and frozen for the client's lifetime. The chain bottom (the "terminal") is internal: it calls `self._httpx2_client.send(request)`, maps `httpx2` errors to `httpware` errors, and raises a `StatusError` subclass on 4xx/5xx.
34+
- **Contract:** the `AsyncMiddleware` chain is composed once via `compose_async` at `AsyncClient.__init__` and frozen for the client's lifetime. The chain bottom (the "terminal") is internal: it calls `self._httpx2_client.send(request)`, maps `httpx2` errors to `httpware` errors, and raises a `StatusError` subclass on 4xx/5xx. The continuation type passed to each middleware is `AsyncNext`.
3335
- **Rule:** mutating the chain after construction is not supported. Per-request behavior goes through `httpx2.Request.extensions` or through `extensions=` kwargs at call sites.
3436

3537
### Seam B: `AsyncClient ↔ ResponseDecoder`
@@ -72,13 +74,13 @@ src/httpware/
7274
├── client.py # AsyncClient
7375
├── errors.py # status-keyed exception tree + NetworkError + RetryBudgetExhaustedError + BulkheadFullError
7476
├── middleware/
75-
│ ├── __init__.py # Middleware protocol, Next type, @before_request/@after_response/@on_error
76-
│ ├── chain.py # compose(middleware, terminal) -> Next
77+
│ ├── __init__.py # AsyncMiddleware protocol, AsyncNext type, @async_before_request/@async_after_response/@async_on_error
78+
│ ├── chain.py # compose_async(middleware, terminal) -> AsyncNext
7779
│ └── resilience/
78-
│ ├── __init__.py # re-exports Bulkhead, Retry, RetryBudget
79-
│ ├── bulkhead.py # Bulkhead middleware (concurrency limiter)
80+
│ ├── __init__.py # re-exports AsyncBulkhead, AsyncRetry, RetryBudget
81+
│ ├── bulkhead.py # AsyncBulkhead middleware (concurrency limiter)
8082
│ ├── budget.py # RetryBudget (Finagle-style token bucket)
81-
│ ├── retry.py # Retry middleware
83+
│ ├── retry.py # AsyncRetry middleware
8284
│ └── _backoff.py # full-jitter exponential backoff helper (private)
8385
├── decoders/
8486
│ ├── __init__.py # ResponseDecoder protocol

src/httpware/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def _raise_on_status_error(response: httpx2.Response) -> None:
7474
STREAMING_BODY_MARKER = "httpware.streaming_body"
7575
"""Key set on ``httpx2.Request.extensions`` by ``_request_with_body`` when content/data/files is an async-iterable.
7676
77-
``Retry.__call__`` reads this marker to refuse retrying a streamed-body request
77+
``AsyncRetry.__call__`` reads this marker to refuse retrying a streamed-body request
7878
(the consumed iterator cannot replay across attempts)."""
7979

8080

@@ -718,7 +718,7 @@ async def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signature
718718
The body is NOT pre-read for 2xx/3xx (streaming preserved); the response
719719
is closed when the context exits.
720720
721-
Bypasses the middleware chain (no Retry, no Bulkhead, no user-installed
721+
Bypasses the middleware chain (no AsyncRetry, no AsyncBulkhead, no user-installed
722722
middleware) for v1 — see planning/specs/2026-06-05-streaming-design.md.
723723
724724
Auto-raises StatusError subclasses on 4xx/5xx (NotFoundError,

src/httpware/errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ def _reconstruct_bulkhead_full(
194194

195195

196196
class BulkheadFullError(ClientError):
197-
"""Raised when ``acquire_timeout`` elapses before a Bulkhead slot becomes available.
197+
"""Raised when ``acquire_timeout`` elapses before an AsyncBulkhead slot becomes available.
198198
199199
Carries the configured caps for caller logging/alerting.
200200
"""

src/httpware/middleware/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""AsyncMiddleware protocol, AsyncNext type, and phase-shortcut decorators.
22
3-
Middleware operates directly on httpx2.Request / httpx2.Response — there is
3+
AsyncMiddleware operates directly on httpx2.Request / httpx2.Response — there is
44
no httpware-owned request type. The chain is composed at AsyncClient.__init__
55
(see client.py) and frozen for the client's lifetime.
66
"""

src/httpware/middleware/resilience/bulkhead.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Bulkhead middleware — concurrency limiter via asyncio.Semaphore.
1+
"""AsyncBulkhead middleware — concurrency limiter via asyncio.Semaphore.
22
33
See planning/specs/2026-06-05-bulkhead-design.md for the contract.
44
@@ -7,7 +7,7 @@
77
releases the slot in a try/finally so success, exceptions, and cancellation
88
all release deterministically.
99
10-
Bulkhead is the sharable unit — pass the same instance to multiple
10+
AsyncBulkhead is the sharable unit — pass the same instance to multiple
1111
AsyncClient(middleware=[shared]) calls to enforce a joint cap across clients.
1212
"""
1313

@@ -33,7 +33,7 @@ class AsyncBulkhead:
3333
Parameters
3434
----------
3535
max_concurrent
36-
Required. Maximum number of in-flight requests this Bulkhead permits.
36+
Required. Maximum number of in-flight requests this AsyncBulkhead permits.
3737
Must be ``>= 1``. There is no default because no value is universally
3838
correct — the right cap depends on downstream capacity and SLA.
3939
acquire_timeout

src/httpware/middleware/resilience/retry.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
"""Retry middleware — automatic retry of transient failures with budget control.
1+
"""AsyncRetry middleware — automatic retry of transient failures with budget control.
22
33
See planning/specs/2026-06-05-retry-and-retry-budget-design.md for the full contract.
44
55
Status-code retry: the AsyncClient terminal raises StatusError subclasses on 4xx/5xx,
6-
so Retry catches StatusError and inspects exc.response.status_code. The original
6+
so AsyncRetry catches StatusError and inspects exc.response.status_code. The original
77
StatusError subclass is re-raised unwrapped on exhaustion, with a PEP 678 note added.
88
"""
99

@@ -123,7 +123,7 @@ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Res
123123
# ---- retryable failure path
124124
if request.extensions.get(STREAMING_BODY_MARKER):
125125
if last_exc is None: # pragma: no cover — invariant from except branch
126-
msg = "Retry: streaming-body refusal reached with no last_exc"
126+
msg = "AsyncRetry: streaming-body refusal reached with no last_exc"
127127
raise AssertionError(msg)
128128
last_exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
129129
_emit_event(
@@ -141,7 +141,7 @@ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Res
141141

142142
if is_last:
143143
if last_exc is None: # pragma: no cover — structural invariant from except branch
144-
msg = "Retry: last_exc unset on final attempt — unreachable"
144+
msg = "AsyncRetry: last_exc unset on final attempt — unreachable"
145145
raise AssertionError(msg)
146146
last_exc.add_note(f"httpware: gave up after {attempt + 1} attempts")
147147
_emit_event(

0 commit comments

Comments
 (0)