Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 28 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,32 +18,33 @@
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)

**Typed, resilient HTTP clients for Python — typed errors, typed response bodies, and composable resilience (retry, bulkhead, circuit breaker), sync or async.**
Typed, resilient HTTP clients for Python, sync and async.

## Why httpware

- **Errors you can catch by name** — a 404 raises `NotFoundError`, a 429
`RateLimitedError`, automatically; everything else bubbles up under one
`httpware.StatusError` base. No `raise_for_status()`, no status-code
branching.
- **Typed response bodies** — `response_model=User` decodes the body straight
to your pydantic or msgspec type; a missing decoder fails fast, *before* the
request goes out.
- **Composable resilience** — retry + retry-budget, bulkhead, circuit breaker,
and timeout as middleware over standard `httpx2`.
- A 4xx or 5xx response raises an exception named after its status, such as
`NotFoundError` for 404 or `RateLimitedError` for 429. All of them subclass
`httpware.StatusError`, so you never call `raise_for_status()`.
- `response_model=User` decodes the body into your pydantic or msgspec type.
If no installed decoder handles the type, the call fails before the request
is sent.
- Retry with a retry budget, bulkhead, circuit breaker, and timeout ship as
middleware you compose per client.

Built on `httpx2`: httpware re-exports `httpx2.Request`/`httpx2.Response` and stays a thin wrapper, not a new HTTP abstraction.
httpware is a thin layer over `httpx2`: requests and responses are plain
`httpx2.Request` and `httpx2.Response` objects.

> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.

## Install

```bash
pip install httpware # core only — no decoder
pip install httpware[pydantic] # + PydanticDecoder — BaseModel, dataclasses, primitives, generics
pip install httpware[msgspec] # + MsgspecDecoder — Struct, dataclasses, primitives, generics
pip install httpware[pydantic,msgspec] # both — BaseModel routes to pydantic, Struct to msgspec
pip install httpware[all] # everything (pydantic, msgspec, otel)
pip install httpware # core only, no decoder
pip install httpware[pydantic] # PydanticDecoder: BaseModel, dataclasses, primitives, generics
pip install httpware[msgspec] # MsgspecDecoder: Struct, dataclasses, primitives, generics
pip install httpware[pydantic,msgspec] # both; BaseModel goes to pydantic, Struct to msgspec
pip install httpware[otel] # OpenTelemetry span events
pip install httpware[all] # pydantic, msgspec, and otel
```

