Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
69e95f7
feat(budget): make RetryBudget thread-safe via threading.Lock
lesnik512 Jun 7, 2026
d24aa54
refactor(internal): extract map_httpx2_exception to _internal/excepti…
lesnik512 Jun 7, 2026
44616ed
refactor(internal): extract status raise + streaming-body predicates …
lesnik512 Jun 7, 2026
4d35a1f
refactor(client): use _internal/exception_mapping + _internal/status …
lesnik512 Jun 7, 2026
6b95250
feat(middleware): add sync Middleware protocol + Next + sync decorators
lesnik512 Jun 7, 2026
d4960f5
feat(middleware/chain): add sync compose alongside compose_async
lesnik512 Jun 7, 2026
d7794b8
test(middleware): cover sync Middleware/Next/compose/decorators
lesnik512 Jun 7, 2026
7494700
feat(retry): add sync Retry alongside AsyncRetry
lesnik512 Jun 7, 2026
68cb24f
feat(bulkhead): add sync Bulkhead with threading.Semaphore
lesnik512 Jun 7, 2026
32eeb04
feat(resilience): re-export sync Retry and Bulkhead
lesnik512 Jun 7, 2026
332f88d
test(retry-sync): cover sync Retry behavior
lesnik512 Jun 7, 2026
690b279
test(bulkhead-sync): cover sync Bulkhead behavior
lesnik512 Jun 7, 2026
905ac8d
feat(client): add sync Client (constructor + terminal + lifecycle)
lesnik512 Jun 7, 2026
ede76a6
feat(client): add sync Client HTTP methods (get/post/put/patch/delete…
lesnik512 Jun 7, 2026
6ae294f
feat(client): add Client.stream() context manager
lesnik512 Jun 7, 2026
39b0710
feat(public-api): export Client, sync Middleware/Retry/Bulkhead/Next …
lesnik512 Jun 7, 2026
a829e4b
test(client-sync): cover sync Client construction, methods, lifecycle
lesnik512 Jun 7, 2026
c1cc161
test(client-stream-sync): cover Client.stream() behavior
lesnik512 Jun 7, 2026
58be249
test: demonstrate shared RetryBudget across sync Client + AsyncClient
lesnik512 Jun 7, 2026
47f63fa
style: ruff auto-fixes after sync addition
lesnik512 Jun 7, 2026
12d5189
chore: code-review follow-ups (dedupe test, parity tests, docstring)
lesnik512 Jun 7, 2026
08299cf
docs: add sync Client + sync middleware/Retry/Bulkhead sections
lesnik512 Jun 7, 2026
91447c3
docs(engineering): document sync Client + new layout + shared helpers
lesnik512 Jun 7, 2026
0de33da
docs(release): draft 0.8.0 notes (sync Client + Async* rename)
lesnik512 Jun 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,32 @@ pip install httpware[all] # everything declared above (pydantic, msgsp

## Quickstart

> Requires: `pip install httpware[pydantic]`
**Async usage:**

```python
import asyncio

from httpware import AsyncClient

async def main() -> None:
async with AsyncClient(base_url="https://example.test") as client:
response = await client.get("/users/42")
print(response.json())

asyncio.run(main())
```

**Sync usage:**

```python
from httpware import Client

with Client(base_url="https://example.test") as client:
response = client.get("/users/42")
print(response.json())
```

Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`:

```python
from httpware import AsyncClient
Expand Down
2 changes: 2 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

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

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`).

## The exception tree

```
Expand Down
28 changes: 25 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,34 @@ pip install httpware[msgspec] # MsgspecDecoder

## First request

**Async usage:**

```python
import asyncio

from httpware import AsyncClient

async def main() -> None:
async with AsyncClient(base_url="https://example.test") as client:
response = await client.get("/users/42")
print(response.json())

asyncio.run(main())
```

**Sync usage:**

```python
from httpware import Client

with Client(base_url="https://example.test") as client:
response = client.get("/users/42")
print(response.json())
```

Typed decoding via `response_model=` works the same way in both worlds:

```python
from httpware import AsyncClient
from pydantic import BaseModel

Expand All @@ -35,9 +60,6 @@ async def main() -> None:
async with AsyncClient(base_url="https://api.example.com") as client:
user = await client.get("/users/1", response_model=User)
print(user.name)


asyncio.run(main())
```

### With resilience middleware
Expand Down
56 changes: 56 additions & 0 deletions docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,62 @@ After this runs, every `httpware` HTTP call gets an `HTTP <method>` span from th

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.

## Sync middleware

The same protocol shape, sync flavor. Use these when wiring middleware into a sync `Client` instead of `AsyncClient`.

```python
from httpware import Middleware, Next, before_request, after_response, on_error
from httpware.middleware.chain import compose
```

A sync `Middleware` is a structural protocol — any callable with the right signature satisfies it:

```python
import httpx2

from httpware import Client
from httpware.middleware import Next


class LoggingMiddleware:
def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002
print(f"-> {request.method} {request.url}")
response = next(request)
print(f"<- {response.status_code}")
return response


with Client(base_url="https://api.example.com", middleware=[LoggingMiddleware()]) as client:
client.get("/users/1")
```

Phase decorators (`@before_request`, `@after_response`, `@on_error`) have the same semantics as their `@async_*` siblings, but wrap sync functions:

```python
import uuid

import httpx2

from httpware import Client, before_request


@before_request
def add_request_id(request: httpx2.Request) -> httpx2.Request:
return httpx2.Request(
request.method,
request.url,
headers={**request.headers, "X-Request-ID": uuid.uuid4().hex},
content=request.content,
)


with Client(base_url="https://api.example.com", middleware=[add_request_id]) as client:
client.get("/users/1")
```

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.

## See also

- **`planning/engineering.md` §3 (Seam A)** — the formal protocol contract and why the chain is frozen at construction.
Expand Down
56 changes: 56 additions & 0 deletions docs/resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,62 @@ Flipping the order (`[AsyncRetry, AsyncBulkhead]`) means each retry attempt grab

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.

## Sync Retry and Bulkhead

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).

