You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: docs/errors.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -4,6 +4,8 @@
4
4
5
5
For the resilience-specific errors (`RetryBudgetExhaustedError`, `BulkheadFullError`) see the [Resilience reference](resilience.md).
6
6
7
+
The status-keyed exception tree is shared between `Client` and `AsyncClient`. Catching `NotFoundError` in sync code uses the same import as catching it in async code (`from httpware import NotFoundError`).
Copy file name to clipboardExpand all lines: docs/middleware.md
+56Lines changed: 56 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -136,6 +136,62 @@ After this runs, every `httpware` HTTP call gets an `HTTP <method>` span from th
136
136
137
137
For production, swap `ConsoleSpanExporter` for your OTLP/Jaeger/Zipkin exporter. See the [OpenTelemetry Python docs](https://opentelemetry.io/docs/languages/python/) for the full SDK setup.
138
138
139
+
## Sync middleware
140
+
141
+
The same protocol shape, sync flavor. Use these when wiring middleware into a sync `Client` instead of `AsyncClient`.
142
+
143
+
```python
144
+
from httpware import Middleware, Next, before_request, after_response, on_error
145
+
from httpware.middleware.chain import compose
146
+
```
147
+
148
+
A sync `Middleware` is a structural protocol — any callable with the right signature satisfies it:
with Client(base_url="https://api.example.com", middleware=[add_request_id]) as client:
190
+
client.get("/users/1")
191
+
```
192
+
193
+
Sync and async middleware classes do not interop: a `Middleware` cannot be passed to `AsyncClient(middleware=...)` and vice versa. Pick the flavor matching your client.
194
+
139
195
## See also
140
196
141
197
-**`planning/engineering.md` §3 (Seam A)** — the formal protocol contract and why the chain is frozen at construction.
Copy file name to clipboardExpand all lines: docs/resilience.md
+56Lines changed: 56 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -168,6 +168,62 @@ Flipping the order (`[AsyncRetry, AsyncBulkhead]`) means each retry attempt grab
168
168
169
169
Cross-cutting middleware that emit per-call state (e.g., the Request-ID middleware in the [Middleware guide](middleware.md)) should sit outside `AsyncRetry` for the same reason — so all attempts of one call share one ID rather than getting a fresh ID per attempt.
170
170
171
+
## Sync Retry and Bulkhead
172
+
173
+
The sync flavors mirror the async ones for use with `Client`. Same parameter set, same defaults, same `RetryBudget` (which is safe to share across sync and async clients in the same process).
174
+
175
+
### `Retry`
176
+
177
+
```python
178
+
from httpware.middleware.resilience import Retry
179
+
```
180
+
181
+
| Parameter | Default | Effect |
182
+
|---|---|---|
183
+
|`max_attempts`|`3`| Total tries (including the first). `1` disables retries entirely; `<1` raises `ValueError`. |
184
+
|`base_delay`|`0.1` (s) | Floor for the full-jitter exponential backoff. |
185
+
|`max_delay`|`5.0` (s) | Ceiling for backoff. |
186
+
|`retry_status_codes`|`frozenset({408, 429, 502, 503, 504})`| Status codes considered retryable. |
187
+
|`retry_methods`|`frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})`| Idempotent methods only by default. POST excluded; pass an explicit frozenset including `"POST"` to retry it. |
188
+
|`respect_retry_after`|`True`| When the response carries a `Retry-After` header on a retryable status, sleep for the header value (clamped to `max_delay`) instead of the jittered backoff. |
189
+
|`budget`|`RetryBudget()` (default-configured) | The token bucket. Pass a shared `RetryBudget` instance to apply one budget across multiple clients — sync, async, or both. |
190
+
191
+
`Retry` uses `time.sleep` between attempts. `Retry-After`, streaming-body refusal, exhaustion behavior, and `RetryBudgetExhaustedError` semantics are identical to `AsyncRetry`.
192
+
193
+
For a whole-attempt wall-clock bound, use `httpx2.Timeout` on the wrapped client or pass `timeout=` per request. `httpware` does not own a structured-cancellation timeout knob.
194
+
195
+
### `Bulkhead`
196
+
197
+
```python
198
+
from httpware.middleware.resilience import Bulkhead
199
+
```
200
+
201
+
| Parameter | Default | Effect |
202
+
|---|---|---|
203
+
|`max_concurrent`|**REQUIRED**| Maximum in-flight requests. `<1` raises `ValueError`. |
204
+
|`acquire_timeout`|`1.0` (s) | How long to wait for a slot before raising `BulkheadFullError`. `None` waits forever; `0` fails fast. `<0` raises `ValueError`. |
205
+
206
+
`Bulkhead` is backed by `threading.Semaphore`. Slot release follows the same `try/finally` contract as `AsyncBulkhead` — success, exception, and (in sync land) interrupt-style exceptions all release the slot.
207
+
208
+
> **Per-world Bulkhead.** A `Bulkhead` (sync) and an `AsyncBulkhead` are separate primitives backed by `threading.Semaphore` and `asyncio.Semaphore` respectively. A single Bulkhead instance cannot enforce a joint cap across sync + async clients in the same process. If you need that, create both with the same `max_concurrent`; the OS will not coordinate the two but the policy intent is documented.
209
+
210
+
### Composition with sync `Client`
211
+
212
+
```python
213
+
from httpware import Client
214
+
from httpware.middleware.resilience import Bulkhead, Retry
215
+
216
+
217
+
with Client(
218
+
base_url="https://api.example.com",
219
+
middleware=[
220
+
Bulkhead(max_concurrent=10),
221
+
Retry(),
222
+
],
223
+
) as client:
224
+
client.get("/users/1")
225
+
```
226
+
171
227
## See also
172
228
173
229
-**[Middleware guide](middleware.md)** — write your own resilience middleware against the same protocol `AsyncRetry` and `AsyncBulkhead` use.
Copy file name to clipboardExpand all lines: docs/testing.md
+23Lines changed: 23 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -28,6 +28,29 @@ The handler can be sync or async; `httpx2.MockTransport` supports both. The test
28
28
29
29
If you use `pytest-asyncio` in auto-mode (`asyncio_mode = "auto"` under `[tool.pytest.ini_options]`), async test functions don't need the `@pytest.mark.asyncio` decorator.
30
30
31
+
### Sync `Client`
32
+
33
+
The same pattern works for the sync `Client` — pass an `httpx2.Client` (not `httpx2.AsyncClient`) built on `httpx2.MockTransport`:
Copy file name to clipboardExpand all lines: planning/engineering.md
+20-18Lines changed: 20 additions & 18 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -8,6 +8,8 @@ This doc is the single distilled reference for `httpware` design rationale, prot
8
8
9
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
10
11
+
The same release also adds a sync `Client` with full feature parity (typed decoding, middleware chain, `Retry`/`Bulkhead`, `stream()`). `RetryBudget` is now thread-safe (one class, both worlds). Sync `Bulkhead` uses `threading.Semaphore` and cannot share an instance with `AsyncBulkhead`. See `planning/specs/2026-06-07-sync-client-design.md`.
12
+
11
13
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.
12
14
13
15
## 2. Architectural invariants (CI-enforced)
@@ -28,10 +30,10 @@ A protocol seam is a documented internal boundary. AI agents and contributors mu
28
30
29
31
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.
-**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`.
36
+
-**Contract:** the middleware chain is composed once at client construction and frozen for the client's lifetime. Both worlds follow the same contract; the only difference is the per-world type: `AsyncClient` composes `AsyncMiddleware`via `compose_async`(the continuation type is `AsyncNext`), and `Client` composes `Middleware` via `compose` (the continuation type is `Next`). Both `compose` and `compose_async` live in `src/httpware/middleware/chain.py`. 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. Same lifecycle rules in both worlds.
35
37
-**Rule:** mutating the chain after construction is not supported. Per-request behavior goes through `httpx2.Request.extensions` or through `extensions=` kwargs at call sites.
36
38
37
39
### Seam B: `AsyncClient ↔ ResponseDecoder`
@@ -65,29 +67,29 @@ The error-mapping table (what `httpx2` exception maps to which `httpware` except
65
67
66
68
## 5. Module layout
67
69
68
-
Current tree (v0.2):
70
+
Current tree:
69
71
70
72
```text
71
73
src/httpware/
72
-
├── __init__.py # public exports
74
+
├── __init__.py # public exports (both worlds at top level)
**Breaking release.** Renames the async middleware surface to use the `Async*`/`async_*` prefix (matching httpx2's convention), drops `Retry(attempt_timeout=...)`, and adds a fully-featured sync `Client`.
4
+
5
+
If you have existing async code, migration is one mechanical pass through your imports — see "Breaking changes" below.
6
+
7
+
## What's new
8
+
9
+
-**Sync `Client`.** Full parity with `AsyncClient`: typed response decoding, middleware chain, `Retry` + `Bulkhead`, `stream()` context manager, lifecycle (`with` + `close()`), and `httpx2.Client` injection. Designed for CLI tools, scripts, Django sync views, Jupyter, and threaded service workers.
10
+
-**Sync `Middleware` + `Next` + decorators.**`from httpware import Middleware, Next, before_request, after_response, on_error`. Same protocol shape as async; bodies are sync.
11
+
-**Sync `Retry` and `Bulkhead`.** Same resilience semantics as their async siblings, with `time.sleep` and `threading.Semaphore`. Sync `Retry` shares `RetryBudget` with async — one instance is safe across both worlds.
12
+
-**`RetryBudget` is now thread-safe** via an internal `threading.Lock`. Async users see no behavioral difference; the overhead is invisible (~50–100 ns per op).
13
+
-**Shared helpers in `_internal/`.**`map_httpx2_exception`, `_raise_on_status_error`, the streaming-body marker, and the body predicates moved to `_internal/exception_mapping.py` and `_internal/status.py`. No public-API change other than the exports listed below.
-`Retry(attempt_timeout=...)` / `AsyncRetry(attempt_timeout=...)` is **removed**. It used `asyncio.timeout` to bound the whole attempt as a structured cancellation; this had no clean sync equivalent and is mostly covered by `httpx2.Timeout` (per-phase I/O bounds) for typical use cases. Users who genuinely need whole-attempt wall-clock bounds can compose their own timeout middleware.
33
+
34
+
### New names that previously meant something else
35
+
36
+
The unprefixed `Middleware`, `Next`, `Retry`, `Bulkhead`, `before_request`, `after_response`, `on_error` now refer to **sync** types. Code that imports them and expects async behavior will break at type-check time (or at the first `await` site).
Then update the symbol references in the file bodies (your type checker will guide you). If you were using `Retry(attempt_timeout=...)`, remove the kwarg and rely on `httpx2.Timeout` or write a minimal timeout middleware.
0 commit comments