Skip to content

Commit d2c24f2

Browse files
authored
Merge pull request #31 from modern-python/feat/sync-client
feat: sync Client + sync Middleware/Retry/Bulkhead + RetryBudget thread-safety
2 parents 28ab9da + 0de33da commit d2c24f2

28 files changed

Lines changed: 3025 additions & 112 deletions

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,32 @@ pip install httpware[all] # everything declared above (pydantic, msgsp
2424

2525
## Quickstart
2626

27-
> Requires: `pip install httpware[pydantic]`
27+
**Async usage:**
28+
29+
```python
30+
import asyncio
31+
32+
from httpware import AsyncClient
33+
34+
async def main() -> None:
35+
async with AsyncClient(base_url="https://example.test") as client:
36+
response = await client.get("/users/42")
37+
print(response.json())
38+
39+
asyncio.run(main())
40+
```
41+
42+
**Sync usage:**
43+
44+
```python
45+
from httpware import Client
46+
47+
with Client(base_url="https://example.test") as client:
48+
response = client.get("/users/42")
49+
print(response.json())
50+
```
51+
52+
Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`:
2853

2954
```python
3055
from httpware import AsyncClient

docs/errors.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
For the resilience-specific errors (`RetryBudgetExhaustedError`, `BulkheadFullError`) see the [Resilience reference](resilience.md).
66

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`).
8+
79
## The exception tree
810

911
```

docs/index.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,34 @@ pip install httpware[msgspec] # MsgspecDecoder
1919

2020
## First request
2121

22+
**Async usage:**
23+
2224
```python
2325
import asyncio
2426

27+
from httpware import AsyncClient
28+
29+
async def main() -> None:
30+
async with AsyncClient(base_url="https://example.test") as client:
31+
response = await client.get("/users/42")
32+
print(response.json())
33+
34+
asyncio.run(main())
35+
```
36+
37+
**Sync usage:**
38+
39+
```python
40+
from httpware import Client
41+
42+
with Client(base_url="https://example.test") as client:
43+
response = client.get("/users/42")
44+
print(response.json())
45+
```
46+
47+
Typed decoding via `response_model=` works the same way in both worlds:
48+
49+
```python
2550
from httpware import AsyncClient
2651
from pydantic import BaseModel
2752

@@ -35,9 +60,6 @@ async def main() -> None:
3560
async with AsyncClient(base_url="https://api.example.com") as client:
3661
user = await client.get("/users/1", response_model=User)
3762
print(user.name)
38-
39-
40-
asyncio.run(main())
4163
```
4264

4365
### With resilience middleware

docs/middleware.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,62 @@ After this runs, every `httpware` HTTP call gets an `HTTP <method>` span from th
136136

