Skip to content

Commit 05a4319

Browse files
authored
docs: fix stale facts and plain up user-facing prose (#151)
Bring README, docs site, demos, SECURITY and contributing guide up to date with the circuit breaker and timeout middleware, fix misleading statements, and remove dash-heavy, bold-labelled, all-caps phrasing.
1 parent 85e247f commit 05a4319

23 files changed

Lines changed: 429 additions & 471 deletions

‎README.md‎

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,32 +18,33 @@
1818
[![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)
1919
[![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)
2020

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

2323
## Why httpware
2424

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

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

3737
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
3838
3939
## Install
4040

4141
```bash
42-
pip install httpware # core only — no decoder
43-
pip install httpware[pydantic] # + PydanticDecoder — BaseModel, dataclasses, primitives, generics
44-
pip install httpware[msgspec] # + MsgspecDecoder — Struct, dataclasses, primitives, generics
45-
pip install httpware[pydantic,msgspec] # both — BaseModel routes to pydantic, Struct to msgspec
46-
pip install httpware[all] # everything (pydantic, msgspec, otel)
42+
pip install httpware # core only, no decoder
43+
pip install httpware[pydantic] # PydanticDecoder: BaseModel, dataclasses, primitives, generics
44+
pip install httpware[msgspec] # MsgspecDecoder: Struct, dataclasses, primitives, generics
45+
pip install httpware[pydantic,msgspec] # both; BaseModel goes to pydantic, Struct to msgspec
46+
pip install httpware[otel] # OpenTelemetry span events
47+
pip install httpware[all] # pydantic, msgspec, and otel
4748
```
4849

4950
## Quickstart
@@ -71,22 +72,24 @@ async def main() -> None:
7172
asyncio.run(main())
7273
```
7374

74-
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`.
75+
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`.
7576

7677
## Documentation
7778

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

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

8790
## 🗒️ [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)
8891

8992
## Part of `modern-python`
9093

9194
Browse the full list of templates and libraries in
92-
[`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
95+
[`modern-python`](https://github.com/modern-python); the org profile has the categorized index.

‎SECURITY.md‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
# Security Policy
1+
# Security policy
22

3-
## Reporting a Vulnerability
3+
## Reporting a vulnerability
44

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

7-
**Do not file a public GitHub issue for security reports.**
7+
Do not file a public GitHub issue for security reports.
88

9-
## Disclosure Timeline
9+
## Disclosure timeline
1010

11-
- We commit to acknowledging your report within **7 days**.
12-
- We aim to provide a fix or detailed mitigation plan within **30 days** of confirmation.
13-
- 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).
11+
- We acknowledge reports within 7 days.
12+
- We aim to provide a fix or a detailed mitigation plan within 30 days of confirming the report.
13+
- 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.
1414

15-
## Supported Versions
15+
## Supported versions
1616

1717
Security fixes are provided for:
1818

@@ -31,5 +31,5 @@ In scope:
3131

3232
Out of scope:
3333

34-
- 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.
34+
- 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.
3535
- Misconfiguration in consuming applications.

‎docs/decoders.md‎

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Decoders
22

3-
`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.
3+
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.
44

5-
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).
5+
`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).
66

77
## The protocol
88

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

23-
Two methods, two distinct jobs:
23+
`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`.
2424

25-
- **`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.
26-
- **`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.
25+
`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.
2726

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

3029
## How the client resolves a model
3130

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

34-
- **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.
35-
- **`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()]`.
36-
- **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).
33+
- 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.
34+
- `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()]`.
35+
- 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).
3736

38-
## Decoders are sync — for both clients
37+
## One sync protocol for both clients
3938

40-
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.
39+
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.
4140

4241
## Writing your own
4342

4443
### Worked example: a CSV decoder
4544

46-
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.
45+
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.
4746

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

80-
`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:
79+
`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:
8180

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

104103
### A note on claiming the right models
105104

106-
`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:
105+
`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:
107106

108107
```python
109108
import json
@@ -122,15 +121,15 @@ class CattrsDecoder:
122121
return self._converter.structure(json.loads(content), model)
123122
```
124123

125-
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.
124+
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.
126125

127-
### When NOT to write a decoder
126+
### When not to write a decoder
128127

129-
- **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.
130-
- **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.
131-
- **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.
128+
- The body is JSON. `PydanticDecoder` and `MsgspecDecoder` handle dataclasses, `TypedDict`s, primitives, pydantic models and msgspec `Struct`s. Install `httpware[pydantic]` or `httpware[msgspec]`.
129+
- You want raw bytes or text. Leave out `response_model=` and read `response.content` or `response.text`.
130+
- The shaping depends on the request rather than the type. That belongs in [middleware](middleware.md).
132131

133132
## See also
134133

135-
- **`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.
136-
- **[Quick-Start: typed responses](index.md)** — composing `response_model=` with the default decoder list.
134+
- [`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.
135+
- [Quickstart: typed responses](index.md#typed-responses): `response_model=` with the default decoders.

0 commit comments

Comments
 (0)