From 596bfdab46ff4464e12cafd81e659833da27edf0 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 14:05:26 +0300 Subject: [PATCH 1/7] docs(story-2.1): design for middleware protocol, Next type, and chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the archived Middleware Execution Model decision forward to the superpowers flow. Decisions: runtime-checkable Protocol matching Transport / ResponseDecoder; TypeAlias for Next (3.11 floor rules out PEP 695); private compose() at _internal/chain.py using a recursive closure fold with transport.__call__ as the bottom of the chain; Sequence[Middleware] over list; no exception handling in compose so CancelledError propagates naturally; public exports at both httpware.middleware.* and httpware.* (matches Request/Response). Strict epic boundary — decorators (2-2), Request helpers (2-3), auth coercion (2-4), AsyncClient wiring (2-5), and streaming chain (4-3) land as their own stories. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...31-middleware-protocol-and-chain-design.md | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-middleware-protocol-and-chain-design.md diff --git a/docs/superpowers/specs/2026-05-31-middleware-protocol-and-chain-design.md b/docs/superpowers/specs/2026-05-31-middleware-protocol-and-chain-design.md new file mode 100644 index 0000000..2682ee8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-middleware-protocol-and-chain-design.md @@ -0,0 +1,218 @@ +# Middleware protocol and chain composition (design) + +- **Date:** 2026-05-31 +- **Status:** approved, ready for plan +- **Scope:** Story 2-1 (first story of Epic 2). Defines the `Middleware` Protocol, `Next` type alias, and the `compose()` chain composer. Out of scope: decorators (2-2), `Request` helpers (2-3), auth coercion (2-4), AsyncClient wiring (2-5), streaming middleware chain (Epic 4). +- **Roadmap pointer:** `docs/engineering.md` §8 "Epic 2 — Compose request-handling logic via middleware". + +## Why + +Epic 2 makes `httpware` extensible: consumers write middleware to add tracing, signing, correlation IDs, etc., and built-in resilience (retry, bulkhead, timeout) lives on the same axis. Story 2-1 is the foundation — it ships the protocol surface, the `Next` callable type, and the composition mechanism. Nothing else in Epic 2 can land until this seam (Seam 2: `AsyncClient ↔ Middleware`) is defined. + +The shape is essentially decided by the archived architecture document (`docs/archive/architecture.md` "Middleware Execution Model"): a recursive async-callable onion. This spec ports that design forward with a few small choices that the archive left open. + +## Decisions + +| Decision | Choice | +| --- | --- | +| Protocol shape | `@runtime_checkable Protocol` with `async def __call__(self, request: Request, next: Next) -> Response`. Matches `Transport` and `ResponseDecoder`. | +| `Next` type | `Next: TypeAlias = Callable[[Request], Awaitable[Response]]`. PEP 695 `type Next = ...` would require 3.12+; project floor is 3.11. | +| Composition | Recursive closure fold via `_internal/chain.compose(middlewares, transport) -> Next`. Bottom of chain is `transport.__call__` (bound method, no wrapper). | +| Empty list | `compose([], transport)` returns `transport.__call__` directly (identity at the bottom). | +| Sequence type | `Sequence[Middleware]` over `list[Middleware]` — accepts tuples; no mutation required. | +| Cancellation | No `try`/`except` blocks in `compose` or `_wrap`; `CancelledError` and all other exceptions propagate untouched. Verified by test. | +| Scope | Strict epic boundary. Stories 2-2 through 2-5 land as their own units. | +| Public exports | Both `httpware.middleware.{Middleware, Next}` and `httpware.{Middleware, Next}`. Matches the existing `Request` / `Response` / `Httpx2Transport` re-export pattern at package root. | +| `compose` visibility | Private (`_internal/chain.compose`). Consumers don't compose chains; AsyncClient (Story 2-5) does. | + +## File structure + +**New files:** + +``` +src/httpware/ +├── middleware/ +│ └── __init__.py # Middleware Protocol + Next type alias (~25 lines) +└── _internal/ + ├── __init__.py # empty marker + └── chain.py # compose() + private _wrap() (~30 lines) +``` + +**Modified files:** + +``` +src/httpware/__init__.py # re-export Middleware, Next at package root +``` + +**New tests:** + +``` +tests/test_middleware.py # protocol surface + chain composition (~11 tests) +``` + +**Files not touched:** `request.py`, `response.py`, `errors.py`, `config.py`, `transports/`, `decoders/`. Story 2-1 is purely additive. + +## Protocol surface + +`src/httpware/middleware/__init__.py`: + +```python +"""Middleware protocol — the AsyncClient ↔ Middleware seam (Seam 2).""" + +from collections.abc import Awaitable, Callable +from typing import Protocol, TypeAlias, runtime_checkable + +from httpware.request import Request +from httpware.response import Response + + +Next: TypeAlias = Callable[[Request], Awaitable[Response]] + + +@runtime_checkable +class Middleware(Protocol): + """Structural protocol every middleware satisfies. + + A middleware receives the incoming `Request` and a `Next` callable. It may + inspect/transform the request, await `next(request)` to forward to the rest + of the chain (eventually the transport), inspect/transform the returned + `Response`, short-circuit by returning a `Response` without calling `next`, + or raise. + """ + + async def __call__(self, request: Request, next: Next) -> Response: + """Process `request`; call `next(request)` to forward, or synthesize a Response.""" + ... + + +__all__ = ["Middleware", "Next"] +``` + +Notes: +- `next` shadows the Python builtin in the method body. Standard for this pattern (ASGI convention). Implementers may name the parameter whatever they want; structural typing matches by position and type, not name. +- `@runtime_checkable` mirrors the other two structural protocols in the codebase (`Transport`, `ResponseDecoder`). Enables `isinstance(obj, Middleware)` for AsyncClient's per-construction validation in Story 2-5. The deferred-work entry on `_ProtocolMeta.__instancecheck__` µs-cost applies only if validation runs per-request; per-construction is fine. + +## Chain composition + +`src/httpware/_internal/chain.py`: + +```python +"""Middleware chain composition — wires a middleware list against a Transport. + +Private helper. AsyncClient calls `compose` at construction time and stores the +returned `Next` callable; per-request dispatch awaits that callable. +""" + +from collections.abc import Sequence + +from httpware.middleware import Middleware, Next +from httpware.request import Request +from httpware.response import Response +from httpware.transports import Transport + + +def compose(middlewares: Sequence[Middleware], transport: Transport) -> Next: + """Fold `middlewares` into a single `Next` callable terminating at `transport`. + + The outermost middleware in the input sequence is the first to receive the + request; its `next` argument forwards to the next middleware, and so on, + until the innermost middleware's `next` calls `transport.__call__`. An + empty sequence returns `transport.__call__` directly. + + The returned callable is reusable across many requests; it captures + references to `middlewares` and `transport` by closure. + """ + chain: Next = transport.__call__ + for middleware in reversed(middlewares): + chain = _wrap(middleware, chain) + return chain + + +def _wrap(middleware: Middleware, next_call: Next) -> Next: + async def _call(request: Request) -> Response: + return await middleware(request, next_call) + + return _call + + +__all__ = ["compose"] +``` + +Notes: +- Bottom of chain: `transport.__call__` is a bound method satisfying `Callable[[Request], Awaitable[Response]]`. Direct assignment; no wrapper coroutine. +- Each chain layer is one closure: `_wrap` captures `middleware` and `next_call`, returns a coroutine function with the `Next` signature. +- Empty sequence: `reversed(())` is a no-op iterator; `chain` stays as `transport.__call__`. Test asserts identity. +- No exception handling anywhere in the file. `CancelledError` and all other exceptions propagate up. This is the cancellation contract. + +## Public exports + +`src/httpware/__init__.py` adds: + +```python +from httpware.middleware import Middleware, Next +``` + +…and adds `"Middleware"` and `"Next"` to `__all__` in their alphabetic positions. + +Result: consumers can write either of: + +```python +from httpware import Middleware, Next +from httpware.middleware import Middleware, Next +``` + +Both work; the package-root path is the canonical user-facing one (matches `Request`, `Response`, `Httpx2Transport`). + +## Testing + +`tests/test_middleware.py` covers protocol + chain in one file. Approximate test list: + +| Test | Verifies | +| --- | --- | +| `test_empty_list_composes_to_transport_call` | `compose([], transport)(req)` returns the same response as awaiting `transport(req)` directly. (Behavioral check, not identity — the chain may add a thin terminal wrapper if the implementation needs one; see Risks.) | +| `test_single_middleware_wraps_transport` | One middleware sees the request, calls `next`, returns the transport's response. | +| `test_chain_runs_outer_to_inner` | Three middlewares append to a shared list; final order asserts outer → inner → transport → inner → outer (onion). | +| `test_short_circuit_returns_synthesized_response` | Middleware that does NOT call `next` returns a synthesized Response; transport (and any inner middleware) is never invoked. | +| `test_middleware_can_transform_request_before_forwarding` | Outer middleware mutates request via `with_header`; inner sees the mutation. | +| `test_middleware_can_transform_response_before_returning` | Outer middleware awaits `next`, returns a modified Response; caller sees the modification. | +| `test_exception_in_middleware_propagates` | Middleware raises a custom exception; bubbles through unchanged. | +| `test_exception_in_transport_propagates_through_chain` | Transport raises; exception passes through each middleware unmodified. | +| `test_cancelled_error_propagates_through_chain` | `asyncio.CancelledError` raised mid-chain propagates to the caller. Explicit per NFR15. | +| `test_runtime_checkable_isinstance_works` | `isinstance(some_middleware, Middleware)` returns True for a valid impl, False for an unrelated callable. | +| `test_compose_returned_callable_is_reusable` | The `Next` returned by `compose` can be awaited multiple times across sequential requests; closure captures don't accumulate state. | + +**Fixtures:** +- `FakeTransport` implementing `Transport`: `async def __call__` returns a fixed Response; `stream()` and `aclose()` stubs. Lives in `tests/test_middleware.py` (file-scoped — not yet shared with other test files). +- `record_calls(*labels)` factory: returns middleware-class instances that append labels to a shared list. Used by the ordering test. + +**Coverage expectation:** 100% line coverage on `middleware/__init__.py`, `_internal/__init__.py`, and `_internal/chain.py`. The Protocol method body (`...`) is excluded via standard coverage pragma if needed; typical for Protocol stubs. + +## Constraints and invariants + +- **No `httpx2` import.** Neither new file imports `httpx2`. The existing `tests/test_no_httpx2_leakage.py` continues to pass without modification. +- **No `from __future__ import annotations`.** PEP 604/585 syntax is native (already enforced repo-wide). +- **No `print()`, no global logging config.** Middleware module does no logging in Story 2-1; observability emission lands in Epic 5. +- **Type suppressions.** None expected. If `ty` flags the bound-method assignment to `Next`, suppress with `# ty: ignore[]` and document the reason. Should not be needed — `Transport.__call__` already has the matching signature. +- **Keyword-only construction.** N/A for Story 2-1 (no new dataclass or exception types). + +## Risks and mitigations + +| Risk | Mitigation | +| --- | --- | +| `ty` rejects `chain: Next = transport.__call__` because `Next` is a value-level alias and bound-method assignment is subtle. | If flagged, change to `chain: Next = transport.__call__`. If still flagged, fall back to `async def _terminal(req): return await transport(req)` at the bottom (one extra async frame per call; acceptable). Decided at implementation time, documented as a code comment if used. | +| Middleware swallows `CancelledError` silently (a user bug, not ours). | Story 2-2's `@on_error` decorator excludes `CancelledError` by design (catches `Exception` only). Story 2-1 itself has no exception handlers, so the protocol can't introduce this risk; it can only be introduced by user code. The cancellation test verifies the chain doesn't accidentally swallow it. | +| `runtime_checkable` µs-cost amortizes badly if AsyncClient calls `isinstance` per request (Story 1-7 deferred concern). | Story 1-7 / 2-5 spec must validate `middleware=[...]` at construction time only. Not a Story 2-1 problem to solve; flagged in the design pointer to the deferred-work entry. | +| Sequence vs list semantic surprise — a user passes a `dict.values()` view and expects fresh ordering each call. | `compose` consumes the sequence once (during construction) and stores closures over the captured `middleware` references. Subsequent mutations to the original sequence have no effect. Test `test_compose_returned_callable_is_reusable` covers reuse; no test for the dict-view edge case (not a real user pattern). | + +## Definition of done + +- `src/httpware/middleware/__init__.py` exports `Middleware` (runtime-checkable Protocol) and `Next` (TypeAlias). +- `src/httpware/_internal/__init__.py` exists as an empty package marker. +- `src/httpware/_internal/chain.py` exports `compose(middlewares, transport) -> Next`. +- `src/httpware/__init__.py` re-exports `Middleware` and `Next` at the package root and adds them to `__all__`. +- `tests/test_middleware.py` contains the 11 tests listed above; all pass. +- `just test` continues green; 100% line coverage on the new modules. +- `just lint-ci` clean: `ruff format --check`, `ruff check --no-fix`, `ty check` all pass. +- `tests/test_no_httpx2_leakage.py` still passes (no `httpx2` import added). +- CHANGELOG.md gets a `[Unreleased]` bullet for Story 2.1. +- Story 2-1 lands as a single PR off `main` via the branch `story/2-1-middleware-protocol-and-chain`. From cb623d6dafb2310ec4be535eb653c593c7a56297 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 14:17:04 +0300 Subject: [PATCH 2/7] feat(story-2.1): Middleware protocol and Next type alias Adds src/httpware/middleware/__init__.py defining: - Next: TypeAlias = Callable[[Request], Awaitable[Response]] - Middleware: @runtime_checkable Protocol with async __call__(request, next) Matches Transport and ResponseDecoder shape. The `next` parameter shadows the Python builtin (standard for this pattern); structural typing matches by position, so concrete middleware may rename it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpware/middleware/__init__.py | 29 +++++++++++++++++++++++++ tests/test_middleware.py | 33 +++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/httpware/middleware/__init__.py create mode 100644 tests/test_middleware.py diff --git a/src/httpware/middleware/__init__.py b/src/httpware/middleware/__init__.py new file mode 100644 index 0000000..3d94fa6 --- /dev/null +++ b/src/httpware/middleware/__init__.py @@ -0,0 +1,29 @@ +"""Middleware protocol — the AsyncClient ↔ Middleware seam (Seam 2).""" + +from collections.abc import Awaitable, Callable +from typing import Protocol, TypeAlias, runtime_checkable + +from httpware.request import Request +from httpware.response import Response + + +Next: TypeAlias = Callable[[Request], Awaitable[Response]] + + +@runtime_checkable +class Middleware(Protocol): + """Structural protocol every middleware satisfies. + + A middleware receives the incoming `Request` and a `Next` callable. It may + inspect/transform the request, await `next(request)` to forward to the rest + of the chain (eventually the transport), inspect/transform the returned + `Response`, short-circuit by returning a `Response` without calling `next`, + or raise. + """ + + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + """Process `request`; call `next(request)` to forward, or synthesize a Response.""" + ... + + +__all__ = ["Middleware", "Next"] diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 0000000..709008f --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,33 @@ +"""Tests for the Middleware protocol and chain composition.""" + +from collections.abc import Awaitable, Callable +from typing import get_type_hints + +from httpware.middleware import Middleware, Next +from httpware.request import Request +from httpware.response import Response + + +class _SignalMiddleware: + """Minimal valid Middleware implementation used by tests.""" + + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + return await next(request) + + +def test_runtime_checkable_isinstance_works() -> None: + """A class implementing `__call__` satisfies the Middleware Protocol at runtime.""" + # runtime_checkable checks for presence of __call__, not signature details + assert isinstance(_SignalMiddleware(), Middleware) + + +def test_next_type_alias_resolves_to_callable() -> None: + """`Next` resolves to `Callable[[Request], Awaitable[Response]]`.""" + expected = Callable[[Request], Awaitable[Response]] + assert Next == expected + + +def test_next_annotation_on_signal_middleware() -> None: + """`next` parameter on `_SignalMiddleware.__call__` is annotated with `Next`.""" + hints = get_type_hints(_SignalMiddleware.__call__) + assert hints["next"] == Next From 1b4d4af8cd331126b41c23a763d56ba17b1a375c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 14:19:59 +0300 Subject: [PATCH 3/7] feat(story-2.1): compose() chain composer with empty and single-middleware cases Adds src/httpware/_internal/chain.compose(middlewares, transport) -> Next using a recursive closure fold. Bottom of chain is transport.__call__ (bound method, no wrapper). Empty sequence returns transport.__call__ directly. Tests verify both cases against a minimal _OkTransport fixture. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/httpware/_internal/__init__.py | 1 + src/httpware/_internal/chain.py | 39 +++++++++++++++++++ tests/test_middleware.py | 60 +++++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/httpware/_internal/__init__.py create mode 100644 src/httpware/_internal/chain.py diff --git a/src/httpware/_internal/__init__.py b/src/httpware/_internal/__init__.py new file mode 100644 index 0000000..e13ba3f --- /dev/null +++ b/src/httpware/_internal/__init__.py @@ -0,0 +1 @@ +"""Private cross-module helpers (not part of the public API).""" diff --git a/src/httpware/_internal/chain.py b/src/httpware/_internal/chain.py new file mode 100644 index 0000000..15688bc --- /dev/null +++ b/src/httpware/_internal/chain.py @@ -0,0 +1,39 @@ +"""Middleware chain composition — wires a middleware list against a Transport. + +Private helper. AsyncClient calls `compose` at construction time and stores the +returned `Next` callable; per-request dispatch awaits that callable. +""" + +from collections.abc import Sequence + +from httpware.middleware import Middleware, Next +from httpware.request import Request +from httpware.response import Response +from httpware.transports import Transport + + +def compose(middlewares: Sequence[Middleware], transport: Transport) -> Next: + """Fold `middlewares` into a single `Next` callable terminating at `transport`. + + The outermost middleware in the input sequence is the first to receive the + request; its `next` argument forwards to the next middleware, and so on, + until the innermost middleware's `next` calls `transport.__call__`. An + empty sequence returns `transport.__call__` directly. + + The returned callable is reusable across many requests; it captures + references to `middlewares` and `transport` by closure. + """ + chain: Next = transport.__call__ + for middleware in reversed(middlewares): + chain = _wrap(middleware, chain) + return chain + + +def _wrap(middleware: Middleware, next_call: Next) -> Next: + async def _call(request: Request) -> Response: + return await middleware(request, next_call) + + return _call + + +__all__ = ["compose"] diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 709008f..bea4921 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,11 +1,13 @@ """Tests for the Middleware protocol and chain composition.""" from collections.abc import Awaitable, Callable +from contextlib import AbstractAsyncContextManager from typing import get_type_hints +from httpware._internal.chain import compose from httpware.middleware import Middleware, Next from httpware.request import Request -from httpware.response import Response +from httpware.response import Response, StreamResponse class _SignalMiddleware: @@ -31,3 +33,59 @@ def test_next_annotation_on_signal_middleware() -> None: """`next` parameter on `_SignalMiddleware.__call__` is annotated with `Next`.""" hints = get_type_hints(_SignalMiddleware.__call__) assert hints["next"] == Next + + +class _OkTransport: + """Minimal Transport: returns a fixed Response, no streaming, no aclose work.""" + + async def __call__(self, request: Request) -> Response: + return Response( + status=200, + headers={"x-from": "transport"}, + content=b"transport", + url=request.url, + elapsed=0.0, + ) + + def stream( # pragma: no cover - not exercised in 2-1 + self, request: Request + ) -> AbstractAsyncContextManager[StreamResponse]: + raise NotImplementedError + + async def aclose(self) -> None: # pragma: no cover - not exercised in 2-1 + return None + + +def _make_request(method: str = "GET", url: str = "https://example.test/") -> Request: + return Request(method=method, url=url) + + +async def test_empty_list_composes_to_transport_call() -> None: + """compose([], transport) yields a callable that behaves like transport(req).""" + transport = _OkTransport() + dispatch = compose([], transport) + + request = _make_request() + response = await dispatch(request) + + assert response.status == 200 # noqa: PLR2004 + assert response.content == b"transport" + assert response.headers["x-from"] == "transport" + + +async def test_single_middleware_wraps_transport() -> None: + """One middleware sees the request, calls next, returns the transport's response unchanged.""" + seen: list[Request] = [] + + class Tap: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + seen.append(request) + return await next(request) + + transport = _OkTransport() + request = _make_request() + + response = await compose([Tap()], transport)(request) + + assert seen == [request] + assert response.content == b"transport" From e2ebade552adfe2d80cb93f062359b972e227ade Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 14:21:25 +0300 Subject: [PATCH 4/7] test(story-2.1): chain ordering, request/response transformation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three tests verifying the onion-execution order (outer→inner→ transport→inner→outer), request mutation via with_header propagates to the inner middleware, and outer middleware can return a modified Response after awaiting next. No production code changes; the existing compose() implementation handles all three cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_middleware.py | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index bea4921..3113a95 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -89,3 +89,69 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 assert seen == [request] assert response.content == b"transport" + + +async def test_chain_runs_outer_to_inner() -> None: + """Three middlewares form an onion: outer→inner→transport→inner→outer.""" + log: list[str] = [] + + def labeled(name: str) -> Middleware: + class Labeled: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + log.append(f"{name}:before") + response = await next(request) + log.append(f"{name}:after") + return response + + return Labeled() + + dispatch = compose([labeled("A"), labeled("B"), labeled("C")], _OkTransport()) + await dispatch(_make_request()) + + assert log == [ + "A:before", + "B:before", + "C:before", + "C:after", + "B:after", + "A:after", + ] + + +async def test_middleware_can_transform_request_before_forwarding() -> None: + """An outer middleware mutates the request via with_header; the inner sees the mutation.""" + seen: list[Request] = [] + + class Stamp: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + stamped = request.with_header("x-trace", "abc123") + return await next(stamped) + + class Inspect: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + seen.append(request) + return await next(request) + + await compose([Stamp(), Inspect()], _OkTransport())(_make_request()) + + assert seen[0].headers["x-trace"] == "abc123" + + +async def test_middleware_can_transform_response_before_returning() -> None: + """An outer middleware awaits next, then returns a modified Response; caller sees it.""" + + class AddHeader: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + response = await next(request) + return Response( + status=response.status, + headers={**response.headers, "x-trace": "abc123"}, + content=response.content, + url=response.url, + elapsed=response.elapsed, + ) + + response = await compose([AddHeader()], _OkTransport())(_make_request()) + + assert response.headers["x-trace"] == "abc123" + assert response.headers["x-from"] == "transport" # original still present From a929686451a94b56c9de2adf9de6831a8a545538 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 14:24:16 +0300 Subject: [PATCH 5/7] test(story-2.1): short-circuit, exception propagation, cancellation, reusability Adds five tests covering the remaining acceptance criteria: - short-circuit middleware bypasses inner layers and the transport - exceptions raised inside middleware bubble through unchanged - exceptions raised by the transport pass through middleware unchanged - asyncio.CancelledError propagates (NFR15) - the Next returned by compose can be reused across sequential requests No production code changes; compose's no-try/except design carries the cancellation guarantee. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_middleware.py | 108 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 3113a95..8bbad5d 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,9 +1,12 @@ """Tests for the Middleware protocol and chain composition.""" +import asyncio from collections.abc import Awaitable, Callable from contextlib import AbstractAsyncContextManager from typing import get_type_hints +import pytest + from httpware._internal.chain import compose from httpware.middleware import Middleware, Next from httpware.request import Request @@ -155,3 +158,108 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 assert response.headers["x-trace"] == "abc123" assert response.headers["x-from"] == "transport" # original still present + + +async def test_short_circuit_returns_synthesized_response() -> None: + """A middleware that does NOT call next returns a synthesized Response; transport never runs.""" + transport_calls = 0 + + class CountingTransport(_OkTransport): + async def __call__(self, request: Request) -> Response: + nonlocal transport_calls + transport_calls += 1 + return await super().__call__(request) + + class ShortCircuit: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002, ARG002 + return Response( + status=418, + headers={}, + content=b"teapot", + url=request.url, + elapsed=0.0, + ) + + class NeverReached: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002, ARG002 + msg = "inner middleware should not be invoked" + raise AssertionError(msg) + + response = await compose([ShortCircuit(), NeverReached()], CountingTransport())(_make_request()) + + assert response.status == 418 # noqa: PLR2004 + assert response.content == b"teapot" + assert transport_calls == 0 + + +async def test_exception_in_middleware_propagates() -> None: + """A custom exception raised inside a middleware bubbles through the chain unchanged.""" + + class CustomError(Exception): + pass + + class Boom: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002, ARG002 + msg = "boom" + raise CustomError(msg) + + with pytest.raises(CustomError, match="boom"): + await compose([Boom()], _OkTransport())(_make_request()) + + +async def test_exception_in_transport_propagates_through_chain() -> None: + """An exception raised by the transport passes through every middleware unmodified.""" + + class TransportFail: + async def __call__(self, request: Request) -> Response: # noqa: ARG002 + msg = "transport failed" + raise RuntimeError(msg) + + def stream( # pragma: no cover - not exercised + self, request: Request + ) -> AbstractAsyncContextManager[StreamResponse]: + raise NotImplementedError + + async def aclose(self) -> None: # pragma: no cover - not exercised + return None + + class Passthrough: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + return await next(request) + + with pytest.raises(RuntimeError, match="transport failed"): + await compose([Passthrough(), Passthrough()], TransportFail())(_make_request()) + + +async def test_cancelled_error_propagates_through_chain() -> None: + """asyncio.CancelledError raised mid-chain propagates to the caller (NFR15).""" + + class Cancel: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002, ARG002 + raise asyncio.CancelledError + + class Passthrough: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + return await next(request) + + with pytest.raises(asyncio.CancelledError): + await compose([Passthrough(), Cancel()], _OkTransport())(_make_request()) + + +async def test_compose_returned_callable_is_reusable() -> None: + """The Next returned by compose can be awaited sequentially across multiple requests.""" + count = 0 + + class Counter: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + nonlocal count + count += 1 + return await next(request) + + dispatch = compose([Counter()], _OkTransport()) + + for _ in range(3): + response = await dispatch(_make_request()) + assert response.status == 200 # noqa: PLR2004 + + assert count == 3 # noqa: PLR2004 From 44de6430c8db63b1b68dcdb577ad176e29584062 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 14:26:15 +0300 Subject: [PATCH 6/7] feat(story-2.1): re-export Middleware and Next at httpware package root Adds Middleware and Next to httpware/__init__.py imports and __all__ so consumers can `from httpware import Middleware, Next` in addition to the subpackage path. Matches the existing Request/Response/Transport re-export pattern. CHANGELOG records the Story 2.1 surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + src/httpware/__init__.py | 3 +++ tests/test_middleware.py | 10 ++++++++++ 3 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08d7431..6a99d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,5 +19,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - 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). - `ResponseDecoder` protocol (`@runtime_checkable`) and default `PydanticDecoder` adapter — single-parse-pass JSON decoding via `pydantic.TypeAdapter.validate_json(bytes)`; a module-level `@functools.lru_cache(maxsize=None)` factory (`_get_adapter`) memoizes one `TypeAdapter` per `response_model` across the process so warm-path requests pay zero adapter-construction cost; `pydantic.ValidationError` surfaces unchanged to the caller (Story 1.5). +- `Middleware` protocol (`@runtime_checkable`) and `Next` callable type alias (`Callable[[Request], Awaitable[Response]]`); private `compose(middlewares, transport)` chain composer at `httpware._internal.chain` using a recursive closure fold with `transport.__call__` as the bottom of the chain. No exception handling inside `compose`, so `asyncio.CancelledError` and user-raised exceptions propagate untouched (Story 2.1). [Unreleased]: https://github.com/modern-python/httpware/commits/main diff --git a/src/httpware/__init__.py b/src/httpware/__init__.py index bf94a04..3933018 100644 --- a/src/httpware/__init__.py +++ b/src/httpware/__init__.py @@ -21,6 +21,7 @@ UnauthorizedError, UnprocessableEntityError, ) +from httpware.middleware import Middleware, Next from httpware.request import Request from httpware.response import Response, StreamResponse from httpware.transports import Transport @@ -38,6 +39,8 @@ "Httpx2Transport", "InternalServerError", "Limits", + "Middleware", + "Next", "NotFoundError", "PydanticDecoder", "RateLimitedError", diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 8bbad5d..ca57690 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -263,3 +263,13 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 assert response.status == 200 # noqa: PLR2004 assert count == 3 # noqa: PLR2004 + + +def test_middleware_and_next_are_reexported_at_package_root() -> None: + """`from httpware import Middleware, Next` works in addition to the subpackage path.""" + import httpware # noqa: PLC0415 + + assert httpware.Middleware is Middleware + assert httpware.Next is Next + assert "Middleware" in httpware.__all__ + assert "Next" in httpware.__all__ From 6111cdbcd1c51370167ea0c9ed08fe76cca9f63c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 31 May 2026 14:26:53 +0300 Subject: [PATCH 7/7] docs(story-2.1): implementation plan for Middleware protocol and chain Co-Authored-By: Claude Opus 4.7 (1M context) --- ...5-31-middleware-protocol-and-chain-plan.md | 830 ++++++++++++++++++ 1 file changed, 830 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-middleware-protocol-and-chain-plan.md diff --git a/docs/superpowers/plans/2026-05-31-middleware-protocol-and-chain-plan.md b/docs/superpowers/plans/2026-05-31-middleware-protocol-and-chain-plan.md new file mode 100644 index 0000000..51870e8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-middleware-protocol-and-chain-plan.md @@ -0,0 +1,830 @@ +# Middleware protocol and chain composition Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship Story 2-1: a `Middleware` runtime-checkable Protocol, a `Next` type alias, and a private `compose()` chain composer at `_internal/chain.py`. No decorators, no built-in middleware, no AsyncClient wiring (those are Stories 2-2 through 2-5). + +**Architecture:** Three new module files plus one test file. `Middleware` and `Next` live at `src/httpware/middleware/__init__.py` and re-export at the package root. `compose(middlewares, transport) -> Next` lives at `src/httpware/_internal/chain.py` and uses a recursive closure fold with `transport.__call__` as the bottom of the chain. No exception handling anywhere in compose — `CancelledError` and all other exceptions propagate untouched. + +**Tech Stack:** Python 3.11 floor. `typing.Protocol`, `typing.TypeAlias`, `typing.runtime_checkable`. No new dependencies, no new extras, no pyproject.toml changes. + +**Branch:** `story/2-1-middleware-protocol-and-chain` (already created; the spec commit is on it). + +**Spec:** `docs/superpowers/specs/2026-05-31-middleware-protocol-and-chain-design.md`. + +--- + +## File Structure + +**New files:** +- `src/httpware/middleware/__init__.py` — `Middleware` Protocol + `Next` type alias. ~25 lines. +- `src/httpware/_internal/__init__.py` — empty package marker. 1 line (module docstring). +- `src/httpware/_internal/chain.py` — `compose()` + private `_wrap()`. ~30 lines. +- `tests/test_middleware.py` — 11 tests, ~150 lines. + +**Modified files:** +- `src/httpware/__init__.py` — add `Middleware` and `Next` to imports and `__all__`. +- `CHANGELOG.md` — add an `[Unreleased]` bullet for Story 2.1. + +**Files untouched (deliberate):** +- `src/httpware/request.py`, `response.py`, `errors.py`, `config.py`, `transports/`, `decoders/` — Story 2-1 is purely additive. +- `pyproject.toml`, `Justfile`, `.github/workflows/` — no tooling changes. +- `tests/test_no_httpx2_leakage.py` — must continue to pass without modification. + +--- + +## Task 1: `Middleware` Protocol and `Next` type alias + +Define the public protocol surface. TDD cycle: write a structural-check test, then the protocol module. + +**Files:** +- Create: `src/httpware/middleware/__init__.py` +- Create: `tests/test_middleware.py` (test file itself, populated incrementally; this task seeds it) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_middleware.py`: + +```python +"""Tests for the Middleware protocol and chain composition.""" + +from typing import get_type_hints + +from httpware.middleware import Middleware, Next +from httpware.request import Request +from httpware.response import Response + + +class _SignalMiddleware: + """Minimal valid Middleware implementation used by tests.""" + + async def __call__(self, request: Request, next: Next) -> Response: + return await next(request) + + +def test_runtime_checkable_isinstance_works() -> None: + """A class implementing `__call__` with the right signature satisfies the Protocol.""" + + assert isinstance(_SignalMiddleware(), Middleware) + + def plain_callable(_req: Request) -> Response: # wrong signature: 1 arg, sync + raise NotImplementedError + + assert not isinstance(plain_callable, Middleware) + + +def test_next_type_alias_is_a_callable_protocol() -> None: + """`Next` is `Callable[[Request], Awaitable[Response]]` — verified by inspecting the alias target.""" + + hints = get_type_hints(_SignalMiddleware.__call__) + assert hints["next"] is Next + # `Next` is a TypeAlias to Callable[[Request], Awaitable[Response]]; identity check above + # is sufficient because the alias is publicly exported as a value. +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_middleware.py -v` + +Expected: `ModuleNotFoundError: No module named 'httpware.middleware'`. + +- [ ] **Step 3: Create the middleware module** + +Create `src/httpware/middleware/__init__.py`: + +```python +"""Middleware protocol — the AsyncClient ↔ Middleware seam (Seam 2).""" + +from collections.abc import Awaitable, Callable +from typing import Protocol, TypeAlias, runtime_checkable + +from httpware.request import Request +from httpware.response import Response + + +Next: TypeAlias = Callable[[Request], Awaitable[Response]] + + +@runtime_checkable +class Middleware(Protocol): + """Structural protocol every middleware satisfies. + + A middleware receives the incoming `Request` and a `Next` callable. It may + inspect/transform the request, await `next(request)` to forward to the rest + of the chain (eventually the transport), inspect/transform the returned + `Response`, short-circuit by returning a `Response` without calling `next`, + or raise. + """ + + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + """Process `request`; call `next(request)` to forward, or synthesize a Response.""" + ... + + +__all__ = ["Middleware", "Next"] +``` + +The `# noqa: A002` suppresses the ruff "argument shadows a Python builtin" check on the `next` parameter name. The shadowing is intentional and standard for this pattern (matches ASGI conventions). Structural typing matches by position and type, not parameter name, so implementers may rename it (and almost certainly should) when writing concrete middleware. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_middleware.py -v` + +Expected: 2 passed. + +- [ ] **Step 5: Lint and ty** + +Run: `uv run ruff check src/httpware/middleware/ tests/test_middleware.py` +Expected: All checks passed. + +Run: `uv run ty check src/httpware/middleware/` +Expected: All checks passed. + +- [ ] **Step 6: Commit** + +```bash +git add src/httpware/middleware/__init__.py tests/test_middleware.py +git commit -m "$(cat <<'EOF' +feat(story-2.1): Middleware protocol and Next type alias + +Adds src/httpware/middleware/__init__.py defining: +- Next: TypeAlias = Callable[[Request], Awaitable[Response]] +- Middleware: @runtime_checkable Protocol with async __call__(request, next) + +Matches Transport and ResponseDecoder shape. The `next` parameter +shadows the Python builtin (standard for this pattern); structural +typing matches by position, so concrete middleware may rename it. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: `compose()` skeleton — empty list and single middleware + +Build the smallest `compose` that satisfies the empty-list and single-middleware cases. + +**Files:** +- Create: `src/httpware/_internal/__init__.py` +- Create: `src/httpware/_internal/chain.py` +- Modify: `tests/test_middleware.py` (append tests) + +- [ ] **Step 1: Add the failing tests** + +Append to `tests/test_middleware.py`: + +```python +import pytest + +from httpware._internal.chain import compose + + +class _OkTransport: + """Minimal Transport: returns a fixed Response, no streaming, no aclose work.""" + + async def __call__(self, request: Request) -> Response: + return Response( + status=200, + headers={"x-from": "transport"}, + content=b"transport", + url=request.url, + elapsed=0.0, + ) + + def stream(self, request: Request): # pragma: no cover - not exercised in 2-1 + raise NotImplementedError + + async def aclose(self) -> None: # pragma: no cover - not exercised in 2-1 + return None + + +def _make_request(method: str = "GET", url: str = "https://example.test/") -> Request: + return Request(method=method, url=url) + + +async def test_empty_list_composes_to_transport_call() -> None: + """compose([], transport) yields a callable that behaves like transport(req).""" + + transport = _OkTransport() + dispatch = compose([], transport) + + request = _make_request() + response = await dispatch(request) + + assert response.status == 200 + assert response.content == b"transport" + assert response.headers["x-from"] == "transport" + + +async def test_single_middleware_wraps_transport() -> None: + """One middleware sees the request, calls next, returns the transport's response unchanged.""" + + seen: list[Request] = [] + + class Tap: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + seen.append(request) + return await next(request) + + transport = _OkTransport() + request = _make_request() + + response = await compose([Tap()], transport)(request) + + assert seen == [request] + assert response.content == b"transport" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_middleware.py -v` +Expected: 2 prior pass; the 2 new tests fail with `ModuleNotFoundError: No module named 'httpware._internal'`. + +- [ ] **Step 3: Create the `_internal` package** + +Create `src/httpware/_internal/__init__.py`: + +```python +"""Private cross-module helpers (not part of the public API).""" +``` + +- [ ] **Step 4: Create the chain module with compose()** + +Create `src/httpware/_internal/chain.py`: + +```python +"""Middleware chain composition — wires a middleware list against a Transport. + +Private helper. AsyncClient calls `compose` at construction time and stores the +returned `Next` callable; per-request dispatch awaits that callable. +""" + +from collections.abc import Sequence + +from httpware.middleware import Middleware, Next +from httpware.request import Request +from httpware.response import Response +from httpware.transports import Transport + + +def compose(middlewares: Sequence[Middleware], transport: Transport) -> Next: + """Fold `middlewares` into a single `Next` callable terminating at `transport`. + + The outermost middleware in the input sequence is the first to receive the + request; its `next` argument forwards to the next middleware, and so on, + until the innermost middleware's `next` calls `transport.__call__`. An + empty sequence returns `transport.__call__` directly. + + The returned callable is reusable across many requests; it captures + references to `middlewares` and `transport` by closure. + """ + chain: Next = transport.__call__ + for middleware in reversed(middlewares): + chain = _wrap(middleware, chain) + return chain + + +def _wrap(middleware: Middleware, next_call: Next) -> Next: + async def _call(request: Request) -> Response: + return await middleware(request, next_call) + + return _call + + +__all__ = ["compose"] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_middleware.py -v` +Expected: 4 passed. + +- [ ] **Step 6: Lint and ty** + +Run: `uv run ruff check src/httpware/_internal/ tests/test_middleware.py` +Expected: All checks passed. + +Run: `uv run ty check src/httpware/_internal/` +Expected: All checks passed. + +If `ty` flags `chain: Next = transport.__call__` (e.g., complaining about assigning a bound method to a `Callable` alias), fall back to wrapping the bottom: + +```python +async def _terminal(request: Request) -> Response: + return await transport(request) + +chain: Next = _terminal +``` + +…and add a code comment explaining why. The behavioral test still passes; only the identity of the empty-list result changes. (No identity assertion is made by any test, so no test edit needed.) + +- [ ] **Step 7: Commit** + +```bash +git add src/httpware/_internal/__init__.py src/httpware/_internal/chain.py tests/test_middleware.py +git commit -m "$(cat <<'EOF' +feat(story-2.1): compose() chain composer with empty and single-middleware cases + +Adds src/httpware/_internal/chain.compose(middlewares, transport) -> Next +using a recursive closure fold. Bottom of chain is transport.__call__ +(bound method, no wrapper). Empty sequence returns transport.__call__ +directly. Tests verify both cases against a minimal _OkTransport fixture. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Chain ordering and request/response transformations + +Verify that the existing `compose()` implementation handles the onion ordering and intermediate transformations correctly. No new production code is expected; if a test reveals a gap, fix it locally. + +**Files:** +- Modify: `tests/test_middleware.py` (append tests) + +- [ ] **Step 1: Add the failing/passing tests** + +Append to `tests/test_middleware.py`: + +```python +async def test_chain_runs_outer_to_inner() -> None: + """Three middlewares form an onion: outer→inner→transport→inner→outer.""" + + log: list[str] = [] + + def labeled(name: str): + class Labeled: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + log.append(f"{name}:before") + response = await next(request) + log.append(f"{name}:after") + return response + + return Labeled() + + dispatch = compose([labeled("A"), labeled("B"), labeled("C")], _OkTransport()) + await dispatch(_make_request()) + + assert log == [ + "A:before", + "B:before", + "C:before", + "C:after", + "B:after", + "A:after", + ] + + +async def test_middleware_can_transform_request_before_forwarding() -> None: + """An outer middleware mutates the request via with_header; the inner sees the mutation.""" + + seen: list[Request] = [] + + class Stamp: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + stamped = request.with_header("x-trace", "abc123") + return await next(stamped) + + class Inspect: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + seen.append(request) + return await next(request) + + await compose([Stamp(), Inspect()], _OkTransport())(_make_request()) + + assert seen[0].headers["x-trace"] == "abc123" + + +async def test_middleware_can_transform_response_before_returning() -> None: + """An outer middleware awaits next, then returns a modified Response; caller sees it.""" + + class AddHeader: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + response = await next(request) + return Response( + status=response.status, + headers={**response.headers, "x-trace": "abc123"}, + content=response.content, + url=response.url, + elapsed=response.elapsed, + ) + + response = await compose([AddHeader()], _OkTransport())(_make_request()) + + assert response.headers["x-trace"] == "abc123" + assert response.headers["x-from"] == "transport" # original still present +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `uv run pytest tests/test_middleware.py -v` +Expected: 7 passed. + +If `test_chain_runs_outer_to_inner` fails with the wrong order, the loop direction in `compose` is wrong — verify the `reversed()` is present and the bottom of the chain is the transport (not the first middleware). + +- [ ] **Step 3: Lint** + +Run: `uv run ruff check tests/test_middleware.py` +Expected: All checks passed. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_middleware.py +git commit -m "$(cat <<'EOF' +test(story-2.1): chain ordering, request/response transformation + +Adds three tests verifying the onion-execution order (outer→inner→ +transport→inner→outer), request mutation via with_header propagates to +the inner middleware, and outer middleware can return a modified +Response after awaiting next. No production code changes; the existing +compose() implementation handles all three cases. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Short-circuit, exception propagation, and cancellation + +The remaining behavioral tests: middleware that doesn't call `next`, exceptions in middleware and transport, and `CancelledError` propagation. None should require production-code changes. + +**Files:** +- Modify: `tests/test_middleware.py` (append tests) + +- [ ] **Step 1: Add the failing/passing tests** + +Append to `tests/test_middleware.py`: + +```python +import asyncio + + +async def test_short_circuit_returns_synthesized_response() -> None: + """A middleware that does NOT call next returns a synthesized Response; transport never runs.""" + + transport_calls = 0 + + class CountingTransport(_OkTransport): + async def __call__(self, request: Request) -> Response: + nonlocal transport_calls + transport_calls += 1 + return await super().__call__(request) + + class ShortCircuit: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + return Response( + status=418, + headers={}, + content=b"teapot", + url=request.url, + elapsed=0.0, + ) + + class NeverReached: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + raise AssertionError("inner middleware should not be invoked") + + response = await compose([ShortCircuit(), NeverReached()], CountingTransport())(_make_request()) + + assert response.status == 418 + assert response.content == b"teapot" + assert transport_calls == 0 + + +async def test_exception_in_middleware_propagates() -> None: + """A custom exception raised inside a middleware bubbles through the chain unchanged.""" + + class CustomError(Exception): + pass + + class Boom: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + raise CustomError("boom") + + with pytest.raises(CustomError, match="boom"): + await compose([Boom()], _OkTransport())(_make_request()) + + +async def test_exception_in_transport_propagates_through_chain() -> None: + """An exception raised by the transport passes through every middleware unmodified.""" + + class TransportFail: + async def __call__(self, request: Request) -> Response: + raise RuntimeError("transport failed") + + def stream(self, request: Request): # pragma: no cover - not exercised + raise NotImplementedError + + async def aclose(self) -> None: # pragma: no cover - not exercised + return None + + class Passthrough: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + return await next(request) + + with pytest.raises(RuntimeError, match="transport failed"): + await compose([Passthrough(), Passthrough()], TransportFail())(_make_request()) + + +async def test_cancelled_error_propagates_through_chain() -> None: + """asyncio.CancelledError raised mid-chain propagates to the caller (NFR15).""" + + class Cancel: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + raise asyncio.CancelledError + + class Passthrough: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + return await next(request) + + with pytest.raises(asyncio.CancelledError): + await compose([Passthrough(), Cancel()], _OkTransport())(_make_request()) + + +async def test_compose_returned_callable_is_reusable() -> None: + """The Next returned by compose can be awaited sequentially across multiple requests.""" + + count = 0 + + class Counter: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + nonlocal count + count += 1 + return await next(request) + + dispatch = compose([Counter()], _OkTransport()) + + for _ in range(3): + response = await dispatch(_make_request()) + assert response.status == 200 + + assert count == 3 +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `uv run pytest tests/test_middleware.py -v` +Expected: 12 passed. + +(The plan defines 12 tests so far: 2 from Task 1 + 2 from Task 2 + 3 from Task 3 + 5 from Task 4. Task 5 adds one more re-export test for a final total of 13. The spec's table lists 11 — the two extras the plan adds are `test_next_type_alias_is_a_callable_protocol` and `test_middleware_and_next_are_reexported_at_package_root`.) + +- [ ] **Step 3: Lint** + +Run: `uv run ruff check tests/test_middleware.py` +Expected: All checks passed. + +- [ ] **Step 4: Verify no `httpx2` leakage was introduced** + +Run: `uv run pytest tests/test_no_httpx2_leakage.py -v` +Expected: pass. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_middleware.py +git commit -m "$(cat <<'EOF' +test(story-2.1): short-circuit, exception propagation, cancellation, reusability + +Adds five tests covering the remaining acceptance criteria: +- short-circuit middleware bypasses inner layers and the transport +- exceptions raised inside middleware bubble through unchanged +- exceptions raised by the transport pass through middleware unchanged +- asyncio.CancelledError propagates (NFR15) +- the Next returned by compose can be reused across sequential requests + +No production code changes; compose's no-try/except design carries +the cancellation guarantee. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Public exports and CHANGELOG + +Wire `Middleware` and `Next` into the package root and add a CHANGELOG bullet. + +**Files:** +- Modify: `src/httpware/__init__.py` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add the failing import test** + +Append to `tests/test_middleware.py`: + +```python +def test_middleware_and_next_are_reexported_at_package_root() -> None: + """`from httpware import Middleware, Next` works in addition to the subpackage path.""" + + import httpware + + assert httpware.Middleware is Middleware + assert httpware.Next is Next + assert "Middleware" in httpware.__all__ + assert "Next" in httpware.__all__ +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_middleware.py::test_middleware_and_next_are_reexported_at_package_root -v` +Expected: `AttributeError: module 'httpware' has no attribute 'Middleware'`. + +- [ ] **Step 3: Add the imports to `src/httpware/__init__.py`** + +Edit `src/httpware/__init__.py`. After the existing `from httpware.errors import (...)` block (or in alphabetic position among the imports), add: + +```python +from httpware.middleware import Middleware, Next +``` + +In the `__all__` list, insert `"Middleware"` and `"Next"` in alphabetic position. The list is alphabetically sorted; place `"Middleware"` between `"Limits"` and `"NotFoundError"`, and `"Next"` between `"NotFoundError"` and `"PydanticDecoder"`. The final `__all__` (relative additions) should look like: + +```python +__all__ = [ + "STATUS_TO_EXCEPTION", + "BadRequestError", + "ClientConfig", + "ClientError", + "ClientStatusError", + "ConflictError", + "ForbiddenError", + "Httpx2Transport", + "InternalServerError", + "Limits", + "Middleware", # NEW + "Next", # NEW + "NotFoundError", + "PydanticDecoder", + "RateLimitedError", + "Request", + "Response", + "ResponseDecoder", + "ServerStatusError", + "ServiceUnavailableError", + "StatusError", + "StreamResponse", + "Timeout", + "TimeoutError", + "Transport", + "TransportError", + "UnauthorizedError", + "UnprocessableEntityError", +] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_middleware.py -v` +Expected: 13 passed. + +- [ ] **Step 5: Update CHANGELOG** + +Edit `CHANGELOG.md`. In the `## [Unreleased]` → `### Added` section, append a new bullet at the end of the list (after the Story 1.5 bullet): + +```markdown +- `Middleware` protocol (`@runtime_checkable`) and `Next` callable type alias (`Callable[[Request], Awaitable[Response]]`); private `compose(middlewares, transport)` chain composer at `httpware._internal.chain` using a recursive closure fold with `transport.__call__` as the bottom of the chain. No exception handling inside `compose`, so `asyncio.CancelledError` and user-raised exceptions propagate untouched (Story 2.1). +``` + +- [ ] **Step 6: Lint and ty** + +Run: `uv run ruff check src/httpware/__init__.py tests/test_middleware.py` +Expected: All checks passed. + +Run: `uv run ty check src/httpware/__init__.py` +Expected: All checks passed. + +- [ ] **Step 7: Commit** + +```bash +git add src/httpware/__init__.py tests/test_middleware.py CHANGELOG.md +git commit -m "$(cat <<'EOF' +feat(story-2.1): re-export Middleware and Next at httpware package root + +Adds Middleware and Next to httpware/__init__.py imports and __all__ +so consumers can `from httpware import Middleware, Next` in addition +to the subpackage path. Matches the existing Request/Response/Transport +re-export pattern. CHANGELOG records the Story 2.1 surface. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Full verification, push, and PR + +End-to-end sanity check on the branch, push, open PR, wait for CI. + +- [ ] **Step 1: Run the full test suite with coverage** + +Run: `just test` +Expected: 170 passed (157 baseline + 13 new), 1 deselected (perf bench), 100% line coverage including the new modules. + +If coverage is below 100% on `middleware/__init__.py`, `_internal/__init__.py`, or `_internal/chain.py`, identify the uncovered line. The Protocol method body (`...`) typically reports as uncovered — add `# pragma: no cover` on the `...` line if so. + +- [ ] **Step 2: Run full lint and type checks** + +Run: `just lint-ci` +Expected: `ruff format --check`, `ruff check --no-fix`, `ty check` all clean. + +- [ ] **Step 3: Confirm the working tree is clean** + +Run: `git status --short` +Expected: empty output (nothing to commit, no untracked files). + +- [ ] **Step 4: Review the branch diff** + +Run: `git log --oneline main..HEAD` +Expected: five or six commits — the spec commit (`docs(story-2.1): design...`), Task 1, Task 2, Task 3, Task 4, Task 5. + +Run: `git diff --stat main..HEAD` +Expected: changes to `CHANGELOG.md`, `docs/superpowers/specs/2026-05-31-middleware-protocol-and-chain-design.md`, `docs/superpowers/plans/2026-05-31-middleware-protocol-and-chain-plan.md`, `src/httpware/__init__.py`, two new files under `src/httpware/_internal/`, one new file under `src/httpware/middleware/`, and `tests/test_middleware.py`. No source files outside this scope should be touched. + +- [ ] **Step 5: Stage and commit the plan file** + +The plan file at `docs/superpowers/plans/2026-05-31-middleware-protocol-and-chain-plan.md` is still untracked (it was created during the writing-plans step but not yet committed). Stage and commit it on this branch so the merge captures the plan alongside the spec. + +Run: +```bash +git add docs/superpowers/plans/2026-05-31-middleware-protocol-and-chain-plan.md +git commit -m "docs(story-2.1): implementation plan for Middleware protocol and chain + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +- [ ] **Step 6: Push the branch** + +Run: `git push -u origin story/2-1-middleware-protocol-and-chain` +Expected: push succeeds; GitHub prints a "Create a pull request for ..." URL. + +- [ ] **Step 7: Open the PR** + +Run: +```bash +gh pr create --title "feat(story-2.1): Middleware protocol, Next type, and chain composition" --body "$(cat <<'EOF' +## Summary + +- Adds the `AsyncClient ↔ Middleware` seam (Seam 2): `Middleware` runtime-checkable Protocol with `async def __call__(self, request: Request, next: Next) -> Response`, and `Next = Callable[[Request], Awaitable[Response]]` exported at both `httpware.middleware.*` and `httpware.*`. +- Adds the private `compose(middlewares, transport) -> Next` at `httpware._internal.chain`. Recursive closure fold; `transport.__call__` is the bottom of the chain; empty list returns `transport.__call__` directly. No `try`/`except` in `compose` or `_wrap` — `asyncio.CancelledError` and user-raised exceptions propagate untouched (NFR15). +- 13 tests cover ordering (outer→inner onion), short-circuit, request and response transformation, exception propagation through middleware and transport, cancellation, runtime_checkable `isinstance`, package-root re-export, and reusability of the composed `Next`. + +Out of scope (subsequent stories): phase decorators (2-2), Request immutability helpers beyond what already exists (2-3), auth coercion (2-4), AsyncClient wiring (2-5), streaming chain (4-3). + +Spec + plan: `docs/superpowers/specs/2026-05-31-middleware-protocol-and-chain-design.md`, `docs/superpowers/plans/2026-05-31-middleware-protocol-and-chain-plan.md`. + +## Test plan + +- [x] `just test` — 170 passed, 1 deselected (perf), 100% line coverage including the new modules. +- [x] `just lint-ci` — `ruff format --check`, `ruff check --no-fix`, `ty check` all clean. +- [x] `tests/test_no_httpx2_leakage.py` passes — no `httpx2` import added. +- [x] `from httpware import Middleware, Next` and `from httpware.middleware import Middleware, Next` both resolve. +- [ ] CI green on all matrix entries. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 8: Wait for CI** + +Run: `gh pr checks` (the PR number is printed by `gh pr create`). +Expected: all five jobs (`lint`, `pytest (3.11)`, `pytest (3.12)`, `pytest (3.13)`, `pytest (3.14)`) green. + +If any check fails, identify which: CI's `lint` runs the same checks as `just lint-ci` and `pytest (3.x)` runs the same suite as `just test`. Fix locally on this branch, push the fix, wait again. + +- [ ] **Step 9: Merge** + +Once CI is green: + +Run: `gh pr merge --merge --delete-branch` +Expected: PR merged, branch deleted locally and on remote. + +Run: `git checkout main && git pull --ff-only && git log --oneline -3` +Expected: the cutover merge commit at HEAD, followed by the most recent Story 2.1 commit. + +Story 2-1 is complete. Story 2-2 (phase decorators) is the next normal-flow item. + +--- + +## Definition of done + +- `src/httpware/middleware/__init__.py` exists and exports `Middleware` (runtime-checkable Protocol) and `Next` (TypeAlias). +- `src/httpware/_internal/__init__.py` exists as an empty package marker. +- `src/httpware/_internal/chain.py` exists and exports `compose(middlewares, transport) -> Next`. +- `src/httpware/__init__.py` re-exports `Middleware` and `Next` at the package root and adds them to `__all__` in alphabetic position. +- `tests/test_middleware.py` contains 13 tests; all pass. +- `just test` shows 170 passed, 1 deselected, 100% line coverage including the new modules. +- `just lint-ci` clean (`ruff format --check`, `ruff check --no-fix`, `ty check`). +- `tests/test_no_httpx2_leakage.py` still passes. +- `CHANGELOG.md` has a Story 2.1 bullet under `[Unreleased]` → `### Added`. +- Both the spec and the plan are committed on `story/2-1-middleware-protocol-and-chain` and land via a single PR.