137137
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.
138138

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:
149+
150+
```python
151+
import httpx2
152+
153+
from httpware import Client
154+
from httpware.middleware import Next
155+
156+
157+
class LoggingMiddleware:
158+
def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002
159+
print(f"-> {request.method} {request.url}")
160+
response = next(request)
161+
print(f"<- {response.status_code}")
162+
return response
163+
164+
165+
with Client(base_url="https://api.example.com", middleware=[LoggingMiddleware()]) as client:
166+
client.get("/users/1")
167+
```
168+
169+
Phase decorators (`@before_request`, `@after_response`, `@on_error`) have the same semantics as their `@async_*` siblings, but wrap sync functions:
170+
171+
```python
172+
import uuid
173+
174+
import httpx2
175+
176+
from httpware import Client, before_request
177+
178+
179+
@before_request
180+
def add_request_id(request: httpx2.Request) -> httpx2.Request:
181+
return httpx2.Request(
182+
request.method,
183+
request.url,
184+
headers={**request.headers, "X-Request-ID": uuid.uuid4().hex},
185+
content=request.content,
186+
)
187+
188+
189+
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+
139195
## See also
140196

141197
- **`planning/engineering.md` §3 (Seam A)** — the formal protocol contract and why the chain is frozen at construction.

docs/resilience.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,62 @@ Flipping the order (`[AsyncRetry, AsyncBulkhead]`) means each retry attempt grab
168168

169169
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.
170170

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+
171227
## See also
172228

173229
- **[Middleware guide](middleware.md)** — write your own resilience middleware against the same protocol `AsyncRetry` and `AsyncBulkhead` use.

docs/testing.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,29 @@ The handler can be sync or async; `httpx2.MockTransport` supports both. The test
2828

2929
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.
3030

31+
### Sync `Client`
32+
33+
The same pattern works for the sync `Client` — pass an `httpx2.Client` (not `httpx2.AsyncClient`) built on `httpx2.MockTransport`:
34+
35+
```python
36+
from http import HTTPStatus
37+
38+
import httpx2
39+
40+
from httpware import Client
41+
42+
43+
def test_get_returns_typed_response() -> None:
44+
def handler(request: httpx2.Request) -> httpx2.Response:
45+
return httpx2.Response(HTTPStatus.OK, request=request, json={"ok": True})
46+
47+
with Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler))) as client:
48+
response = client.get("https://example.test/x")
49+
50+
assert response.status_code == HTTPStatus.OK
51+
assert response.json() == {"ok": True}
52+
```
53+
3154
## Recording / stateful handlers
3255

3356
For tests that need to vary the response by call count or assert on the requests that came in, use a handler with instance state:

planning/engineering.md

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

99
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.
1010

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+
1113
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.
1214

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

2931
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.
3032

31-
### Seam A: `AsyncClient ↔ AsyncMiddleware`
33+
### Seam A: `Client`/`AsyncClient``Middleware`/`AsyncMiddleware`
3234

3335
- **Where:** `src/httpware/client.py``src/httpware/middleware/`.
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`.
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.
3537
- **Rule:** mutating the chain after construction is not supported. Per-request behavior goes through `httpx2.Request.extensions` or through `extensions=` kwargs at call sites.
3638

