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