## Quickstart
Expand Down Expand Up @@ -71,22 +72,24 @@ async def main() -> None:
asyncio.run(main())
```

The sync `Client` is identical — swap `AsyncClient` → `Client` and drop the `await` / `async with`. A 4xx/5xx response raises a typed `StatusError`; a malformed body raises `DecodeError`. Both subclass `httpware.ClientError`.
The sync `Client` works the same way: use `Client` instead of `AsyncClient`, and drop `await` and `async with`. A 4xx/5xx response raises a typed `StatusError`; a malformed body raises `DecodeError`. Both subclass `httpware.ClientError`.

## Documentation

Full guides live at **[httpware.modern-python.org](https://httpware.modern-python.org)**:
Full guides live at [httpware.modern-python.org](https://httpware.modern-python.org):

- **[Quickstart & observability](https://httpware.modern-python.org/)** — resilience middleware, streaming, and the stable logger/event contract.
- **[Middleware](https://httpware.modern-python.org/middleware/)** — write your own (auth, tracing, request-ID propagation).
- **[Resilience](https://httpware.modern-python.org/resilience/)** — retry + retry-budget, bulkhead, circuit breaker, timeout.
- **[Errors](https://httpware.modern-python.org/errors/)** — the exception tree and catching strategies.
- **[Testing](https://httpware.modern-python.org/testing/)** — `httpx2.MockTransport` injection.
- **[Recipes](https://httpware.modern-python.org/recipes/modern-di/)** — DI wiring, phase-decorator patterns, link-header pagination.
- [Quickstart](https://httpware.modern-python.org/): first requests, client options, streaming.
- [Resilience](https://httpware.modern-python.org/resilience/): retry and retry budget, bulkhead, circuit breaker, timeout.
- [Errors](https://httpware.modern-python.org/errors/): the exception tree and how to catch it.
- [Decoders](https://httpware.modern-python.org/decoders/): typed response bodies and custom decoders.
- [Middleware](https://httpware.modern-python.org/middleware/): writing your own (auth, tracing, request IDs).
- [Observability](https://httpware.modern-python.org/observability/): logger and event names, OpenTelemetry wiring.
- [Testing](https://httpware.modern-python.org/testing/): injecting `httpx2.MockTransport`.
- [Recipes](https://httpware.modern-python.org/recipes/modern-di/): DI wiring, phase decorators, Link header pagination.

## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases) · 📦 [PyPI](https://pypi.org/project/httpware) · 📝 [License](https://github.com/modern-python/httpware/blob/main/LICENSE)

## Part of `modern-python`

Browse the full list of templates and libraries in
[`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
[`modern-python`](https://github.com/modern-python); the org profile has the categorized index.
18 changes: 9 additions & 9 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
# Security Policy
# Security policy

## Reporting a Vulnerability
## Reporting a vulnerability

If you discover a security vulnerability in `httpware`, please report it privately via [GitHub Security Advisories](https://github.com/modern-python/httpware/security/advisories/new).

**Do not file a public GitHub issue for security reports.**
Do not file a public GitHub issue for security reports.

## Disclosure Timeline
## Disclosure timeline

- We commit to acknowledging your report within **7 days**.
- We aim to provide a fix or detailed mitigation plan within **30 days** of confirmation.
- We follow a **90-day private disclosure window** before public disclosure of the vulnerability and fix, unless a coordinated earlier disclosure is in the interest of users (e.g., the vulnerability is already being actively exploited).
- We acknowledge reports within 7 days.
- We aim to provide a fix or a detailed mitigation plan within 30 days of confirming the report.
- We keep a vulnerability and its fix private for 90 days before disclosing them, unless an earlier coordinated disclosure serves users better, for example when the vulnerability is already being exploited.

## Supported Versions
## Supported versions

Security fixes are provided for:

Expand All @@ -31,5 +31,5 @@ In scope:

Out of scope:

- Vulnerabilities in transitive dependencies (`httpx2`, `pydantic`, etc.) — report those upstream. We will fast-track a `httpware` release pinning the patched version once an upstream fix is available.
- Vulnerabilities in dependencies such as `httpx2` or `pydantic`. Report those upstream; once a fix is released there, we will quickly publish an httpware release that requires the patched version.
- Misconfiguration in consuming applications.
43 changes: 21 additions & 22 deletions docs/decoders.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Decoders

`httpware`'s typed-response extension point is the **`ResponseDecoder` protocol**. A decoder turns raw response bytes into a typed object: when you pass `response_model=` to `send` / `send_with_response`, the client walks its decoder list, picks the first one that claims your model, and hands it the body.
A decoder turns raw response bytes into a typed object. When you pass `response_model=` to a request, the client goes through its decoder list, picks the first decoder that accepts your type, and hands it the body.

The built-in `PydanticDecoder` and `MsgspecDecoder` are themselves implementations of this protocol; nothing about them is privileged. Reach for a custom decoder when you need a body **format** the built-ins don't speak (CSV, XML, MessagePack, a bespoke binary frame) or a **type system** they don't cover (`attrs`, `marshmallow`, your own class hierarchy). If pydantic or msgspec already decodes your model, you don't need one — see [When NOT to write a decoder](#when-not-to-write-a-decoder).
`PydanticDecoder` and `MsgspecDecoder` implement the same `ResponseDecoder` protocol you would. Write your own when the body is in a format the built-ins can't read (CSV, XML, MessagePack, a custom binary format) or the type comes from a library they don't support (`attrs`, `marshmallow`, your own classes). If pydantic or msgspec already decodes your type, you don't need one; see [When not to write a decoder](#when-not-to-write-a-decoder).

## The protocol

Expand All @@ -20,30 +20,29 @@ class ResponseDecoder(Protocol):
def decode(self, content: bytes, model: type[T]) -> T: ...
```

Two methods, two distinct jobs:
`can_decode(model)` decides whether the decoder takes a type. The client asks each decoder in `decoders=[...]` order and uses the first one that returns `True`. Accept every type you can handle and let the list order express the caller's preference, but reject types that belong to another library: a CSV decoder should not accept a `pydantic.BaseModel`. `can_decode` must never raise. It runs before the request and outside the `DecodeError` wrapping that protects `decode`, so an exception there reaches the caller as something other than a `ClientError`. If the decoder can't tell, return `False`.

- **`can_decode(model) -> bool`** — the dispatch predicate. The client walks `decoders=[...]` in order and picks the **first** decoder that returns `True`. Claim every model you can actually handle (broad is correct — list ordering, not narrow predicates, encodes the caller's preference), but **reject another library's native types**: a CSV decoder has no business claiming a `pydantic.BaseModel`. `can_decode` **MUST NOT raise** — it runs at dispatch time, before the HTTP call and *outside* the `DecodeError` wrap that protects `decode`, so an exception here escapes `httpware`'s `ClientError` contract instead of being translated. A decoder that can't decide must return `False` (decline), not raise.
- **`decode(content, model) -> T`** — the decode itself, raw response bytes in, a `model` instance out. Any exception you raise here is caught by the client and wrapped as `httpware.DecodeError` (carrying `response`, `model`, and the `original` exception). You do **not** need to raise `DecodeError` yourself — raise whatever your parser raises and let the seam translate it.
`decode(content, model)` takes the raw body and returns an instance of `model`. The client wraps any exception it raises in `httpware.DecodeError`, with `response`, `model` and the `original` exception attached, so raise whatever your parser raises.

The protocol is `@runtime_checkable` and structural: any object with these two methods satisfies it. You do not subclass anything.
The protocol is structural and `@runtime_checkable`: any object with these two methods satisfies it, with no base class.

## How the client resolves a model

Both clients take `decoders: Sequence[ResponseDecoder] | None = None`, composed once at `__init__` and frozen for the client's lifetime.
Both clients take `decoders: Sequence[ResponseDecoder] | None = None`. The list is fixed when the client is built.

- **Order is preference.** `decoders=[CsvDecoder(), PydanticDecoder()]` asks the CSV decoder first; pydantic only sees models CSV declined. List position is how you disambiguate a shape two decoders could both claim.
- **`decoders=None`** resolves against installed extras — pydantic-first when both are present, either-only when one is, an empty tuple when neither. To *add* a decoder without losing the built-ins, list them explicitly: `decoders=[CsvDecoder(), PydanticDecoder()]`.
- **No claimer is a pre-flight error.** When `response_model=` is set and no decoder claims it, the client raises `MissingDecoderError` **before** sending the request — you find out at wiring time, not after a wasted round-trip. This is distinct from `DecodeError`: `MissingDecoderError` means *nothing handles this model* (fix: install an extra or pass `decoders=[...]`); `DecodeError` means *a decoder ran and the payload was malformed* (fix: the server or the model). See [Errors](errors.md).
- Order is preference. With `decoders=[CsvDecoder(), PydanticDecoder()]`, pydantic only sees the types the CSV decoder declined. When two decoders could both handle a type, the earlier one wins.
- `decoders=None` uses the installed extras: pydantic then msgspec when both are installed, whichever one is installed, or no decoders at all. Passing a list replaces the defaults, so include the built-ins you still want: `decoders=[CsvDecoder(), PydanticDecoder()]`.
- If `response_model=` is set and no decoder accepts it, the client raises `MissingDecoderError` before sending the request. That means nothing handles the type, and the fix is to install an extra or pass `decoders=[...]`. A `DecodeError` instead means a decoder ran and the body didn't fit the type, which points at the server or the model. See [Errors](errors.md).

## Decoders are sync — for both clients
## One sync protocol for both clients

Unlike middleware, which has separate `AsyncMiddleware` and `Middleware` flavors, there is **one** `ResponseDecoder` protocol, shared by `AsyncClient` and `Client` alike. `decode` is a synchronous method: by the time it runs, the body has already been read off the wire, so decoding is pure CPU work with nothing to await. Write one decoder and pass it to either client.
Middleware comes in sync and async versions, but there is only one `ResponseDecoder` protocol, used by both clients. `decode` is synchronous because the body has already been read when it runs, so there is nothing to await. The same decoder works with either client.

## Writing your own

### Worked example: a CSV decoder

A decoder for `text/csv` endpoints that returns a `list` of dataclass rows. Both built-ins are JSON, so this is the case they can't cover — and it shows the seam's real shape: raw bytes in, typed object out, no JSON anywhere.
This decoder reads `text/csv` responses into a `list` of dataclass rows. Both built-ins only read JSON, so they can't handle this case.

```python
import csv
Expand Down Expand Up @@ -77,7 +76,7 @@ class CsvDecoder:
return [row_type(**{name: field_types[name](value) for name, value in row.items()}) for row in reader]
```

`can_decode` is total and never raises: a non-`list` model, a bare `list`, or `list[int]` all fall through to `False`. `decode` coerces each CSV cell with its field's type (CSV values arrive as strings) — a real decoder would handle optionals, dates, and missing columns; this is where your domain logic goes. Wire it ahead of the built-ins so it gets first refusal on `list[...]` models while pydantic still handles everything else:
`can_decode` never raises: a non-`list` type, a bare `list` and `list[int]` all return `False`. `decode` converts each CSV cell, which arrives as a string, with its field's type. A real decoder would also handle optional fields, dates and missing columns. Put it before the built-ins so it sees `list[...]` types first, while pydantic still handles everything else:

```python
@dataclasses.dataclass
Expand All @@ -103,7 +102,7 @@ The same decoder instance works with a sync `Client(decoders=[CsvDecoder(), Pyda

### A note on claiming the right models

`can_decode` is a contract with the *rest of the list*. Claim too broadly and you steal models from decoders behind you; claim too narrowly and your decoder never runs. The rule of thumb: claim exactly the types you natively own, and reject another library's. An adapter for a third-party type system narrows its claim to that system — for example, a [`cattrs`](https://catt.rs)-backed decoder for `attrs` classes:
`can_decode` affects the other decoders in the list. Accept too much and you take types from the decoders after yours; accept too little and yours never runs. Accept exactly the types your decoder is for, and reject types from other libraries. A decoder for a third-party type system should accept only that system's types, as in this [`cattrs`](https://catt.rs) decoder for `attrs` classes:

```python
import json
Expand All @@ -122,15 +121,15 @@ class CattrsDecoder:
return self._converter.structure(json.loads(content), model)
```

Note this decoder is **two-pass** (`json.loads`, then `structure`). The built-in adapters deliberately decode in a single bytes-in pass (`TypeAdapter.validate_json`, `msgspec.json.Decoder.decode`) to skip the intermediate `dict` allocation — but that's a *performance choice for the built-ins*, not a protocol obligation. A custom decoder may go two-pass when its underlying library only structures from native Python objects; you pay one extra allocation, nothing more.
This decoder makes two passes: `json.loads`, then `structure`. The built-ins decode straight from bytes (`TypeAdapter.validate_json`, `msgspec.json.Decoder.decode`) to avoid building an intermediate `dict`, but that is only an optimization. Two passes are fine when your library can only work from Python objects; the cost is one extra allocation.

### When NOT to write a decoder
### When not to write a decoder

- **Your model is JSON.** Dataclasses, `TypedDict`s, primitives, pydantic models, and msgspec `Struct`s are all covered by the built-in `PydanticDecoder` / `MsgspecDecoder`. Install the extra (`httpware[pydantic]` or `httpware[msgspec]`) instead of writing a decoder.
- **You only want raw bytes or text.** Don't pass `response_model=` at all — call `send` (or a verb method) without it and read `response.content` / `response.text` directly. Decoders are for *typed* bodies.
- **The transform is per-call, not per-type.** If the shaping depends on the request rather than the model, it's a [middleware](middleware.md) concern, not a decoder.
- The body is JSON. `PydanticDecoder` and `MsgspecDecoder` handle dataclasses, `TypedDict`s, primitives, pydantic models and msgspec `Struct`s. Install `httpware[pydantic]` or `httpware[msgspec]`.
- You want raw bytes or text. Leave out `response_model=` and read `response.content` or `response.text`.
- The shaping depends on the request rather than the type. That belongs in [middleware](middleware.md).

## See also

- **`src/httpware/decoders/pydantic.py` and `msgspec.py`** — the built-in adapters as reference implementations, including how they memoize a `can_decode` verdict and cache the underlying parser per model.
- **[Quick-Start: typed responses](index.md)** — composing `response_model=` with the default decoder list.
- [`src/httpware/decoders/`](https://github.com/modern-python/httpware/tree/main/src/httpware/decoders): the built-in decoders, including how they cache `can_decode` results and parsers per type.
- [Quickstart: typed responses](index.md#typed-responses): `response_model=` with the default decoders.
Loading
Loading