From d5ab21323282d28de2f5f16f9f8da1fe3494dd1a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 14 May 2026 08:43:42 +0300 Subject: [PATCH] feat(story-1.4): transport protocol and Httpx2Transport adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Transport protocol (Seam 1, middleware ↔ transport) and the default Httpx2Transport implementation — the single seam where httpx2 is imported. Adds a minimal StreamResponse placeholder so the protocol's stream() signature can be typed today and fleshed out in Story 4.1. - src/httpware/transports/__init__.py: @runtime_checkable Transport Protocol with __call__ / stream / aclose - src/httpware/transports/httpx2.py: Httpx2Transport adapter; lazy AsyncClient init guarded by asyncio.Lock; httpx2.{TimeoutException, HTTPError, InvalidURL, CookieConflict} and the closed-client RuntimeError mapped to httpware.{TimeoutError, TransportError} with the original message preserved on the mapped instance - src/httpware/response.py: StreamResponse placeholder (status, headers, url) re-exported from httpware/__init__.py - tests/test_no_httpx2_leakage.py: CI invariant — only transports/httpx2.py may import httpx2; cwd-robust pathing - tests/test_transports_httpx2.py: 63 unit tests covering protocol membership, success / 4xx / 5xx / exception-mapping / lazy-init / concurrent-init / post-close paths - docs/stories/1-4-...: story spec + Review Findings with 3-layer adversarial review outcome - docs/deferred-work.md: 5 review-time deferrals + extended case-insensitive-headers entry to cover multi-value collapse - CHANGELOG.md: Unreleased / Added entry Full suite 140 passing, 100% coverage on every source module, lint green (ruff + ty), httpx2 leakage invariant holds. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + docs/deferred-work.md | 18 +- ...rt-protocol-and-httpx2transport-adapter.md | 471 ++++++++++++++++++ src/httpware/__init__.py | 7 +- src/httpware/response.py | 9 + src/httpware/transports/__init__.py | 27 + src/httpware/transports/httpx2.py | 180 +++++++ tests/test_no_httpx2_leakage.py | 21 + tests/test_transports_httpx2.py | 459 +++++++++++++++++ 9 files changed, 1189 insertions(+), 4 deletions(-) create mode 100644 docs/stories/1-4-transport-protocol-and-httpx2transport-adapter.md create mode 100644 src/httpware/transports/__init__.py create mode 100644 src/httpware/transports/httpx2.py create mode 100644 tests/test_no_httpx2_leakage.py create mode 100644 tests/test_transports_httpx2.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ae6b99..f0d897a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,5 +17,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `CLAUDE.md` with AI-agent guidance. - Core data types: `Request`, `Response`, `Limits`, `Timeout`, `ClientConfig` — frozen+slotted dataclasses with `with_*` immutability helpers on `Request` and computed `text`/`json()` accessors on `Response` (Story 1.2). - Status-keyed exception hierarchy with plain typed fields: `ClientError`, `TransportError`, `TimeoutError`, `StatusError`, `ClientStatusError`/`ServerStatusError` bases, 9 leaf classes (`BadRequestError` … `ServiceUnavailableError`), `STATUS_TO_EXCEPTION` lookup dict (Story 1.3). `StatusError` is picklable and deep-copyable via custom `__reduce__`; `__repr__` and the summary message strip `user:pass@` userinfo from the request URL; `headers` is stored as a read-only `MappingProxyType` so caller mutations after `raise` do not bleed into the exception. `TimeoutError` multi-inherits from `builtins.TimeoutError` (revisits architecture Decision 3) so `except builtins.TimeoutError` (the form `asyncio.wait_for` raises) also catches httpware-raised timeouts. +- `Transport` protocol (`@runtime_checkable`) and default `Httpx2Transport` adapter; `StreamResponse` placeholder for Story 4.1 protocol typing; the wire `method` is uppercased at the seam and `httpx2` exceptions (`TimeoutException`, `HTTPError`, `InvalidURL`, `CookieConflict`, and the closed-client `RuntimeError`) are mapped to `httpware.TimeoutError` / `httpware.TransportError` (with the original exception's message preserved on the mapped instance) so no `httpx2` exception escapes the library; lazy `httpx2.AsyncClient` construction is guarded by an `asyncio.Lock` so concurrent first-calls share one client; `httpx2` is confined to `src/httpware/transports/httpx2.py` (Story 1.4). [Unreleased]: https://github.com/modern-python/httpware/commits/main diff --git a/docs/deferred-work.md b/docs/deferred-work.md index 68edf43..37b6684 100644 --- a/docs/deferred-work.md +++ b/docs/deferred-work.md @@ -2,11 +2,23 @@ Items raised in reviews that are real but not actionable now. +## Deferred from: code review of story-1-4 (2026-05-14) + +- **Unbounded error body size** — `StatusError.body` holds the full `resp.content` with no cap; large 5xx pages stay pinned in memory through exception lifetimes (Sentry payloads, logs, retained tracebacks). Revisit with retry/observability middleware. (`src/httpware/transports/httpx2.py:117-124`) +- **`httpx2.StreamError` family escape** — `StreamError` and its children (`StreamConsumed`, `StreamClosed`, `ResponseNotRead`, `RequestNotRead`) are `RuntimeError` subclasses, not `HTTPError`; not caught by the seam's `except httpx2.HTTPError`. Unreachable via the default httpx2 config (no redirects, no retries) but exploitable through user-supplied clients with retry layers. Revisit when retry middleware lands. (`src/httpware/transports/httpx2.py:103-108`) +- **Header CRLF / log-injection** — extends the existing URL CRLF deferral. `dict(request.headers)` forwards values verbatim including embedded `\r\n`; full sanitization lands with the `Redactor` middleware (Story 5.3). (`src/httpware/transports/httpx2.py:94`) +- **Userinfo on `StatusError.request_url` raw field** — `__repr__` and the exception summary strip `user:pass@`, but the field itself retains credentials, leaking through structured-logging serializers. Defense-in-depth strip is the Redactor's job (Story 5.3) per `errors.py` docstring. (`src/httpware/transports/httpx2.py:123`) +- **Concurrent `aclose()` ↔ `__call__` races** — no synchronization between in-flight `client.send` and a parallel `aclose`. Best case raises `RuntimeError`; worst case completes on a partly-disposed pool. Broader concurrency / lifecycle design; defer to Story 1.7 or retry stories. (`src/httpware/transports/httpx2.py:87-145`) + +## Deferred from: code review of story-1-4 (2026-05-13) + +- **URL CRLF / log-injection** — relying on httpx2's `InvalidURL` validation; explicit `Redactor`-level sanitization deferred to Story 5.3. +- **`request.method` validation beyond uppercasing** — httpx2 surfaces `LocalProtocolError` for malformed methods; no further mitigation in `transports/httpx2.py`. +- **Case-insensitive header type + multi-valued header collapse** — `Mapping[str, str]` with lowercase ASCII keys is the v0 contract. Two limitations bundled: (a) case-insensitive lookup unavailable; (b) `dict(resp.headers)` collapses duplicate-key headers like `Set-Cookie`, `Via`, `Link` to the last value only. Revisit together when real header-handling middleware (Story 2.3 era) demands either capability — the contract widens to `Mapping[str, Sequence[str]]` (or similar) at that point. + ## Deferred from: code review of story-1-3 (2026-05-13) -- **`request_method` / `request_url` CRLF / log-injection** — fields stored verbatim; `repr` echoes embedded `\r\n`. Transport seam should validate before crafting the request. (`src/httpware/errors.py:55,58`) — revisit in Story 1.4. -- **Header case-folding contract** — `Mapping[str, str]` doesn't pin case-sensitivity. `exc.headers["Content-Type"]` may `KeyError` if `httpx2.Headers` hands in lowercased keys. (`src/httpware/errors.py:33`) — Story 1.4 transport seam decides. -- **`request_method` casing normalization** — `repr` faithfully echoes `"get"` / `"Get"` if middleware lowercases. Document expected casing or normalize at the seam. (`src/httpware/errors.py:35`) — Story 1.4. +Resolved by Story 1.4: method uppercased at seam; httpx2 returns lowercased headers (v0 contract); CRLF mitigation via `httpx2.InvalidURL`. ## Deferred from: code review of story-1-2 (2026-05-13) diff --git a/docs/stories/1-4-transport-protocol-and-httpx2transport-adapter.md b/docs/stories/1-4-transport-protocol-and-httpx2transport-adapter.md new file mode 100644 index 0000000..d2ed0d7 --- /dev/null +++ b/docs/stories/1-4-transport-protocol-and-httpx2transport-adapter.md @@ -0,0 +1,471 @@ +--- +story_key: 1-4-transport-protocol-and-httpx2transport-adapter +epic: 1 +story: 4 +title: Transport protocol and Httpx2Transport adapter +status: review +created: 2026-05-13 +input_documents: + - docs/prd.md + - docs/architecture.md + - docs/epics.md + - docs/stories/1-2-core-data-types.md + - docs/stories/1-3-exception-hierarchy-with-plain-fields.md + - docs/deferred-work.md +--- + +# Story 1.4: Transport protocol and Httpx2Transport adapter + +## Story + +**As a** library author, +**I want** a `Transport` protocol and a default `Httpx2Transport` implementation, +**So that** the entire library talks to one abstraction and `httpx2` is confined to a single file. + +## Acceptance Criteria + +**AC1.** **Given** the data types (Story 1.2) and the exception hierarchy (Story 1.3), **When** I implement `src/httpware/transports/__init__.py`, **Then** the module defines a `Transport` class decorated with `@runtime_checkable` and inheriting `Protocol` (from `typing`), with **exactly** these three method signatures and no other public attributes: + +```python +async def __call__(self, request: Request) -> Response: ... +def stream(self, request: Request) -> AbstractAsyncContextManager[StreamResponse]: ... +async def aclose(self) -> None: ... +``` + +`AbstractAsyncContextManager` is imported from `collections.abc`. The module's `__all__` is `["Transport"]`. The module docstring is one short line describing the protocol's role as the middleware↔transport seam (Seam 1 in the architecture). + +**AC2.** **And** the `StreamResponse` type referenced in `Transport.stream`'s signature is added to `src/httpware/response.py` as a **minimal stub** to unblock typing: a `@dataclass(frozen=True, slots=True)` class with **exactly three public fields** — `status: int`, `headers: Mapping[str, str]`, `url: str` — and no methods. A one-line docstring identifies it as a placeholder for Story 4.1 (full streaming machinery — `_stream`, `_release`, `iter_bytes`, `iter_text`, `iter_lines` — arrives in Epic 4). `StreamResponse` is re-exported from `httpware/__init__.py` and added to `__all__`. + +**AC3.** **And** I implement `src/httpware/transports/httpx2.py` with a class `Httpx2Transport` whose `__init__` is keyword-only and accepts exactly these parameters (each with the default shown): + +```python +def __init__( + self, + *, + client: httpx2.AsyncClient | None = None, + limits: Limits | None = None, + timeout: Timeout | None = None, +) -> None: ... +``` + +If `client` is supplied, `limits` and `timeout` are ignored (and a `ValueError` is raised if either is non-None alongside `client`, so the precedence is explicit, not silent). If `client` is `None`, a default `httpx2.AsyncClient` is constructed **lazily on first use** (event-loop binding — architecture line 749) with `httpx2.Limits(...)` and `httpx2.Timeout(...)` translated from the supplied `Limits` / `Timeout` (or their dataclass defaults if both are `None`). The constructed client is stored on a private `_client` attribute (typed `httpx2.AsyncClient | None`). Story 1.4 uses a "close everything in `aclose()`" policy: the transport closes whatever `_client` references regardless of who created it; the ownership-respecting variant lands in Story 1.7 (architecture Decision 9). Lazy init is guarded by a private `_init_lock: asyncio.Lock` so concurrent first-calls share one client. + +**AC4.** **And** `Httpx2Transport.__call__(request)` translates `Request` → `httpx2.Request` with this mapping: + +| httpware `Request` field | httpx2 `Request` argument | +|---|---| +| `method` | `method` (passed through; **uppercased at the seam** — see AC10) | +| `url` | `url` (passed through unmodified) | +| `headers` | `headers` (passed through as a `dict(request.headers)` copy) | +| `params` | `params` (passed through as a `dict(request.params)` copy) | +| `cookies` | `cookies` (passed through as a `dict(request.cookies)` copy) | +| `body` | `content=` (httpx2's bytes-input argument; passed through as-is, including `None`) | +| `extensions` | `extensions` (passed through as a `dict(request.extensions)` copy if non-empty, else `None`) | + +Then awaits `await client.send(httpx2_request)` (no `stream=`, no `auth=`, no `follow_redirects=` overrides — those land in later stories). The implementation **measures elapsed wall-clock seconds itself** using `time.monotonic()` deltas around the `send` call (rationale: `httpx2.Response.elapsed` is unreliable in `MockTransport` test paths and tied to the response-close lifecycle; portable measurement at the seam is cleaner and matches the `Response.elapsed` contract from Story 1.2). + +**AC5.** **And** on success (response received), `__call__` translates `httpx2.Response` → `httpware.Response` with this mapping (and **does not** call `resp.aclose()` explicitly — `client.send` with the default `stream=False` already buffers `resp.content` into memory): + +```python +Response( + status=resp.status_code, + headers=dict(resp.headers), # httpx2 returns lowercased keys (see AC11) + content=resp.content, + url=str(resp.url), + elapsed=monotonic_elapsed, # measured at the seam, NOT resp.elapsed +) +``` + +**AC6.** **And** if `resp.status_code` is **not in `range(200, 400)`** (i.e., 4xx or 5xx — 1xx and 3xx are treated as successful responses and returned to the caller; see Open Questions for the 3xx rationale), `__call__` raises a `StatusError` subclass instead of returning. The class is resolved via the architecture's documented idiom (architecture line 581): + +```python +exc_class = STATUS_TO_EXCEPTION.get( + resp.status_code, + ClientStatusError if resp.status_code < 500 else ServerStatusError, +) +raise exc_class( + status=resp.status_code, + body=resp.content, + headers=dict(resp.headers), + json=_try_decode_json(resp), + request_method=request.method, # the httpware Request method, uppercased + request_url=request.url, # the httpware Request URL (unredacted; errors.py redacts in __repr__) +) +``` + +A private module-level helper `_try_decode_json(resp: httpx2.Response) -> Any | None` attempts to parse `resp.content` as JSON when the response's `content-type` (case-insensitively located) starts with `application/json`; on success returns the decoded value, on `json.JSONDecodeError` or any non-JSON content type returns `None`. The helper never raises and never inspects body bytes if the content type is wrong. + +**AC7.** **And** `__call__` translates `httpx2` exceptions to `httpware` exceptions at the seam — **no `httpx2` exception is allowed to escape**. The mapping is implemented as exactly three `except` clauses on the `await client.send(...)` call, in this order: + +```python +try: + httpx2_resp = await self._get_client().send(httpx2_req) +except httpx2.TimeoutException as exc: + raise TimeoutError() from exc +except httpx2.HTTPError as exc: + raise TransportError() from exc +except httpx2.InvalidURL as exc: + raise TransportError() from exc +``` + +Rationale & coverage: + +- **Order matters.** `httpx2.TimeoutException` is a subclass of `httpx2.HTTPError` (via `TransportError → RequestError → HTTPError`); catching it first ensures all 4 timeout leaves (`ConnectTimeout`, `ReadTimeout`, `WriteTimeout`, `PoolTimeout`) map to `httpware.TimeoutError`. +- **`httpx2.HTTPError` covers the remaining 8 entries from the architecture's table:** `ConnectError`, `NetworkError`, `ProxyError`, `UnsupportedProtocol`, `ProtocolError`, `RemoteProtocolError`, `LocalProtocolError`, `DecodingError`, plus `TooManyRedirects` and `httpx2.CloseError`/`ReadError`/`WriteError`/`StreamError` — all `HTTPError` descendants. +- **`httpx2.InvalidURL` is a direct `Exception` subclass** in httpx2 — **NOT** under `HTTPError` — so it requires its own except clause. The architecture's mapping table (line 218) listed it under `TransportError` but did not flag the inheritance gap; this story closes it. +- All three clauses use `raise ... from exc` so the original `httpx2` exception remains in `__cause__` for debugging without becoming part of the `httpware` public surface. +- `TimeoutError()` and `TransportError()` are constructed **without** the 6 status fields — those fields belong to `StatusError`. Story 1.3 AC6 made the bare-class shape explicit ("constructed at the `Httpx2Transport` seam in Story 1.4; field requirements for those two are deferred to that story's mapping work"); this story confirms the shape (no fields beyond `args`). + +**AC8.** **And** `Httpx2Transport.stream(request)` exists with the protocol's signature — `def stream(self, request: Request) -> AbstractAsyncContextManager[StreamResponse]` — and its body raises `NotImplementedError("Streaming arrives in Epic 4 (Story 4.1).")` synchronously (i.e., the `NotImplementedError` is raised when `stream(...)` is *called*, before any `async with`). The method is annotated to return `AbstractAsyncContextManager[StreamResponse]` even though it always raises; this keeps the `Transport` protocol's `isinstance` check passing. + +**AC9.** **And** `Httpx2Transport.aclose()` is idempotent and safe whether or not the client was lazily constructed: + +- If `_client` is `None` (lazy default never used): does nothing. +- If `_client` is set and `_owned` is True (default constructed by us): calls `await self._client.aclose()`, then sets `self._client = None`. +- If `_client` is set and `_owned` is False (user supplied via `client=`): calls `await self._client.aclose()` (the architecture's later ref-counting in Story 1.7 governs whether `AsyncClient.__aexit__` calls this at all; at the transport layer we close what we have). +- After `aclose()`, subsequent calls to `aclose()` are no-ops (idempotency); subsequent calls to `__call__` or `stream()` raise `TransportError` (the client is gone — re-entering would silently reconstruct, which is a footgun). + +**AC10.** **And** the **request_method casing** deferred from Story 1.3 is resolved at this seam: `Httpx2Transport.__call__` uppercases `request.method` before passing it to `httpx2.Request(method=...)` AND before storing it on any raised `StatusError`'s `request_method` field. `httpware.Request.method` itself is **NOT** mutated — it remains whatever the caller stored (immutability of `Request` is a Story 1.2 invariant). The uppercase normalization lives only in the seam path. One unit test asserts that `request.method = "get"` produces `exc.request_method == "GET"` on a 404 response. + +**AC11.** **And** the **header case-folding contract** deferred from Story 1.3 is resolved at this seam: `httpx2.Response.headers` returns lowercased keys (`{"content-type": "...", "content-length": "..."}`); `dict(resp.headers)` preserves that lowercasing. The `Response.headers` and `StatusError.headers` produced by this transport therefore use **lowercase ASCII** keys. This story does **not** add a case-insensitive header type (deferred to Story 2.3 or whoever lands header-handling middleware) — `Mapping[str, str]` with lowercase keys is the v0 contract. Documented in the `Httpx2Transport` class docstring. + +**AC12.** **And** the **CRLF / log-injection** concern deferred from Story 1.3 is partially mitigated by httpx2's own URL validation: `httpx2.Request(url="http://x.com/\r\nInjected: yes")` raises `httpx2.InvalidURL` before reaching the wire. We rely on httpx2 here; we do **NOT** add validation in `transports/httpx2.py`. The `request.method` value is uppercased (AC10) and httpx2 validates it ("Invalid HTTP method" `LocalProtocolError`); no further mitigation in this story. This is added to `docs/deferred-work.md` as "method-/URL-validation centralization" for the future `Redactor`/validation story. + +**AC13.** **And** the CI-enforced invariant from the architecture is met: `grep -rE 'import httpx2|from httpx2' src/httpware/` returns matches **only** in `src/httpware/transports/httpx2.py`. No other module — including `transports/__init__.py` — imports `httpx2`. A test in `tests/test_no_httpx2_leakage.py` runs this grep against the source tree and asserts the only match path is `transports/httpx2.py`. + +**AC14.** **And** `src/httpware/__init__.py` re-exports `Transport`, `Httpx2Transport`, and `StreamResponse` via explicit absolute-path imports, and `__all__` is updated to the final 24-entry list (sorted by `ruff` RUF022 — ALL-CAPS first, then mixed-case classes alphabetically). The full expected list is in Dev Notes → Public API Surface. `from httpware import Transport, Httpx2Transport, StreamResponse` succeeds. + +**AC15.** **And** `uv run ty check`, `uv run ruff format --check`, and `uv run ruff check` all pass with zero diagnostics on the new modules and on the modified `__init__.py` / `response.py`. + +**AC16.** **And** unit tests under `tests/test_transports_httpx2.py` cover, at minimum (use `httpx2.MockTransport` to inject responses and exceptions — pass it via `Httpx2Transport(client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)))`): + +- (a) **Protocol membership:** `isinstance(Httpx2Transport(), Transport) is True` (runtime_checkable check). +- (b) **Success path 200:** issues a GET, returns a `httpware.Response` with the expected status, content, lowercased headers, url, and a non-negative `elapsed`. +- (c) **Status-code mapping — parametrized over 200, 400, 401, 403, 404, 409, 422, 429, 500, 503:** asserts that 200 returns a `Response`; the others raise the precise leaf class (e.g., 404 → `NotFoundError`, 503 → `ServiceUnavailableError`). For each error case, asserts `exc.status == code`, `exc.request_method == "GET"`, and `exc.request_url == `. +- (d) **Unknown-status fallback:** 418 raises `ClientStatusError` (not any leaf); 504 raises `ServerStatusError` (not any leaf). Confirms the architecture's fallback idiom. +- (e) **`_try_decode_json` hits all branches:** JSON content type with valid JSON sets `exc.json` to the decoded value; non-JSON content type sets `exc.json` to `None`; malformed JSON in a JSON-typed response sets `exc.json` to `None` (helper swallows `JSONDecodeError`). +- (f) **Exception mapping — `httpx2.TimeoutException` family:** parametrized over `ConnectTimeout`, `ReadTimeout`, `WriteTimeout`, `PoolTimeout` — each raises `httpware.TimeoutError` from the seam; assert `type(exc) is TimeoutError`, `exc.__cause__` is the original `httpx2.`. +- (g) **Exception mapping — `httpx2.HTTPError` family (representative):** parametrized over `ConnectError`, `NetworkError`, `ProxyError`, `UnsupportedProtocol`, `LocalProtocolError`, `RemoteProtocolError`, `DecodingError`, `TooManyRedirects` — each raises `httpware.TransportError` from the seam; assert `type(exc) is TransportError`, `exc.__cause__` is the original httpx2 exception. +- (h) **Exception mapping — `httpx2.InvalidURL`:** raises `httpware.TransportError`; assert chain via `__cause__`. (Verifies the orphan-class case that's not under `HTTPError`.) +- (i) **No `httpx2` exception escapes:** parametrized over the union of (f)+(g)+(h) — asserts `pytest.raises((TimeoutError, TransportError))` catches every case; no `pytest.raises(httpx2.HTTPError)` test ever fires because the seam catches them all. +- (j) **Method casing normalization:** `Httpx2Transport.__call__(Request(method="get", url=...))` against a 404 mock produces `exc.request_method == "GET"` (uppercased at the seam). +- (k) **`stream()` raises `NotImplementedError` synchronously:** `transport.stream(req)` (without `async with`) raises `NotImplementedError`. +- (l) **`aclose()` is idempotent:** `await transport.aclose(); await transport.aclose()` does not raise; `_client` is `None` afterwards. +- (m) **`aclose()` on a never-used transport** (lazy client never constructed): `await Httpx2Transport().aclose()` is a no-op (does not raise). +- (n) **Post-close call raises:** after `await transport.aclose()`, `await transport(req)` raises `TransportError`. +- (o) **Lazy event-loop binding:** `Httpx2Transport()` constructed outside an event loop does not raise; the underlying `httpx2.AsyncClient` is created on first `__call__` (assert via `transport._client is None` before, non-None after). +- (p) **Constructor argument conflict:** `Httpx2Transport(client=..., limits=Limits())` raises `ValueError`; `Httpx2Transport(client=..., timeout=Timeout())` raises `ValueError`. +- (q) **No-leakage CI grep test** (`tests/test_no_httpx2_leakage.py`): walks `src/httpware/`, asserts that `import httpx2` / `from httpx2` appears **only** in `src/httpware/transports/httpx2.py`. + +`uv run pytest` exits 0; coverage on `src/httpware/transports/httpx2.py` and `src/httpware/transports/__init__.py` is **100%** (the modules are small and every branch is reachable from these tests). + +## Tasks/Subtasks + +- [x] **Task 1: Add minimal `StreamResponse` stub to `src/httpware/response.py`** (AC2) + - [x] 1.1: Append a `@dataclass(frozen=True, slots=True)` class `StreamResponse` after the existing `Response` class. Fields: `status: int`, `headers: Mapping[str, str]`, `url: str`. No methods. One-line docstring: `"""Placeholder for the streaming response type — fleshed out in Story 4.1."""`. + - [x] 1.2: Do **not** modify `Response` or its helpers. Do **not** add `_stream` / `_release` private fields yet (they're Story 4.1's contract surface). + +- [x] **Task 2: Create `src/httpware/transports/__init__.py`** (AC1) + - [x] 2.1: Module docstring: `"""Transport protocol — the middleware ↔ transport seam (Seam 1)."""`. + - [x] 2.2: Imports: `from contextlib import AbstractAsyncContextManager` (deviation from spec — see Completion Notes; spec said `collections.abc` but the symbol is not exported there on Python 3.11/3.12), `from typing import Protocol, runtime_checkable`, `from httpware.request import Request`, `from httpware.response import Response, StreamResponse`. No `httpx2` import — this module is the protocol, not the implementation. + - [x] 2.3: Define `@runtime_checkable` `class Transport(Protocol):` with the three methods exactly as specified in AC1. Method bodies are `...` (Protocol convention). Each method has a one-line docstring. + - [x] 2.4: `__all__ = ["Transport"]`. + +- [x] **Task 3: Create `src/httpware/transports/httpx2.py` — module setup** (AC3) + - [x] 3.1: Module docstring: short line plus second paragraph documenting the lowercase-headers + uppercase-method contracts (AC10, AC11). + - [x] 3.2: Imports: `dataclasses`, `json`, `time`, `contextlib.AbstractAsyncContextManager` (same deviation as Task 2.2), `typing.Any`, `httpx2`, plus the `httpware` config/errors/request/response symbols. `Httpx2Transport` does not import `Transport` — structural subtyping handles it. + - [x] 3.3: `# noqa: A004` applied to the `TimeoutError` line in the multi-line `from httpware.errors import (...)` block. + - [x] 3.4: Only `import httpx2` is used — no `from httpx2 import ...` lines. + +- [x] **Task 4: Implement `Httpx2Transport.__init__` + lazy client construction** (AC3) + - [x] 4.1: Class signature: `class Httpx2Transport:` (no protocol inheritance). + - [x] 4.2: `__init__` kwargs-only via bare `*`. `_client`, `_owned`, `_limits`, `_timeout` set; `ValueError` raised on `client` + (`limits` or `timeout`). + - [x] 4.3: `_closed: bool = False` initialised. + - [x] 4.4: `_get_client()` raises `TransportError` when `_closed`; otherwise lazily constructs `httpx2.AsyncClient(limits=..., timeout=...)` from `Limits()` / `Timeout()` defaults. + +- [x] **Task 5: Implement `Httpx2Transport.__call__` — request translation + send + timing** (AC4, AC5, AC10, AC12) + - [x] 5.1: Signature: `async def __call__(self, request: Request) -> Response`. + - [x] 5.2: `method = request.method.upper()`; `request` is not mutated. + - [x] 5.3: `httpx2.Request(...)` built with defensive `dict(...)` copies; `content=request.body`; `extensions=` only set when non-empty. + - [x] 5.4: `time.monotonic()` measurement straddles `client.send`; passed to `Response`. + - [x] 5.5: Three-clause `except` (`TimeoutException` → `TimeoutError`, `HTTPError` → `TransportError`, `InvalidURL` → `TransportError`), all `from exc`. + - [x] 5.6: `400 <= status < 600` branch uses `STATUS_TO_EXCEPTION.get(status, ClientStatusError if status < 500 else ServerStatusError)`; uppercased `method` stored on `request_method`; `request.url` passed verbatim. + - [x] 5.7: Non-error statuses build and return `Response(status, headers=dict(...), content, url=str(resp.url), elapsed=elapsed)`. + +- [x] **Task 6: Implement `_try_decode_json` helper** (AC6) + - [x] 6.1: Module-level private function with `# noqa: ANN401` on `Any | None` return. + - [x] 6.2: Case-insensitive `content-type` lookup; `lstrip().lower().startswith("application/json")` gate. + - [x] 6.3: `try: json.loads(resp.content)` / `except json.JSONDecodeError: return None` — no broader catch. + - [x] 6.4: Empty-body short-circuit returns `None` before `json.loads`. + +- [x] **Task 7: Implement `Httpx2Transport.stream`** (AC8) + - [x] 7.1: Sync `def stream(self, request: Request) -> AbstractAsyncContextManager[StreamResponse]`. + - [x] 7.2: Body raises `NotImplementedError("Streaming arrives in Epic 4 (Story 4.1).")` synchronously (ruff did not flag the unused parameter so no `# noqa: ARG002` was needed). + +- [x] **Task 8: Implement `Httpx2Transport.aclose`** (AC9) + - [x] 8.1: `async def aclose(self) -> None`; returns immediately if already closed. + - [x] 8.2: Awaits `self._client.aclose()` when present, sets `_client = None`, sets `_closed = True`. + - [x] 8.3: No try/except wrapper around `aclose()`. + +- [x] **Task 9: Update `src/httpware/__init__.py`** (AC14) + - [x] 9.1: `from httpware.response import Response, StreamResponse` extended in place. + - [x] 9.2: `from httpware.transports import Transport` added. + - [x] 9.3: `from httpware.transports.httpx2 import Httpx2Transport` added. + - [x] 9.4: `__all__` expanded to 24 entries; `ruff check` reports zero diagnostics. + +- [x] **Task 10: Write `tests/test_transports_httpx2.py`** (AC16) + - [x] 10.1: Imports cover the public symbols under test; `TimeoutError` uses `# noqa: A004`. + - [x] 10.2: `_make_transport(handler)` helper builds an `Httpx2Transport` wrapping an `httpx2.AsyncClient(transport=httpx2.MockTransport(handler))`. + - [x] 10.3: Cases (a)–(p) implemented with `pytest.mark.parametrize` over the status leaves, 4 timeout classes, and 8 HTTPError descendants. + - [x] 10.4: Case (i) parametrizes over all mapped httpx2 exceptions and asserts `not isinstance(info.value, httpx2.HTTPError)`. + - [x] 10.5: Case (o) tests pre-call `_client is None`, post-call (with MockTransport) `_client is not None`, and a separate test invokes `_get_client()` directly with `Limits()/Timeout()` to exercise lazy construction without a network call. + - [x] 10.6: Case (q) lives in `tests/test_no_httpx2_leakage.py`, parametrized over every `.py` file under `src/httpware/`, asserts the only file that imports `httpx2` is `src/httpware/transports/httpx2.py`. + +- [x] **Task 11: Update `docs/deferred-work.md`** (AC12 — partial) + - [x] 11.1: New "Deferred from: code review of story-1-4 (2026-05-13)" section added with the 3 carryover items (URL CRLF / log-injection deferred to Story 5.3, `request.method` validation, case-insensitive header type). + - [x] 11.2: Story-1-3 section replaced with the one-line "Resolved by Story 1.4" summary. + +- [x] **Task 12: Validate locally + update CHANGELOG** (AC13, AC15, DoD) + - [x] 12.1: `just lint` passes (eof-fixer, ruff format, ruff check --fix, ty check) — zero diagnostics. + - [x] 12.2: `just test` passes (132 tests); coverage 100% on `src/httpware/transports/__init__.py` and `src/httpware/transports/httpx2.py`; no regressions on prior tests. + - [x] 12.3: `grep -rE 'import httpx2|from httpx2' src/httpware/` returns exactly `src/httpware//transports/httpx2.py:import httpx2`. + - [x] 12.4: `uv run python -c "from httpware import Transport, Httpx2Transport, StreamResponse; print('ok')"` → `ok`. + - [x] 12.5: `CHANGELOG.md` `Unreleased` → `Added` appended with one Story-1.4 bullet; prior entries untouched. + - [x] 12.6: Front-matter `status:` and trailing `## Status` set to `review`; File List updated below. + +### Review Findings + +Code review of 2026-05-14 (Blind Hunter + Edge Case Hunter + Acceptance Auditor). 16 findings retained after triage; 15+ dismissed as wrong, intentional, or out-of-scope. All 3 decision-needed items resolved 2026-05-14. + +#### Patch (11) — fix unambiguously + +- [x] [Review][Patch] **Wrap `httpx2.Request(...)` construction inside the try block** — `httpx2.InvalidURL`, `httpx2.CookieConflict`, and `httpx2.LocalProtocolError` raised at Request init escape uncaught, violating the "no `httpx2` exception escapes" invariant. Note: `InvalidURL` and `CookieConflict` are `Exception` (not `HTTPError`) subclasses, so they bypass the existing `except httpx2.HTTPError`. [`src/httpware/transports/httpx2.py:91-99`] +- [x] [Review][Patch] **Map closed-client `RuntimeError` to `TransportError`** — `client.send` on a closed `httpx2.AsyncClient` raises a bare `RuntimeError("Cannot send a request, as the client has been closed.")` which is not caught and escapes. Add `except RuntimeError as exc: raise TransportError(str(exc)) from exc` (or narrow check on message), reachable today via the user-supplied-client lifecycle. [`src/httpware/transports/httpx2.py:101-108`] +- [x] [Review][Patch] **Preserve original exception message when mapping** — `raise TimeoutError from exc` instantiates `TimeoutError()` with no args; `str(exc)` is empty, operators see `httpware.TimeoutError: ` in logs. Replace with `raise TimeoutError(str(exc)) from exc` (and same for `TransportError`). The `__cause__` chain is preserved either way. [`src/httpware/transports/httpx2.py:103-108`] +- [x] [Review][Patch] **Remove dead `_owned` field (or honor it in `aclose()`)** — set at `__init__` line 65, never read; `aclose()` unconditionally closes the underlying client. Spec line 51 incorrectly claims `_owned` is "used by `aclose()`". Story 1.4 deliberately chose the "close everything" policy (spec line 395), so remove `_owned` and reconcile spec line 51 with reality. Alternative: implement ownership-respecting `aclose` (Story 1.7 territory). [`src/httpware/transports/httpx2.py:65,138-145`] +- [x] [Review][Patch] **Tighten `_try_decode_json` content-type match** — `.startswith("application/json")` falsely matches `application/jsonpatch`. Split on `;` and check the bare media type equals `application/json` (deferring `+json` variants per spec Open Question (a)). [`src/httpware/transports/httpx2.py:40`] +- [x] [Review][Patch] **`extensions=dict(request.extensions)` unconditionally** — the `if request.extensions else None` branch silently drops falsy-but-non-empty mapping subclasses and is fragile to intent. Just pass a dict (default `{}` is fine). [`src/httpware/transports/httpx2.py:98`] +- [x] [Review][Patch] **Anchor leakage test to a repo-relative path and assert non-empty** — `_SOURCES = sorted(Path("src/httpware").rglob("*.py"))` resolves to cwd at module import; pytest invoked from a different directory yields zero parametrized cases and silently passes. Anchor to `Path(__file__).resolve().parents[1] / "src/httpware"` and add `assert _SOURCES, "leakage test discovered no source files"`. [`tests/test_no_httpx2_leakage.py:10-11`] +- [x] [Review][Patch] **Move lowercase-headers + uppercase-method contract to `Httpx2Transport` class docstring** — AC11 says "Documented in the `Httpx2Transport` class docstring"; currently lives in the module docstring only. Cosmetic but a literal AC gap. [`src/httpware/transports/httpx2.py:51`] +- [x] [Review][Patch] **Add `_closed` check to `stream()`** *(from decision 1b)* — currently raises `NotImplementedError` unconditionally; AC9 requires post-close calls to raise `TransportError`. Check `self._closed` first; raise `TransportError("Httpx2Transport is closed.")` if closed, else fall through to `NotImplementedError`. Add a test for the post-close case under section (n). [`src/httpware/transports/httpx2.py:133-136`, `tests/test_transports_httpx2.py:326-331`] +- [x] [Review][Patch] **Document multi-valued response header collapse** *(from decision 2a)* — `dict(resp.headers)` drops duplicate-key headers (Set-Cookie, Via, Link). Accept the loss for v0 (consistent with `Mapping[str, str]`); add a `# noqa`-style comment at the call site and extend the `case-insensitive header type` deferred-work entry to mention multi-value collapse alongside case-insensitivity. [`src/httpware/transports/httpx2.py:111`, `docs/deferred-work.md`] +- [x] [Review][Patch] **`asyncio.Lock` around lazy `_get_client()`** *(from decision 3a)* — guard the `self._client is None` check with a lazily-created `asyncio.Lock` (stored on the transport). Double-checked locking: cheap fast path when already initialized, lock only on first init. Update the lazy-init test to also assert no leaks under concurrent first-calls. [`src/httpware/transports/httpx2.py:70-85`, `tests/test_transports_httpx2.py:651-657`] + +#### Defer (5) — appended to `docs/deferred-work.md` + +- [x] [Review][Defer] **Unbounded error body size on `StatusError.body`** — `resp.content` materializes the full body; large 5xx pages stay pinned in memory through exception lifetimes. No size cap. Revisit alongside retry/observability middleware. [`src/httpware/transports/httpx2.py:117-124`] +- [x] [Review][Defer] **`httpx2.StreamError` / `StreamConsumed` family escape uncaught** — these are `RuntimeError` subclasses, not `HTTPError`, so `except httpx2.HTTPError` misses them. Not triggered by default httpx2 config (no redirects, no retries) but reachable via user-supplied clients with retry middleware. Revisit when retry middleware lands. [`src/httpware/transports/httpx2.py:103-108`] +- [x] [Review][Defer] **Header CRLF injection** — `dict(request.headers)` forwards header values verbatim, including embedded `\r\n`. Spec already defers URL CRLF to the Redactor (Story 5.3); extend the deferral to cover headers, which are a strictly larger attack surface. [`src/httpware/transports/httpx2.py:94`] +- [x] [Review][Defer] **Userinfo preserved on `StatusError.request_url` field** — `__repr__` and `Exception.__init__` summary strip `user:pass@`, but the raw field retains credentials. Defense-in-depth strip at construction is the Redactor's job (Story 5.3) per existing errors.py docstring. [`src/httpware/transports/httpx2.py:123`] +- [x] [Review][Defer] **Concurrent `aclose()` ↔ `__call__` races** — no synchronization between in-flight `client.send` and `aclose`. Best case `RuntimeError`; worst case partly-disposed pool. Broader concurrency/lifecycle design; defer to Story 1.7 or retry stories. [`src/httpware/transports/httpx2.py:87-145`] + +## Dev Notes + +### Architecture references (authoritative — read these before coding) + +- `docs/architecture.md` § **Decision 2 — Transport protocol shape** (lines 200–213). Authoritative signature for the protocol; ship this verbatim. +- `docs/architecture.md` § **Decision 3 — Exception mapping at the seam** (lines 214–223). The mapping table the AC7 except clauses implement. **Caveat:** the table lists `InvalidURL` under `TransportError` but doesn't flag that `httpx2.InvalidURL` is NOT a subclass of `httpx2.HTTPError` — this story catches it separately (AC7). Flag for reviewer. +- `docs/architecture.md` § **Pattern Examples — exception construction with keyword args** (lines 577–597). The fallback idiom `STATUS_TO_EXCEPTION.get(status, ClientStatusError if status < 500 else ServerStatusError)` shipped verbatim in AC6. +- `docs/architecture.md` § **Async Naming** (lines 474–478). `aclose()` is the sole `a`-prefix exception; `__call__` is plain async. `stream` is sync `def` returning an async CM (not `async def`). +- `docs/architecture.md` § **Exception Construction** (lines 480–499). Kwargs-only construction with 6 fields — already enforced by Story 1.3's `StatusError.__init__`. +- `docs/architecture.md` § **Cross-cutting concerns** (line 749): "**Event-loop binding** — `transports/httpx2.py` (lazy `httpx2.AsyncClient` creation on first `__call__`)" — AC3 codifies this. +- `docs/architecture.md` § **Seam 4 — Httpx2Transport ↔ httpx2** (lines 709–714). The CI grep invariant from AC13. +- `docs/prd.md` **FR12–FR16** (Transport Layer). FR13 is "Custom `Transport` is pluggable"; FR14–FR16 are signature shape; this story implements them. +- `docs/prd.md` **FR36** ("framework raises `httpware`-owned exceptions only"). AC7's no-leakage invariant. +- `docs/epics.md` **Story 1.4** (lines 321–337) — authoritative AC source; this file expands it. +- `docs/stories/1-3-exception-hierarchy-with-plain-fields.md` — exception classes this story constructs. **Read the Review Findings section** — the seam contract for headers/method-casing/URL-userinfo is partly inherited from there. +- `docs/deferred-work.md` lines 7–9 — the three Story 1.3 deferrals that this story resolves (AC10, AC11, AC12). +- `CLAUDE.md` § Code conventions — kwargs-only exceptions, `Http` is two letters (`Httpx2Transport`, not `HTTPX2Transport`), `# ty: ignore[…]` only. + +### Key design points + +**`Transport` is a structural Protocol, not an ABC.** `@runtime_checkable` enables `isinstance(x, Transport)` for the few places we want to gate on conformance (notably `AsyncClient(transport=...)` in Story 1.7). Cost is acceptable for a 3-method protocol per architecture line 212. **Do not** make `Httpx2Transport` inherit from `Transport` — structural typing handles it, and inheritance creates needless coupling. + +**Lazy `httpx2.AsyncClient` construction.** Architecture line 749 is explicit: the underlying client is built on first `__call__`, not in `__init__`. Reason: an `AsyncClient` constructed before the event loop exists binds to the *wrong* loop and explodes at first use. Tests that construct `Httpx2Transport` in sync `setup` fixtures must not require an event loop. The `_get_client()` indirection isolates this concern. + +**`_owned` is informational, not consequential — yet.** In Story 1.4, `aclose()` closes whatever it has (owned or not). The architecture's Decision 9 ref-counting (Story 1.7) will use `_owned` to decide whether `AsyncClient.__aexit__` may safely call this — i.e., a user-supplied client should NOT be closed by `AsyncClient.__aexit__` if the user might still hold a reference. We carry `_owned` now so Story 1.7 doesn't have to refactor the constructor. + +**Method casing normalization at the seam.** Story 1.3's deferred-work `request_method casing` is resolved by uppercasing in `__call__` immediately before passing to httpx2 AND storing on `StatusError`. The httpware `Request` itself is immutable and unchanged — callers can build `Request(method="get", ...)` and the on-the-wire request will be `GET` while `request.method` (the immutable value) remains `"get"`. This matches HTTP/1.1 RFC 7230 (case-insensitive, but conventionally uppercase) and httpx2's own behavior (httpx2 forwards whatever you give it; it does not uppercase). + +**Header case-folding contract: lowercase wins (v0).** `httpx2.Response.headers` is a case-insensitive multimap; `dict(resp.headers)` returns lowercased keys. The httpware `Response.headers` is `Mapping[str, str]` — the contract is "lowercase ASCII keys" in v0. If/when a real header-handling middleware needs case-preservation, Story 2.3 (or whenever it lands) will introduce a `Headers` type. **Do not** add `Headers` in this story. + +**`request_url` is stored raw on `StatusError`; `errors.py` redacts on `__repr__` / `str()`.** Story 1.3 added `_strip_userinfo` and `__reduce__` machinery; the seam's job is to hand the raw URL to `StatusError(...)` and trust the error type to redact at emission points. **Do not** call `_strip_userinfo` from `transports/httpx2.py` — that would be double-redaction and would break the `pickle` round-trip (the reconstructor expects the raw URL). + +**No `auth`, `follow_redirects`, `stream=` overrides on `client.send`.** Per the architecture's middleware-execution model (Decision 4), auth normalization is a middleware (Story 2.4); redirects are out of scope for v0.1.0 (the architecture treats 3xx as responses that callers must handle); `stream=` is Story 4.1's province. The seam passes only `request` to `send`. + +**`_try_decode_json` is best-effort, lossy, and never raises.** If a response declares `application/json` but the bytes are garbage, `exc.json` is `None`. If a 4xx response declares `text/html` with HTML content, `exc.json` is `None`. The user can always inspect `exc.body` directly for the raw bytes. This matches the architecture's pattern example (line 586): `json=_try_json(resp)` is documented as best-effort. + +**`__call__` has the architecture's "exception-mapping seam" responsibility — and only that.** No retry, no logging, no observability hooks here. Those layers live in `middleware/*` (Epic 2+) and wrap the transport from above. Resist the urge to "add a try/except for a clean error message" — clean error messages are the `StatusError.__repr__` story (Story 1.3), not this one. + +**`stream()` returns a typed AbstractAsyncContextManager but raises NotImplementedError when called.** The protocol contract requires the signature; the implementation deferral is documented in the architecture (Decision 10 → Story 4.1). Raising synchronously (not from inside `__aenter__`) catches the misuse at the call site rather than inside an `async with`, which is friendlier for users debugging an "I tried to stream and got nothing" report. + +### Public API Surface (canonical `__all__` after this story) + +After Story 1.4, `httpware/__init__.py` must re-export exactly these **24** symbols in RUF022 order (ALL-CAPS first, then mixed-case classes alphabetically): + +```python +__all__ = [ + "STATUS_TO_EXCEPTION", + "BadRequestError", + "ClientConfig", + "ClientError", + "ClientStatusError", + "ConflictError", + "ForbiddenError", + "Httpx2Transport", + "InternalServerError", + "Limits", + "NotFoundError", + "RateLimitedError", + "Request", + "Response", + "ServerStatusError", + "ServiceUnavailableError", + "StatusError", + "StreamResponse", + "Timeout", + "TimeoutError", + "Transport", + "TransportError", + "UnauthorizedError", + "UnprocessableEntityError", +] +``` + +That's 24 entries: 21 from Story 1.3 + 3 new (`Httpx2Transport`, `StreamResponse`, `Transport`). The CI gate is `ruff check --select RUF022`; trust ruff's order. (`Transport` sorts between `Timeout`/`TimeoutError` and `TransportError`; `StreamResponse` between `StatusError` and `Timeout`.) + +### What lives where after this story + +| File | New / modified | Contents | +|---|---|---| +| `src/httpware/transports/__init__.py` | **new** | `@runtime_checkable` `Transport(Protocol)` with `__call__`, `stream`, `aclose`. | +| `src/httpware/transports/httpx2.py` | **new** | `Httpx2Transport`: kwargs-only `__init__`, lazy `_get_client`, `__call__` with mapping/timing/status-check, `stream` raising `NotImplementedError`, idempotent `aclose`, `_try_decode_json` helper. | +| `src/httpware/response.py` | **modify** | Append minimal `StreamResponse` (3 public fields, no methods). | +| `src/httpware/__init__.py` | **modify** | Add 3 new re-exports; expand `__all__` from 21 to 24. | +| `tests/test_transports_httpx2.py` | **new** | (a)–(p) from AC16. | +| `tests/test_no_httpx2_leakage.py` | **new** | (q) — the CI-invariant grep test (architecture Seam 4). | +| `docs/deferred-work.md` | **modify** | Mark 3 Story-1-3 items resolved; add 3 new deferrals from this story's review surface. | +| `CHANGELOG.md` | **modify** | One new bullet under `Unreleased` → `Added`. | + +### Read-before-edit (per architect's guidance) + +Files this story modifies (read current state before editing): + +- `src/httpware/response.py` — currently 51 lines: module docstring, `_get_content_type` helper, `_parse_charset` helper, `_CHARSET_PREFIX` constant, `Response` dataclass with `text` property and `json()` method. Append `StreamResponse` after the `Response` class definition; do not reorder or rename anything that exists. +- `src/httpware/__init__.py` — currently 48 lines: module docstring, four `from httpware. import (...)` blocks (config, errors, request, response), one `__all__` with 21 entries. Add 2 new imports (`from httpware.transports import Transport`, `from httpware.transports.httpx2 import Httpx2Transport`), extend the existing `from httpware.response import Response` to also import `StreamResponse`, and replace `__all__` with the 24-entry list. `ruff check --fix` will re-sort everything. +- `docs/deferred-work.md` — has a section "Deferred from: code review of story-1-3 (2026-05-13)" with 3 items at lines 5–9. Mark those 3 items as resolved (replace section content with a one-line "Resolved by Story 1.4" note) and append a new section for this story's review surface. +- `CHANGELOG.md` — `Unreleased` → `Added` has bullets for Stories 1.1, 1.2, 1.3. Append at end; do not reformat existing entries. + +Files this story creates (no prior state to preserve): `src/httpware/transports/__init__.py`, `src/httpware/transports/httpx2.py`, `tests/test_transports_httpx2.py`, `tests/test_no_httpx2_leakage.py`. + +### Carryover from Story 1.3 + +- The 15 exception classes from Story 1.3 are the **destination** of this story's mapping. `StatusError.__init__` is kwargs-only; construct via the architecture's idiom (line 581). +- `TransportError` and `TimeoutError` are bare in Story 1.3 — construct them in this story with **zero arguments**: `raise TransportError() from exc`. No `status` / `body` / etc. on these two classes. +- `_strip_userinfo` redaction lives in `errors.py` — invoked automatically on `__repr__` / `str(exc)` / `Exception.__init__`. **Do not** call it from `transports/httpx2.py`. +- `StatusError.__reduce__` (pickling) round-trips `headers` through `dict(self.headers)` — so the seam can hand in any `Mapping[str, str]` and pickle will work. We pass `dict(resp.headers)`, which is already a plain dict; no concern. +- `from __future__ import annotations` is forbidden; use native PEP 604 unions. +- `asyncio_mode = "auto"` is set — async test functions don't need `@pytest.mark.asyncio`. Heavy use of async tests in this story; pytest-asyncio handles it. +- `pythonpath = ["src"]` is set; `from httpware.transports import ...` works. + +### Anti-patterns to reject (will fail review or CI) + +- ❌ `import httpx2` anywhere outside `src/httpware/transports/httpx2.py`. CI grep test (AC13/AC16-q) is the gate. +- ❌ `from __future__ import annotations` anywhere. +- ❌ Making `Httpx2Transport` inherit from `Transport`. Structural subtyping via `@runtime_checkable` is the design. +- ❌ Catching `httpx2.HTTPError` first (before `TimeoutException`) — would misroute timeouts to `TransportError`. +- ❌ Omitting the `httpx2.InvalidURL` except clause — it's not under `HTTPError` and would escape. +- ❌ Letting a `httpx2.*` exception escape `__call__` via any code path (validated by AC16-i). +- ❌ Calling `_strip_userinfo` from the transport — that's the error type's contract, not the seam's. +- ❌ Calling `resp.aclose()` after `await client.send(...)` — `send` with default `stream=False` already buffers `resp.content`, and calling `aclose()` would discard timing information for nothing. +- ❌ Reading `resp.elapsed` — measure it ourselves with `time.monotonic()` (httpx2's `elapsed` is set lazily by the response-close lifecycle and is unreliable in `MockTransport` test paths). +- ❌ Raising `Exception` / `RuntimeError` / `ValueError` for transport-level failures (except the `ValueError` for constructor argument conflicts, which is a programming error and not a runtime transport failure). +- ❌ Adding retry / observability / logging logic inside `Httpx2Transport`. Those are middleware concerns (Epic 2+). +- ❌ Mutating `request` (it's a frozen dataclass; would raise). Use locals (`method = request.method.upper()`). +- ❌ Passing `body=request.body` to `httpx2.Request` — the parameter is `content=`, not `body=`. `body=` does not exist on `httpx2.Request`. +- ❌ Importing `Transport` from `httpware.transports` inside `httpx2.py` — unnecessary; structural typing handles the conformance check. +- ❌ `Optional[X]` / `Union[X, Y]` — use `X | None` / `X | Y`. +- ❌ `typing.Mapping`, `typing.AbstractContextManager` — use `collections.abc` versions. +- ❌ Adding `__init__.py` files to `tests/` subdirectories beyond what already exists (`tests/__init__.py` is fine — keep it). +- ❌ Using `respx` for transport-level mocking. The architecture forbids it in httpware's own tests (line 553); use `httpx2.MockTransport` instead. + +### Testing standards summary + +- `pytest-asyncio` auto mode — async test functions don't need `@pytest.mark.asyncio`. +- `httpx2.MockTransport` for response and exception injection; **no `respx`** (architecture line 553). +- Parametrize aggressively over the 10 status codes (AC16-c), the 4 timeout classes (AC16-f), the 8 HTTPError descendants (AC16-g). Keeps the test file dense. +- Use `pytest.raises(httpware.TimeoutError)` / `pytest.raises(httpware.TransportError)` — exact-type assertions (`type(exc) is TimeoutError`) to catch any future subclass drift. +- Assert `exc.__cause__` is the original httpx2 exception in each mapping test — locks the `raise ... from exc` invariant. +- Coverage target: **100%** on both new transport modules (`__init__.py` and `httpx2.py`). The modules are small (~80 LOC combined); every branch is reachable. +- No Hypothesis tests in this story (no concurrency, no property-based invariants — those land in retry-budget territory, Epic 3). +- No real-network tests in this story — every test uses `MockTransport`. Integration tests against `httpbingo.org` arrive in Story 1.7. +- `tests/test_no_httpx2_leakage.py` is a project-invariant test, not transport-specific — keep it short and explicit. + +### Definition of Done + +- All 16 ACs verified (each AC mapped to at least one test in `tests/test_transports_httpx2.py` / `tests/test_no_httpx2_leakage.py`, or to a check in `just lint`). +- All Task/Subtask checkboxes are `[x]`. +- `ruff format`, `ruff check`, `ty check`, `pytest` all pass locally with zero diagnostics. +- Coverage 100% on `src/httpware/transports/__init__.py` AND `src/httpware/transports/httpx2.py`; 100% retained on the 4 prior modules. +- `grep -rE 'import httpx2|from httpx2' src/httpware/` returns exactly one path: `src/httpware/transports/httpx2.py`. +- File List below is updated to reflect every changed and new file. +- `CHANGELOG.md` has a new dated bullet under `Unreleased` → `Added`. +- `docs/deferred-work.md` reflects the 3 resolved items (from story-1-3 review) and the 3 new deferrals (from this story's anticipated review). +- `httpware/__init__.py`'s `__all__` and explicit imports are in lockstep (every entry in `__all__` is imported; every imported symbol is in `__all__`); RUF022 is clean. +- Front-matter `status:` and trailing `## Status` are both set to `review`. + +### Open questions / things to flag for reviewer + +- **3xx handling.** AC6 treats 3xx as successful responses (returned to the caller as a `Response`), not as errors. Rationale: `httpx2.AsyncClient` has `follow_redirects=False` by default; a 3xx coming through means the caller wants to inspect it. The architecture does not explicitly say "treat 3xx as success", but the fallback rule in `errors.py` ("Fallback assumes `400 <= status < 600`") implies it. If the reviewer prefers raising `ClientStatusError` for 3xx (or auto-following redirects via httpx2's `follow_redirects=True`), that's a one-line change to the status range check and a constructor change. Flag for review. +- **`Httpx2Transport.__init__` accepting `client + limits/timeout`.** AC3 raises `ValueError` if both are non-None (silent precedence is a footgun). The alternative — silently ignoring `limits`/`timeout` when `client` is supplied — is what httpx itself does in some cases, but the strict-error path is friendlier in debugging. If the reviewer prefers silent precedence, that's a one-line change. +- **`aclose()` closing a user-supplied client.** AC9 says we close whatever we have, owned or not. Architecture Decision 9 (Story 1.7) introduces ref-counting that may want a different policy — specifically, "don't close what we don't own". For Story 1.4, the simple policy is "close everything"; Story 1.7 can refine. Flag if reviewer wants the more conservative policy now. +- **`_try_decode_json` accepts `application/json` prefix only, not `+json` suffixes.** Real-world APIs sometimes return `application/vnd.api+json` or `application/problem+json` (RFC 7807). AC6 deliberately scopes to the strict prefix to avoid surprises; the architecture doesn't prescribe a richer match. If the reviewer wants `+json` support, it's a one-line widening. Flag. +- **`Httpx2Transport()` with no args and no `client=`** is constructible, but actually-issuing requests will try to make a real `httpx2.AsyncClient` and hit the network on a real `await transport(...)`. The "lazy" property is tested by checking `_client is None` pre-call; the post-call non-None case requires a `MockTransport`-backed instance to avoid real network. AC16-o has a slightly awkward shape because of this — flag if the reviewer wants a cleaner test arrangement (e.g., requiring `client=` explicitly in v0). +- **`httpx2.InvalidURL` is the architectural mapping table's hidden gotcha** (line 218 lists it but doesn't note the inheritance gap). The architecture document should be amended; flag during review whether to land an architecture patch in the same PR. + +## Change Log + +| Date | Change | Notes | +|---|---|---| +| 2026-05-13 | Story created | Drafted from `docs/epics.md` Story 1.4 + `docs/architecture.md` Decision 2/3 + `transports/httpx2.py` placement; expanded the epic's multi-clause AC into AC1–AC16 for traceability; verified `httpx2` exception hierarchy and `MockTransport` shape against installed `httpx2==2.0.0`; flagged `httpx2.InvalidURL` as not-under-HTTPError (closes a gap in the architecture's mapping table); resolved 3 Story-1-3 deferrals (request_method casing AC10, header case-folding AC11, CRLF mitigation strategy AC12); added open questions on 3xx handling, constructor argument conflict, aclose policy, `+json` suffix matching, and lazy-construction test ergonomics. | +| 2026-05-13 | Story implemented | All 12 tasks (32 subtasks) complete; 16 ACs verified. Two new modules under `src/httpware/transports/`, `StreamResponse` stub added, public `__all__` expanded 21 → 24. One deviation from spec: `AbstractAsyncContextManager` imported from `contextlib` rather than `collections.abc` because the symbol is only re-exported from `collections.abc` on Python ≥3.12 and the project floor is 3.11. 62 new tests added (`tests/test_transports_httpx2.py` 55 + `tests/test_no_httpx2_leakage.py` 7); full suite 132 passing; 100% coverage on every source module; `grep` invariant holds (single `httpx2` import in the library). 3 Story 1.3 deferrals resolved, 3 new Story 1.4 deferrals recorded. Status → `review`. | +| 2026-05-14 | Code review + patches | 3-layer adversarial review (Blind Hunter, Edge Case Hunter, Acceptance Auditor) → 16 retained findings (3 decision-needed, 8 patch, 5 defer), ~28 dismissed. Decisions resolved: (1b) `stream()` honors AC9 post-close → `TransportError`; (2a) accept multi-valued header collapse for v0, document; (3a) `asyncio.Lock` around lazy init. All 11 patches applied to `transports/httpx2.py`, `tests/test_transports_httpx2.py` (+8 new tests, 140 total), `tests/test_no_httpx2_leakage.py` (cwd-robust). 5 deferrals appended to `docs/deferred-work.md`. Full suite 140 passing, 100% coverage holds, lint green. Status → `done`. | + +## Dev Agent Record + +### Implementation Plan + +Implemented in the order specified by the story tasks: `StreamResponse` stub (Task 1) → `Transport` protocol (Task 2) → `Httpx2Transport` module + class (Tasks 3–8) → public `__init__.py` re-exports (Task 9) → tests (Task 10) → docs/changelog/story metadata (Tasks 11–12). Each task was finished and verified (lint + tests) before advancing. + +### Debug Log + +- `from collections.abc import AbstractAsyncContextManager` (specified in AC1, Task 2.2, and Task 3.2) raises `ImportError` on the project's Python 3.11 floor — the symbol was only added to `collections.abc` in Python 3.12. Switched to `from contextlib import AbstractAsyncContextManager` in both `transports/__init__.py` and `transports/httpx2.py`. This is a deviation from the story spec but is required for the project's stated Python floor; surface in the architecture review (the spec carried this assumption through from earlier drafts). Code behaves identically — `contextlib.AbstractAsyncContextManager` is the canonical home and the symbol is re-exported from `collections.abc` only on ≥3.12. +- Initial lint pass surfaced `ANN202` (missing return type annotation) on the inner-handler factory helpers in `tests/test_transports_httpx2.py` and `PLR2004` on a handful of literal status comparisons in test assertions; both addressed by typing the helpers as returning `Callable[[httpx2.Request], httpx2.Response]` and adding targeted `# noqa: PLR2004` on the assertion lines (test-only ergonomic noise). +- Ruff auto-fixed `raise TimeoutError() from exc` → `raise TimeoutError from exc` (TRY302/equivalent). Semantics unchanged for zero-arg construction; left as ruff produced. +- `httpx2.Response.elapsed` raises `RuntimeError` before the response is closed; confirms AC4's rationale for `time.monotonic()`-based timing at the seam. + +### Completion Notes + +- All 16 ACs satisfied. Each AC maps to at least one parametrized or explicit test in `tests/test_transports_httpx2.py` / `tests/test_no_httpx2_leakage.py`, or to a `just lint` / grep guard. +- Single deviation from the story spec: `AbstractAsyncContextManager` is imported from `contextlib` instead of `collections.abc` (see Debug Log). All other AC text honoured verbatim. +- 132 tests pass (70 pre-existing + 62 new). Coverage is **100% on every source module**, including the two new transport modules. +- `grep -rE 'import httpx2|from httpx2' src/httpware/` returns a single match in `src/httpware/transports/httpx2.py` — Seam 4 invariant holds. +- `from httpware import Transport, Httpx2Transport, StreamResponse` succeeds; `__all__` is now 24 entries (RUF022-clean). +- Resolved the 3 Story 1.3 deferrals (method casing AC10, header case-folding AC11, CRLF mitigation strategy AC12); recorded 3 new deferrals from this story (URL CRLF / log-injection → Story 5.3, `request.method` validation, case-insensitive header type). +- Open questions from the story remain open for the reviewer: 3xx handling, `client + limits/timeout` `ValueError` policy, `aclose()` closing a user-supplied client, `_try_decode_json` accepting `+json` suffixes, and `Httpx2Transport()` lazy-construct-then-real-network ergonomics — all noted under "Open questions" in Dev Notes. + +## File List + +- `src/httpware/transports/__init__.py` — new (Transport protocol; Seam 1) +- `src/httpware/transports/httpx2.py` — new (Httpx2Transport adapter; only file importing httpx2; Seam 4) +- `src/httpware/response.py` — modified (appended minimal `StreamResponse` stub) +- `src/httpware/__init__.py` — modified (added `Transport`, `Httpx2Transport`, `StreamResponse`; `__all__` expanded 21 → 24) +- `tests/test_transports_httpx2.py` — new (AC16 cases a–p, 55 parametrized + explicit tests) +- `tests/test_no_httpx2_leakage.py` — new (AC16-q project-invariant grep guard) +- `docs/deferred-work.md` — modified (Story 1.3 deferrals collapsed to "Resolved by Story 1.4"; 3 new Story 1.4 deferrals added) +- `CHANGELOG.md` — modified (one new bullet under `Unreleased` → `Added`) +- `docs/stories/1-4-transport-protocol-and-httpx2transport-adapter.md` — modified (status → `review`; checkboxes; Dev Agent Record / File List / Change Log filled) + +## Status + +`done` diff --git a/src/httpware/__init__.py b/src/httpware/__init__.py index 64fe819..8606226 100644 --- a/src/httpware/__init__.py +++ b/src/httpware/__init__.py @@ -20,7 +20,9 @@ UnprocessableEntityError, ) from httpware.request import Request -from httpware.response import Response +from httpware.response import Response, StreamResponse +from httpware.transports import Transport +from httpware.transports.httpx2 import Httpx2Transport __all__ = [ @@ -31,6 +33,7 @@ "ClientStatusError", "ConflictError", "ForbiddenError", + "Httpx2Transport", "InternalServerError", "Limits", "NotFoundError", @@ -40,8 +43,10 @@ "ServerStatusError", "ServiceUnavailableError", "StatusError", + "StreamResponse", "Timeout", "TimeoutError", + "Transport", "TransportError", "UnauthorizedError", "UnprocessableEntityError", diff --git a/src/httpware/response.py b/src/httpware/response.py index 637f774..2d082d2 100644 --- a/src/httpware/response.py +++ b/src/httpware/response.py @@ -49,3 +49,12 @@ def text(self) -> str: def json(self) -> Any: # noqa: ANN401 """Parse `content` as JSON.""" return json.loads(self.content) + + +@dataclass(frozen=True, slots=True) +class StreamResponse: + """Placeholder for the streaming response type — fleshed out in Story 4.1.""" + + status: int + headers: Mapping[str, str] + url: str diff --git a/src/httpware/transports/__init__.py b/src/httpware/transports/__init__.py new file mode 100644 index 0000000..b2967ff --- /dev/null +++ b/src/httpware/transports/__init__.py @@ -0,0 +1,27 @@ +"""Transport protocol — the middleware ↔ transport seam (Seam 1).""" + +from contextlib import AbstractAsyncContextManager +from typing import Protocol, runtime_checkable + +from httpware.request import Request +from httpware.response import Response, StreamResponse + + +@runtime_checkable +class Transport(Protocol): + """Structural protocol every transport adapter satisfies.""" + + async def __call__(self, request: Request) -> Response: + """Send `request` and return the buffered response.""" + ... + + def stream(self, request: Request) -> AbstractAsyncContextManager[StreamResponse]: + """Open a streaming response for `request` as an async context manager.""" + ... + + async def aclose(self) -> None: + """Release any resources held by the transport.""" + ... + + +__all__ = ["Transport"] diff --git a/src/httpware/transports/httpx2.py b/src/httpware/transports/httpx2.py new file mode 100644 index 0000000..1ab32a0 --- /dev/null +++ b/src/httpware/transports/httpx2.py @@ -0,0 +1,180 @@ +"""Httpx2Transport — adapts the httpx2 AsyncClient to the Transport protocol. + +This is the only file in `httpware` that imports `httpx2`. The v0 +method / header / multi-valued-header contracts are documented on the +`Httpx2Transport` class. +""" + +import asyncio +import dataclasses +import json +import time +from contextlib import AbstractAsyncContextManager +from typing import Any + +import httpx2 + +from httpware.config import Limits, Timeout +from httpware.errors import ( + STATUS_TO_EXCEPTION, + ClientStatusError, + ServerStatusError, + TimeoutError, # noqa: A004 + TransportError, +) +from httpware.request import Request +from httpware.response import Response, StreamResponse + + +def _try_decode_json(resp: httpx2.Response) -> Any | None: # noqa: ANN401 + """Best-effort JSON decode of `resp.content`; never raises.""" + content_type = "" + for key, value in resp.headers.items(): + if key.lower() == "content-type": + content_type = value + break + # Strict match on the bare media type: ``application/json`` only. + # Splitting on ``;`` strips parameters (e.g. ``; charset=utf-8``) and + # avoids ``application/jsonpatch`` false-positives that ``startswith`` + # would accept. ``+json`` variants (``application/problem+json``, + # ``application/vnd.api+json``) are deferred per Open Question (a). + media_type = content_type.split(";", 1)[0].strip().lower() + if media_type != "application/json": + return None + if not resp.content: + return None + try: + return json.loads(resp.content) + except json.JSONDecodeError: + return None + + +class Httpx2Transport: + """Default `Transport` implementation backed by `httpx2.AsyncClient`. + + This is the only place in ``httpware`` that imports ``httpx2``. It owns + three v0 contracts the rest of the library relies on: + + * The wire ``method`` is uppercased at this seam; the + ``httpware.Request.method`` itself is left untouched. + * ``headers`` returned to callers (and stored on ``StatusError``) use + the lowercase ASCII keys that ``httpx2.Response.headers`` already + emits. A case-insensitive header type is deferred until middleware + needs it. + * ``Mapping[str, str]`` is single-valued. ``dict(resp.headers)`` + collapses duplicate-key headers (``Set-Cookie``, ``Via``, ``Link``) + to the last value only; the multi-valued contract widens together + with the case-insensitive type in a later story. + """ + + def __init__( + self, + *, + client: httpx2.AsyncClient | None = None, + limits: Limits | None = None, + timeout: Timeout | None = None, + ) -> None: + """Store the (optionally user-supplied) client and lazy-init config.""" + if client is not None and (limits is not None or timeout is not None): + msg = "Pass limits/timeout only when client is None." + raise ValueError(msg) + self._client: httpx2.AsyncClient | None = client + self._limits: Limits | None = limits + self._timeout: Timeout | None = timeout + self._closed: bool = False + self._init_lock: asyncio.Lock | None = None + + async def _get_client(self) -> httpx2.AsyncClient: + if self._closed: + msg = "Httpx2Transport is closed." + raise TransportError(msg) + if self._client is not None: + return self._client + if self._init_lock is None: + self._init_lock = asyncio.Lock() + async with self._init_lock: + if self._client is None: + limits = self._limits or Limits() + timeout = self._timeout or Timeout() + httpx2_limits = httpx2.Limits(**dataclasses.asdict(limits)) + httpx2_timeout = httpx2.Timeout( + connect=timeout.connect, + read=timeout.read, + write=timeout.write, + pool=timeout.pool, + ) + self._client = httpx2.AsyncClient(limits=httpx2_limits, timeout=httpx2_timeout) + return self._client + + async def __call__(self, request: Request) -> Response: + """Send `request` and return a `Response`, raising on 4xx/5xx.""" + client = await self._get_client() + method = request.method.upper() + try: + httpx2_req = httpx2.Request( + method=method, + url=request.url, + headers=dict(request.headers), + params=dict(request.params), + cookies=dict(request.cookies), + content=request.body, + extensions=dict(request.extensions), + ) + start = time.monotonic() + resp = await client.send(httpx2_req) + except httpx2.TimeoutException as exc: + raise TimeoutError(str(exc)) from exc + except httpx2.HTTPError as exc: + raise TransportError(str(exc)) from exc + except (httpx2.InvalidURL, httpx2.CookieConflict) as exc: + raise TransportError(str(exc)) from exc + except RuntimeError as exc: + # ``httpx2.AsyncClient.send`` raises a bare RuntimeError when + # the client has been closed externally; there is no public + # attribute we can interrogate ahead of time. + if "closed" in str(exc): + raise TransportError(str(exc)) from exc + raise + elapsed = time.monotonic() - start + status = resp.status_code + # ``dict(...)`` collapses duplicate-key headers (Set-Cookie etc.) + # to the last value — see class docstring; widens with the + # multi-valued header contract in a later story. + headers = dict(resp.headers) + if 400 <= status < 600: # noqa: PLR2004 + exc_class = STATUS_TO_EXCEPTION.get( + status, + ClientStatusError if status < 500 else ServerStatusError, # noqa: PLR2004 + ) + raise exc_class( + status=status, + body=resp.content, + headers=headers, + json=_try_decode_json(resp), + request_method=method, + request_url=request.url, + ) + return Response( + status=status, + headers=headers, + content=resp.content, + url=str(resp.url), + elapsed=elapsed, + ) + + def stream(self, request: Request) -> AbstractAsyncContextManager[StreamResponse]: # noqa: ARG002 + """Open a streaming response — not yet implemented (Story 4.1).""" + if self._closed: + msg = "Httpx2Transport is closed." + raise TransportError(msg) + msg = "Streaming arrives in Epic 4 (Story 4.1)." + raise NotImplementedError(msg) + + async def aclose(self) -> None: + """Close the underlying client; safe to call repeatedly.""" + if self._closed: + return + if self._client is not None: + await self._client.aclose() + self._client = None + self._closed = True diff --git a/tests/test_no_httpx2_leakage.py b/tests/test_no_httpx2_leakage.py new file mode 100644 index 0000000..c2749e6 --- /dev/null +++ b/tests/test_no_httpx2_leakage.py @@ -0,0 +1,21 @@ +"""CI-invariant guard: only `transports/httpx2.py` may import `httpx2`.""" + +import re +from pathlib import Path + +import pytest + + +_PATTERN = re.compile(r"^\s*(?:import|from)\s+httpx2\b", re.MULTILINE) +_SRC_ROOT = Path(__file__).resolve().parents[1] / "src" / "httpware" +_SOURCES = sorted(_SRC_ROOT.rglob("*.py")) +_ALLOWED = _SRC_ROOT / "transports" / "httpx2.py" + +assert _SOURCES, f"leakage test discovered no source files under {_SRC_ROOT}" + + +@pytest.mark.parametrize("path", _SOURCES, ids=lambda p: p.relative_to(_SRC_ROOT.parent).as_posix()) +def test_only_httpx2_transport_imports_httpx2(path: Path) -> None: + text = path.read_text(encoding="utf-8") + if _PATTERN.search(text): + assert path == _ALLOWED, f"unexpected httpx2 import in {path}" diff --git a/tests/test_transports_httpx2.py b/tests/test_transports_httpx2.py new file mode 100644 index 0000000..faacfc7 --- /dev/null +++ b/tests/test_transports_httpx2.py @@ -0,0 +1,459 @@ +"""Unit tests for httpware.transports.httpx2.""" + +import asyncio +from collections.abc import Callable + +import httpx2 +import pytest + +from httpware import ( + BadRequestError, + ClientStatusError, + ConflictError, + ForbiddenError, + Httpx2Transport, + InternalServerError, + Limits, + NotFoundError, + RateLimitedError, + Request, + Response, + ServerStatusError, + ServiceUnavailableError, + StatusError, + Timeout, + TimeoutError, # noqa: A004 + Transport, + TransportError, + UnauthorizedError, + UnprocessableEntityError, +) + + +_Handler = Callable[[httpx2.Request], httpx2.Response] + + +def _status_handler(code: int, content: bytes = b"", headers: dict[str, str] | None = None) -> _Handler: + def handler(_req: httpx2.Request) -> httpx2.Response: + return httpx2.Response(code, content=content, headers=headers or {}) + + return handler + + +def _raising_handler(exc: BaseException) -> _Handler: + def handler(_req: httpx2.Request) -> httpx2.Response: + raise exc + + return handler + + +def _make_transport(handler: _Handler) -> Httpx2Transport: + return Httpx2Transport(client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler))) + + +# ----- (a) protocol membership ---------------------------------------------- + + +def test_httpx2_transport_satisfies_transport_protocol() -> None: + assert isinstance(Httpx2Transport(), Transport) + + +# ----- (b) success path 200 -------------------------------------------------- + + +async def test_success_path_returns_response() -> None: + transport = _make_transport(_status_handler(200, content=b"hello", headers={"content-type": "text/plain"})) + try: + resp = await transport(Request(method="GET", url="http://example.com/x")) + finally: + await transport.aclose() + + assert isinstance(resp, Response) + assert resp.status == 200 # noqa: PLR2004 + assert resp.content == b"hello" + assert resp.url == "http://example.com/x" + # lowercase ASCII keys per AC11 + assert "content-type" in resp.headers + assert resp.headers["content-type"] == "text/plain" + assert resp.elapsed >= 0.0 + + +# ----- (c) status-code mapping ---------------------------------------------- + + +_STATUS_LEAVES: list[tuple[int, type[StatusError]]] = [ + (400, BadRequestError), + (401, UnauthorizedError), + (403, ForbiddenError), + (404, NotFoundError), + (409, ConflictError), + (422, UnprocessableEntityError), + (429, RateLimitedError), + (500, InternalServerError), + (503, ServiceUnavailableError), +] + + +async def test_success_status_200_returns_response_not_raises() -> None: + transport = _make_transport(_status_handler(200)) + try: + resp = await transport(Request(method="GET", url="http://example.com/")) + finally: + await transport.aclose() + assert resp.status == 200 # noqa: PLR2004 + + +@pytest.mark.parametrize(("code", "exc_cls"), _STATUS_LEAVES) +async def test_status_mapping_raises_precise_leaf(code: int, exc_cls: type[StatusError]) -> None: + transport = _make_transport( + _status_handler(code, content=b'{"err":1}', headers={"content-type": "application/json"}) + ) + try: + with pytest.raises(exc_cls) as info: + await transport(Request(method="GET", url="http://example.com/p")) + finally: + await transport.aclose() + + assert type(info.value) is exc_cls + assert info.value.status == code + assert info.value.request_method == "GET" + assert info.value.request_url == "http://example.com/p" + assert info.value.json == {"err": 1} + + +# ----- (d) unknown-status fallback ------------------------------------------ + + +async def test_unknown_4xx_falls_back_to_client_status_error() -> None: + transport = _make_transport(_status_handler(418)) + try: + with pytest.raises(ClientStatusError) as info: + await transport(Request(method="GET", url="http://example.com/")) + finally: + await transport.aclose() + assert type(info.value) is ClientStatusError + assert info.value.status == 418 # noqa: PLR2004 + + +async def test_unknown_5xx_falls_back_to_server_status_error() -> None: + transport = _make_transport(_status_handler(504)) + try: + with pytest.raises(ServerStatusError) as info: + await transport(Request(method="GET", url="http://example.com/")) + finally: + await transport.aclose() + assert type(info.value) is ServerStatusError + assert info.value.status == 504 # noqa: PLR2004 + + +# ----- (e) _try_decode_json branches ---------------------------------------- + + +async def test_json_body_decodes_into_exception_json_field() -> None: + transport = _make_transport( + _status_handler(400, content=b'{"k": "v"}', headers={"content-type": "application/json; charset=utf-8"}) + ) + try: + with pytest.raises(BadRequestError) as info: + await transport(Request(method="GET", url="http://example.com/")) + finally: + await transport.aclose() + assert info.value.json == {"k": "v"} + + +async def test_non_json_body_yields_none_on_exception_json() -> None: + transport = _make_transport( + _status_handler(500, content=b"oops", headers={"content-type": "text/html"}) + ) + try: + with pytest.raises(InternalServerError) as info: + await transport(Request(method="GET", url="http://example.com/")) + finally: + await transport.aclose() + assert info.value.json is None + + +async def test_malformed_json_body_yields_none_on_exception_json() -> None: + transport = _make_transport( + _status_handler(400, content=b"{not json", headers={"content-type": "application/json"}) + ) + try: + with pytest.raises(BadRequestError) as info: + await transport(Request(method="GET", url="http://example.com/")) + finally: + await transport.aclose() + assert info.value.json is None + + +async def test_empty_body_with_json_content_type_yields_none() -> None: + transport = _make_transport(_status_handler(400, content=b"", headers={"content-type": "application/json"})) + try: + with pytest.raises(BadRequestError) as info: + await transport(Request(method="GET", url="http://example.com/")) + finally: + await transport.aclose() + assert info.value.json is None + + +async def test_missing_content_type_header_yields_none_on_exception_json() -> None: + transport = _make_transport(_status_handler(400, content=b'{"k": 1}', headers={})) + try: + with pytest.raises(BadRequestError) as info: + await transport(Request(method="GET", url="http://example.com/")) + finally: + await transport.aclose() + assert info.value.json is None + + +# ----- (f) httpx2.TimeoutException family ----------------------------------- + + +_TIMEOUT_CLASSES = [httpx2.ConnectTimeout, httpx2.ReadTimeout, httpx2.WriteTimeout, httpx2.PoolTimeout] + + +@pytest.mark.parametrize("timeout_cls", _TIMEOUT_CLASSES) +async def test_timeout_classes_map_to_httpware_timeout_error(timeout_cls) -> None: # noqa: ANN001 + transport = _make_transport(_raising_handler(timeout_cls("boom"))) + try: + with pytest.raises(TimeoutError) as info: + await transport(Request(method="GET", url="http://example.com/")) + finally: + await transport.aclose() + assert type(info.value) is TimeoutError + assert isinstance(info.value.__cause__, timeout_cls) + + +# ----- (g) httpx2.HTTPError family (representative) ------------------------- + + +_HTTP_ERROR_CLASSES = [ + httpx2.ConnectError, + httpx2.NetworkError, + httpx2.ProxyError, + httpx2.UnsupportedProtocol, + httpx2.LocalProtocolError, + httpx2.RemoteProtocolError, + httpx2.DecodingError, + httpx2.TooManyRedirects, +] + + +@pytest.mark.parametrize("http_err_cls", _HTTP_ERROR_CLASSES) +async def test_http_error_descendants_map_to_transport_error(http_err_cls) -> None: # noqa: ANN001 + transport = _make_transport(_raising_handler(http_err_cls("boom"))) + try: + with pytest.raises(TransportError) as info: + await transport(Request(method="GET", url="http://example.com/")) + finally: + await transport.aclose() + assert type(info.value) is TransportError + assert isinstance(info.value.__cause__, http_err_cls) + + +# ----- (h) httpx2.InvalidURL (orphan branch) -------------------------------- + + +async def test_invalid_url_maps_to_transport_error() -> None: + transport = _make_transport(_raising_handler(httpx2.InvalidURL("nope"))) + try: + with pytest.raises(TransportError) as info: + await transport(Request(method="GET", url="http://example.com/")) + finally: + await transport.aclose() + assert type(info.value) is TransportError + assert isinstance(info.value.__cause__, httpx2.InvalidURL) + + +# ----- (i) no httpx2 exception escapes -------------------------------------- + + +_ALL_HTTPX2_EXCEPTIONS = _TIMEOUT_CLASSES + _HTTP_ERROR_CLASSES + [httpx2.InvalidURL] + + +@pytest.mark.parametrize("exc_cls", _ALL_HTTPX2_EXCEPTIONS) +async def test_no_httpx2_exception_escapes(exc_cls) -> None: # noqa: ANN001 + transport = _make_transport(_raising_handler(exc_cls("boom"))) + try: + with pytest.raises((TimeoutError, TransportError)) as info: + await transport(Request(method="GET", url="http://example.com/")) + finally: + await transport.aclose() + assert not isinstance(info.value, httpx2.HTTPError) + + +# ----- (j) method casing normalization -------------------------------------- + + +async def test_lowercase_method_uppercased_in_status_error() -> None: + transport = _make_transport(_status_handler(404)) + try: + with pytest.raises(NotFoundError) as info: + await transport(Request(method="get", url="http://example.com/p")) + finally: + await transport.aclose() + assert info.value.request_method == "GET" + + +# ----- (k) stream() raises synchronously ------------------------------------ + + +def test_stream_raises_not_implemented_synchronously() -> None: + transport = Httpx2Transport() + with pytest.raises(NotImplementedError): + transport.stream(Request(method="GET", url="http://example.com/")) + + +# ----- (l) aclose() idempotency --------------------------------------------- + + +async def test_aclose_is_idempotent() -> None: + transport = _make_transport(_status_handler(200)) + await transport(Request(method="GET", url="http://example.com/")) + await transport.aclose() + await transport.aclose() + assert transport._client is None # noqa: SLF001 + + +# ----- (m) aclose() on never-used transport --------------------------------- + + +async def test_aclose_no_op_on_never_used_transport() -> None: + transport = Httpx2Transport() + await transport.aclose() + assert transport._client is None # noqa: SLF001 + + +# ----- (n) post-close call raises ------------------------------------------- + + +async def test_post_close_call_raises_transport_error() -> None: + transport = _make_transport(_status_handler(200)) + await transport(Request(method="GET", url="http://example.com/")) + await transport.aclose() + with pytest.raises(TransportError): + await transport(Request(method="GET", url="http://example.com/")) + + +async def test_post_close_stream_raises_transport_error() -> None: + transport = _make_transport(_status_handler(200)) + await transport.aclose() + with pytest.raises(TransportError): + transport.stream(Request(method="GET", url="http://example.com/")) + + +async def test_pre_close_stream_still_raises_not_implemented() -> None: + transport = _make_transport(_status_handler(200)) + with pytest.raises(NotImplementedError): + transport.stream(Request(method="GET", url="http://example.com/")) + await transport.aclose() + + +async def test_invalid_url_at_request_construction_maps_to_transport_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = _make_transport(_status_handler(200)) + + def _boom(*_args: object, **_kwargs: object) -> httpx2.Request: + msg = "bad url" + raise httpx2.InvalidURL(msg) + + monkeypatch.setattr(httpx2, "Request", _boom) + with pytest.raises(TransportError): + await transport(Request(method="GET", url="http://example.com/")) + + +async def test_cookie_conflict_at_request_construction_maps_to_transport_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = _make_transport(_status_handler(200)) + + def _boom(*_args: object, **_kwargs: object) -> httpx2.Request: + msg = "conflict" + raise httpx2.CookieConflict(msg) + + monkeypatch.setattr(httpx2, "Request", _boom) + with pytest.raises(TransportError): + await transport(Request(method="GET", url="http://example.com/")) + + +async def test_send_on_externally_closed_user_client_maps_to_transport_error() -> None: + user_client = httpx2.AsyncClient(transport=httpx2.MockTransport(_status_handler(200))) + transport = Httpx2Transport(client=user_client) + await user_client.aclose() + with pytest.raises(TransportError): + await transport(Request(method="GET", url="http://example.com/")) + + +async def test_unexpected_runtime_error_propagates_unchanged( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = _make_transport(_status_handler(200)) + client = await transport._get_client() # noqa: SLF001 + + async def _boom(*_args: object, **_kwargs: object) -> httpx2.Response: + msg = "something else entirely" + raise RuntimeError(msg) + + monkeypatch.setattr(client, "send", _boom) + with pytest.raises(RuntimeError, match="something else entirely"): + await transport(Request(method="GET", url="http://example.com/")) + + +async def test_mapped_exceptions_preserve_original_message() -> None: + transport = _make_transport(_raising_handler(httpx2.ReadTimeout("read timed out after 30s"))) + with pytest.raises(TimeoutError, match="read timed out after 30s") as info: + await transport(Request(method="GET", url="http://example.com/")) + assert isinstance(info.value.__cause__, httpx2.ReadTimeout) + + +# ----- (o) lazy event-loop binding ------------------------------------------ + + +def test_default_transport_is_lazy_pre_call() -> None: + transport = Httpx2Transport() + assert transport._client is None # noqa: SLF001 + + +async def test_default_transport_constructs_client_on_first_call() -> None: + transport = _make_transport(_status_handler(200)) + # _make_transport pre-supplies a client; assert post-call non-None invariant. + await transport(Request(method="GET", url="http://example.com/")) + assert transport._client is not None # noqa: SLF001 + await transport.aclose() + + +async def test_lazy_default_constructs_real_client_on_first_call() -> None: + transport = Httpx2Transport(limits=Limits(), timeout=Timeout()) + assert transport._client is None # noqa: SLF001 + # Touch _get_client directly to avoid network; lazy construction is what we test. + client = await transport._get_client() # noqa: SLF001 + assert isinstance(client, httpx2.AsyncClient) + assert transport._client is client # noqa: SLF001 + await transport.aclose() + + +async def test_concurrent_first_calls_initialize_client_once() -> None: + transport = Httpx2Transport(limits=Limits(), timeout=Timeout()) + clients = await asyncio.gather( + transport._get_client(), # noqa: SLF001 + transport._get_client(), # noqa: SLF001 + transport._get_client(), # noqa: SLF001 + ) + assert clients[0] is clients[1] is clients[2] + assert transport._client is clients[0] # noqa: SLF001 + await transport.aclose() + + +# ----- (p) constructor argument conflict ------------------------------------ + + +def test_constructor_rejects_client_plus_limits() -> None: + user_client = httpx2.AsyncClient() + with pytest.raises(ValueError, match="limits/timeout"): + Httpx2Transport(client=user_client, limits=Limits()) + + +def test_constructor_rejects_client_plus_timeout() -> None: + user_client = httpx2.AsyncClient() + with pytest.raises(ValueError, match="limits/timeout"): + Httpx2Transport(client=user_client, timeout=Timeout())