### `Retry`

```python
from httpware.middleware.resilience import Retry
```

| Parameter | Default | Effect |
|---|---|---|
| `max_attempts` | `3` | Total tries (including the first). `1` disables retries entirely; `<1` raises `ValueError`. |
| `base_delay` | `0.1` (s) | Floor for the full-jitter exponential backoff. |
| `max_delay` | `5.0` (s) | Ceiling for backoff. |
| `retry_status_codes` | `frozenset({408, 429, 502, 503, 504})` | Status codes considered retryable. |
| `retry_methods` | `frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})` | Idempotent methods only by default. POST excluded; pass an explicit frozenset including `"POST"` to retry it. |
| `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. |
| `budget` | `RetryBudget()` (default-configured) | The token bucket. Pass a shared `RetryBudget` instance to apply one budget across multiple clients — sync, async, or both. |

`Retry` uses `time.sleep` between attempts. `Retry-After`, streaming-body refusal, exhaustion behavior, and `RetryBudgetExhaustedError` semantics are identical to `AsyncRetry`.

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.

### `Bulkhead`

```python
from httpware.middleware.resilience import Bulkhead
```

| Parameter | Default | Effect |
|---|---|---|
| `max_concurrent` | **REQUIRED** | Maximum in-flight requests. `<1` raises `ValueError`. |
| `acquire_timeout` | `1.0` (s) | How long to wait for a slot before raising `BulkheadFullError`. `None` waits forever; `0` fails fast. `<0` raises `ValueError`. |

`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.

> **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.

### Composition with sync `Client`

```python
from httpware import Client
from httpware.middleware.resilience import Bulkhead, Retry


with Client(
base_url="https://api.example.com",
middleware=[
Bulkhead(max_concurrent=10),
Retry(),
],
) as client:
client.get("/users/1")
```

## See also

- **[Middleware guide](middleware.md)** — write your own resilience middleware against the same protocol `AsyncRetry` and `AsyncBulkhead` use.
Expand Down
23 changes: 23 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ The handler can be sync or async; `httpx2.MockTransport` supports both. The test

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.

### Sync `Client`

The same pattern works for the sync `Client` — pass an `httpx2.Client` (not `httpx2.AsyncClient`) built on `httpx2.MockTransport`:

```python
from http import HTTPStatus

import httpx2

from httpware import Client


def test_get_returns_typed_response() -> None:
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(HTTPStatus.OK, request=request, json={"ok": True})

with Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler))) as client:
response = client.get("https://example.test/x")

assert response.status_code == HTTPStatus.OK
assert response.json() == {"ok": True}
```