3739
### Seam B: `AsyncClient ↔ ResponseDecoder`
@@ -65,29 +67,29 @@ The error-mapping table (what `httpx2` exception maps to which `httpware` except
6567

6668
## 5. Module layout
6769

68-
Current tree (v0.2):
70+
Current tree:
6971

7072
```text
7173
src/httpware/
72-
├── __init__.py # public exports
74+
├── __init__.py # public exports (both worlds at top level)
7375
├── py.typed
74-
├── client.py # AsyncClient
75-
├── errors.py # status-keyed exception tree + NetworkError + RetryBudgetExhaustedError + BulkheadFullError
76+
├── client.py # Client (sync) + AsyncClient (async)
77+
├── errors.py # status-keyed exception tree (shared)
7678
├── middleware/
77-
│ ├── __init__.py # AsyncMiddleware protocol, AsyncNext type, @async_before_request/@async_after_response/@async_on_error
78-
│ ├── chain.py # compose_async(middleware, terminal) -> AsyncNext
79+
│ ├── __init__.py # Middleware + AsyncMiddleware, Next + AsyncNext, decorators
80+
│ ├── chain.py # compose + compose_async
7981
│ └── resilience/
80-
│ ├── __init__.py # re-exports AsyncBulkhead, AsyncRetry, RetryBudget
81-
│ ├── bulkhead.py # AsyncBulkhead middleware (concurrency limiter)
82-
│ ├── budget.py # RetryBudget (Finagle-style token bucket)
83-
│ ├── retry.py # AsyncRetry middleware
84-
│ └── _backoff.py # full-jitter exponential backoff helper (private)
85-
├── decoders/
86-
│ ├── __init__.py # ResponseDecoder protocol
87-
│ ├── pydantic.py # PydanticDecoder (extra: pydantic)
88-
│ └── msgspec.py # MsgspecDecoder (extra: msgspec)
82+
│ ├── __init__.py # re-exports both worlds + RetryBudget
83+
│ ├── bulkhead.py # Bulkhead + AsyncBulkhead
84+
│ ├── budget.py # RetryBudget (thread-safe; shared)
85+
│ ├── retry.py # Retry + AsyncRetry
86+
│ └── _backoff.py # full-jitter helper (shared)
87+
├── decoders/ # shared (ResponseDecoder + adapters)
8988
└── _internal/
90-
└── import_checker.py # is_msgspec_installed, is_pydantic_installed
89+
├── exception_mapping.py # map_httpx2_exception (shared)
90+
├── import_checker.py # is_*_installed flags
91+
├── observability.py # _emit_event
92+
└── status.py # _raise_on_status_error, _is_streaming_body_*, STREAMING_BODY_MARKER
9193
```
9294

9395
**Deleted relative to 0.1.0:** `request.py`, `response.py`, `config.py`, `transports/` (Transport protocol + Httpx2Transport), `_internal/auth.py`, `_internal/chain.py`. The `RecordedTransport` testing helper is gone; tests inject `httpx2.MockTransport` via `httpx2_client=` instead.

planning/releases/0.8.0.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# httpware 0.8.0 — Sync Client + httpx2-aligned naming
2+
3+
**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.
14+
15+
## Breaking changes
16+
17+
### Renames
18+
19+
| Old name | New name |
20+
|---|---|
21+
| `httpware.Middleware` | `httpware.AsyncMiddleware` |
22+
| `httpware.Next` | `httpware.AsyncNext` |
23+
| `httpware.Retry` | `httpware.AsyncRetry` |
24+
| `httpware.Bulkhead` | `httpware.AsyncBulkhead` |
25+
| `httpware.before_request` | `httpware.async_before_request` |
26+
| `httpware.after_response` | `httpware.async_after_response` |
27+
| `httpware.on_error` | `httpware.async_on_error` |
28+
| `httpware.middleware.chain.compose` | `httpware.middleware.chain.compose_async` |
29+
30+
### Removals
31+
32+
- `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).
37+
38+
## Migration
39+
40+
A one-pass sed/regex covers most of the work:
41+
42+
```bash
43+
# in your project root:
44+
git ls-files '*.py' | xargs sed -i.bak \
45+
-e 's/from httpware import \(.*\)\bMiddleware\b/from httpware import \1AsyncMiddleware/g' \
46+
-e 's/from httpware import \(.*\)\bNext\b/from httpware import \1AsyncNext/g' \
47+
-e 's/from httpware import \(.*\)\bRetry\b/from httpware import \1AsyncRetry/g' \
48+
-e 's/from httpware import \(.*\)\bBulkhead\b/from httpware import \1AsyncBulkhead/g' \
49+
-e 's/from httpware import \(.*\)\bbefore_request\b/from httpware import \1async_before_request/g' \
50+
-e 's/from httpware import \(.*\)\bafter_response\b/from httpware import \1async_after_response/g' \
51+
-e 's/from httpware import \(.*\)\bon_error\b/from httpware import \1async_on_error/g'
52+
```
53+
54+
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.
55+
56+
## References
57+
58+
- Design spec: [`planning/specs/2026-06-07-sync-client-design.md`](../specs/2026-06-07-sync-client-design.md)
59+
- Implementation plan: [`planning/plans/2026-06-07-sync-client-plan.md`](../plans/2026-06-07-sync-client-plan.md)
60+
- Engineering notes: [`planning/engineering.md`](../engineering.md) §3 Seam A, §5 module layout
61+
- Source spec parent (httpx convention): [`planning/archive/specs/2026-06-03-thin-httpx2-wrapper-design.md`](../archive/specs/2026-06-03-thin-httpx2-wrapper-design.md)

0 commit comments

Comments
 (0)