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
@@ -4,107 +4,84 @@ This doc is the single distilled reference for `httpware` design rationale, prot
4
4
5
5
## 1. Project intent
6
6
7
-
`httpware` is a Python async HTTP client framework for building resilient service clients. It supersedes `community-of-python/base-client` and ships under the `modern-python` org. The framework owns the abstraction layer above the underlying HTTP client (`httpx2` by default); consumers never import the transport directly.
7
+
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; Pydantic ships as the default, msgspec as an opt-in extra), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx.
8
+
9
+
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.
8
10
9
11
## 2. Architectural invariants (CI-enforced)
10
12
11
13
These are non-negotiable. CI rejects PRs that violate them. The "why" exists so future contributors can judge edge cases instead of blindly following the rule.
12
14
13
-
-**No `httpx2` leakage outside `src/httpware/transports/httpx2.py`.***Why:* the whole point of the framework is to own the abstraction above the underlying client. Any consumer that imports `httpx2` directly defeats the abstraction and pins us to the current transport choice.
14
-
-**No `httpx2` private API.***Why:* private symbols can change between patch releases. We accept the public-API surface as the contract.
15
+
-**No `httpx2._` private API.***Why:* private symbols can change between patch releases. We accept the public-API surface as the contract.
15
16
-**No `from __future__ import annotations`.***Why:* Python 3.11+ floor. PEP 604/585 syntax is native; the future-import would only add noise and inconsistency.
16
17
-**No `print()`.***Why:* ruff-enforced. Libraries log; they do not print to stdout. Stray prints leak into consumer applications.
17
18
-**No global logging config.***Why:*`logging.basicConfig()` from a library mutates the consumer's logging tree. We only acquire `logging.getLogger("httpware")` or namespaced child loggers and let consumers configure handlers.
18
19
-**Type suppressions use `# ty: ignore[<rule>]`.***Why:* this project uses `ty`, not `mypy`. `# type: ignore` is silently accepted by `ty` but ambiguous; `# ty: ignore[<rule>]` is checked and rule-specific.
19
20
20
-
## 3. The five protocol seams
21
+
The 0.1.0 "no `httpx2` leakage outside `transports/httpx2.py`" invariant is **retired in v0.2**. Exposing `httpx2.Request`/`httpx2.Response` is the design.
21
22
22
-
A protocol seam is a documented internal boundary. AI agents and contributors must respect it — never cross a seam except through its protocol.
23
+
## 3. The three protocol seams
23
24
24
-
### Seam 1: `Middleware ↔ Transport`
25
+
A protocol seam is a documented internal boundary. AI agents and contributors must respect it — never cross a seam except through its protocol.
-**Contract:** the chain bottom calls `transport.__call__(request) -> Response`.
28
-
-**Rule:** middleware never instantiates a transport; the `AsyncClient` injects it at construction.
27
+
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 middleware chain is composed at `AsyncClient.__init__` and frozen for the client's lifetime.
34
-
-**Rule:** mutating the chain after construction is not supported. Per-request middleware is expressed via the `Request` extensions field, not by rebuilding the chain.
32
+
-**Contract:** the middleware chain is composed once 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.
33
+
-**Rule:** mutating the chain after construction is not supported. Per-request behavior goes through `httpx2.Request.extensions` or through `extensions=` kwargs at call sites.
-**Contract:** the decoder is invoked when the caller passes `response_model=`. The protocol is `decode(content: bytes, model: type[T]) -> T`.
38
+
-**Contract:** the decoder is invoked when the caller passes `response_model=`. The protocol is `decode(content: bytes, model: type[T]) -> T`. Decoder errors (`pydantic.ValidationError`, `msgspec.ValidationError`) propagate unwrapped.
40
39
-**Rule:** the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (`json.loads` then `validate_python`) is rejected: a single bytes-in / typed-object-out pass avoids the redundant intermediate `dict` allocation and parses faster. The Pydantic adapter implements this as `TypeAdapter(model).validate_json(content)`, with the `TypeAdapter` itself memoized via `@functools.lru_cache(maxsize=1024)` on a module-level `_get_adapter(model)` factory (the adapter is the expensive part to build). The msgspec adapter implements it as `msgspec.json.decode(content, type=model)`.
41
40
42
-
### Seam 4: `Httpx2Transport ↔ httpx2`
43
-
44
-
-**Where:**`src/httpware/transports/httpx2.py` is the only file that may import `httpx2`.
45
-
-**Contract:**`httpx2` exceptions are mapped to `httpware` exceptions at this seam.
46
-
-**Rule:** CI grep checks enforce zero `httpx2` imports outside this file and zero `httpx2._` private references anywhere.
47
-
48
-
### Seam 5: `httpware ↔ optional extras`
41
+
### Seam C: `httpware ↔ optional extras`
49
42
50
43
-**Where:**`pyproject.toml` extras (`[project.optional-dependencies]`) ↔ the adapter modules that import them.
51
-
-**Contract:** each optional dependency is imported only inside its own dedicated module (e.g., `pydantic` in `decoders/pydantic.py`; `msgspec` in `decoders/msgspec.py` when 1-6 lands; `opentelemetry` in `middleware/observability/otel.py` when 5-4 lands).
44
+
-**Contract:** each optional dependency is imported only inside its own dedicated module (e.g., `pydantic` in `decoders/pydantic.py`; `msgspec` in `decoders/msgspec.py`; `opentelemetry` in `middleware/observability/otel.py` when Epic 5 lands).
52
45
-**Rule:** never import an extra at package top-level. The package must import cleanly when the extra is not installed.
53
46
54
47
## 4. Exception contract
55
48
56
-
All `httpware` HTTP exceptions are constructed with **keyword arguments only**. The mandatory fields on every `StatusError` (and its 4xx/5xx subclasses) are:
49
+
`StatusError` and all its 4xx/5xx subclasses are constructed with a **single positional `response: httpx2.Response`**. Subclasses do not override `__init__`. All fields are available via `exc.response.*` (status code, headers, content, request, etc.).
|`request_url`|`str`| request URL, may include userinfo (Redactor sanitizes — Story 5.3) |
51
+
```python
52
+
raise NotFoundError(response) # correct
53
+
exc.response.status_code # 404
54
+
exc.response.request.url # URL of the failed request
55
+
```
56
+
57
+
`__repr__` and the `str()` summary strip `user:pass@` userinfo from `response.request.url` to avoid leaking credentials in tracebacks. Query-string secrets are not stripped here.
66
58
67
-
The mapping table from`httpx2`errors to `httpware`errors lives at Seam 4 (`src/httpware/transports/httpx2.py`). Status-keyed exceptions are looked up via the `STATUS_TO_EXCEPTION` table in `src/httpware/errors.py`.
59
+
The error-mapping table (what`httpx2`exception maps to which `httpware`exception) lives at the `AsyncClient` terminal in `src/httpware/client.py`. Status-keyed exceptions are looked up via the `STATUS_TO_EXCEPTION` table in `src/httpware/errors.py`. Unknown 4xx falls back to `ClientStatusError`; unknown 5xx falls back to `ServerStatusError`.
68
60
69
-
Constructing any of these exceptions positionally is a programming error caught by `ty`. The keyword-only signature is enforced via `__init__` definitions, not docstrings.
61
+
`TimeoutError` inherits from both `httpware.ClientError` and `builtins.TimeoutError` so `except builtins.TimeoutError` (the form `asyncio.wait_for` uses) also catches httpware-raised timeouts.
70
62
71
63
## 5. Module layout
72
64
73
-
Current tree (post-story-1.5):
65
+
Current tree (v0.2):
74
66
75
67
```text
76
68
src/httpware/
77
-
├── __init__.py # public exports + __all__
69
+
├── __init__.py # public exports
78
70
├── py.typed
79
-
├── config.py # Limits, Timeout, ClientConfig
80
-
├── request.py # Request + with_* helpers
81
-
├── response.py # Response (StreamResponse pending in Epic 4)
82
-
├── errors.py # status-keyed exception hierarchy
71
+
├── client.py # AsyncClient
72
+
├── errors.py # status-keyed exception tree (response: httpx2.Response)
73
+
├── middleware/
74
+
│ ├── __init__.py # Middleware protocol, Next type, @before_request/@after_response/@on_error
75
+
│ └── chain.py # compose(middleware, terminal) -> Next
**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.
108
85
109
86
## 6. Testing patterns
110
87
@@ -131,52 +108,24 @@ Caller-facing pattern: consumers select the implementation by passing it explici
131
108
132
109
## 8. Remaining roadmap
133
110
134
-
Twenty-seven stories remain. Topic slugs in `planning/specs/` and `planning/plans/` use kebab-case descriptions, not the story IDs — these IDs are retained as a stable identifier convention from the original epic structure.
135
-
136
-
### Epic 1 — Make typed HTTP requests with sensible defaults
137
-
138
-
-**1-6**`msgspec` decoder via extras — second `ResponseDecoder` adapter, opt-in.
139
-
-**1-7**`AsyncClient` with HTTP methods, `response_model`, `with_options`, lifecycle — the main public surface.
140
-
-**1-8**`RecordedTransport` for testing — ships with the library; replaces `respx` for transport-level tests.
141
-
142
-
### Epic 2 — Compose request-handling logic via middleware
-**5-2** Wire emission into resilience middlewares.
169
-
-**5-3**`Redactor` class and integration (closes deferred work on URL/header/userinfo sanitization).
170
-
-**5-4** OpenTelemetry middleware via the `otel` extra.
171
-
-**5-5** Logging policy enforcement (CI grep on `logging.basicConfig`, `logging.getLogger()` without a name).
119
+
`1-7``AsyncClient` (the heart of v0.2 — shipped in the pivot PR), `2-5` wire middleware into `AsyncClient` (trivially part of `1-7`), `6-1` migration guide (extended with httpware 0.1→0.2 notes), `6-4` CI invariant gates (drop the "no `httpx2` leakage" rule).
172
120
173
-
### Epic 6 — Ship v1.0
121
+
### Surviving (land in subsequent PRs)
174
122
175
-
-**6-1** Migration guide from `base-client`.
176
-
-**6-2** Documentation site (`mkdocs`).
177
-
-**6-3** Public benchmark suite.
178
-
-**6-4** CI enforcement gates (codify the invariants in Section 2 as CI jobs).
179
-
-**6-5** Release flow with Trusted Publishers + Sigstore.
-**Carry-forward decoder:**`1-6` msgspec decoder via extras — second `ResponseDecoder` adapter, already implemented; verified surviving in the pivot.
128
+
-**Middleware protocol:**`2-1` and `2-2` already implemented in the pivot (protocol, chain, phase decorators).
180
129
181
130
When work starts on a roadmap item, it gets a spec at `planning/specs/YYYY-MM-DD-<topic>-design.md` and a plan at `planning/plans/YYYY-MM-DD-<topic>-plan.md`.
0 commit comments