## Recording / stateful handlers

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:
Expand Down
38 changes: 20 additions & 18 deletions planning/engineering.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ This doc is the single distilled reference for `httpware` design rationale, prot

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.

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`.

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.

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

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.

### Seam A: `AsyncClient ↔ AsyncMiddleware`
### Seam A: `Client`/`AsyncClient``Middleware`/`AsyncMiddleware`

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

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

## 5. Module layout

Current tree (v0.2):
Current tree:

```text
src/httpware/
├── __init__.py # public exports
├── __init__.py # public exports (both worlds at top level)
├── py.typed
├── client.py # AsyncClient
├── errors.py # status-keyed exception tree + NetworkError + RetryBudgetExhaustedError + BulkheadFullError
├── client.py # Client (sync) + AsyncClient (async)
├── errors.py # status-keyed exception tree (shared)
├── middleware/
│ ├── __init__.py # AsyncMiddleware protocol, AsyncNext type, @async_before_request/@async_after_response/@async_on_error
│ ├── chain.py # compose_async(middleware, terminal) -> AsyncNext
│ ├── __init__.py # Middleware + AsyncMiddleware, Next + AsyncNext, decorators
│ ├── chain.py # compose + compose_async
│ └── resilience/
│ ├── __init__.py # re-exports AsyncBulkhead, AsyncRetry, RetryBudget
│ ├── bulkhead.py # AsyncBulkhead middleware (concurrency limiter)
│ ├── budget.py # RetryBudget (Finagle-style token bucket)
│ ├── retry.py # AsyncRetry middleware
│ └── _backoff.py # full-jitter exponential backoff helper (private)
├── decoders/
│ ├── __init__.py # ResponseDecoder protocol
│ ├── pydantic.py # PydanticDecoder (extra: pydantic)
│ └── msgspec.py # MsgspecDecoder (extra: msgspec)
│ ├── __init__.py # re-exports both worlds + RetryBudget
│ ├── bulkhead.py # Bulkhead + AsyncBulkhead
│ ├── budget.py # RetryBudget (thread-safe; shared)
│ ├── retry.py # Retry + AsyncRetry
│ └── _backoff.py # full-jitter helper (shared)
├── decoders/ # shared (ResponseDecoder + adapters)
└── _internal/
└── import_checker.py # is_msgspec_installed, is_pydantic_installed
├── exception_mapping.py # map_httpx2_exception (shared)
├── import_checker.py # is_*_installed flags
├── observability.py # _emit_event
└── status.py # _raise_on_status_error, _is_streaming_body_*, STREAMING_BODY_MARKER
```

**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.
Expand Down
61 changes: 61 additions & 0 deletions planning/releases/0.8.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# httpware 0.8.0 — Sync Client + httpx2-aligned naming

**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`.

If you have existing async code, migration is one mechanical pass through your imports — see "Breaking changes" below.

## What's new

- **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.
- **Sync `Middleware` + `Next` + decorators.** `from httpware import Middleware, Next, before_request, after_response, on_error`. Same protocol shape as async; bodies are sync.
- **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.
- **`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).
- **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.

## Breaking changes

### Renames

| Old name | New name |
|---|---|
| `httpware.Middleware` | `httpware.AsyncMiddleware` |
| `httpware.Next` | `httpware.AsyncNext` |
| `httpware.Retry` | `httpware.AsyncRetry` |
| `httpware.Bulkhead` | `httpware.AsyncBulkhead` |
| `httpware.before_request` | `httpware.async_before_request` |
| `httpware.after_response` | `httpware.async_after_response` |
| `httpware.on_error` | `httpware.async_on_error` |
| `httpware.middleware.chain.compose` | `httpware.middleware.chain.compose_async` |

### Removals

- `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.

### New names that previously meant something else

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).

## Migration

A one-pass sed/regex covers most of the work:

```bash
# in your project root:
git ls-files '*.py' | xargs sed -i.bak \
-e 's/from httpware import \(.*\)\bMiddleware\b/from httpware import \1AsyncMiddleware/g' \
-e 's/from httpware import \(.*\)\bNext\b/from httpware import \1AsyncNext/g' \
-e 's/from httpware import \(.*\)\bRetry\b/from httpware import \1AsyncRetry/g' \
-e 's/from httpware import \(.*\)\bBulkhead\b/from httpware import \1AsyncBulkhead/g' \
-e 's/from httpware import \(.*\)\bbefore_request\b/from httpware import \1async_before_request/g' \
-e 's/from httpware import \(.*\)\bafter_response\b/from httpware import \1async_after_response/g' \
-e 's/from httpware import \(.*\)\bon_error\b/from httpware import \1async_on_error/g'
```

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.

## References

- Design spec: [`planning/specs/2026-06-07-sync-client-design.md`](../specs/2026-06-07-sync-client-design.md)
- Implementation plan: [`planning/plans/2026-06-07-sync-client-plan.md`](../plans/2026-06-07-sync-client-plan.md)
- Engineering notes: [`planning/engineering.md`](../engineering.md) §3 Seam A, §5 module layout
- 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)
Loading
Loading