diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a99d6f..dc32377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,5 +20,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `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). +- Phase-shortcut decorators `@before_request`, `@after_response`, `@on_error` for lifecycle hooks without authoring a full `Middleware` class. `@on_error` catches `Exception` only (so `asyncio.CancelledError` propagates); its handler may return a `Response` to recover or `None` to re-raise (Story 2.2). [Unreleased]: https://github.com/modern-python/httpware/commits/main diff --git a/docs/engineering.md b/docs/engineering.md index 571b7d5..309d42a 100644 --- a/docs/engineering.md +++ b/docs/engineering.md @@ -142,7 +142,7 @@ Twenty-seven stories remain. Topic slugs in `docs/superpowers/specs/` and `docs/ ### Epic 2 — Compose request-handling logic via middleware - **2-1** `Middleware` protocol, `Next` type, chain composition. -- **2-2** Phase shortcut decorators (`@on_request`, `@on_response`, `@on_error`). +- **2-2** Phase shortcut decorators (`@before_request`, `@after_response`, `@on_error`). - **2-3** `Request` immutability helpers (`with_headers`, `with_cookie`, `with_extension`, etc.). - **2-4** Auth coercion as middleware. - **2-5** Wire middleware into `AsyncClient`. diff --git a/docs/superpowers/plans/2026-05-31-phase-shortcut-decorators-plan.md b/docs/superpowers/plans/2026-05-31-phase-shortcut-decorators-plan.md new file mode 100644 index 0000000..5c09a4d --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-phase-shortcut-decorators-plan.md @@ -0,0 +1,739 @@ +# Phase-shortcut decorators 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-2: three sync decorator factories `before_request`, `after_response`, `on_error` in `src/httpware/middleware/__init__.py` that wrap async user functions into `Middleware`-conforming instances. + +**Architecture:** Append three factory functions to the existing `middleware/__init__.py`. Each factory defines a private class inside its body, instantiates it, returns the instance. `f` is captured by closure; instance `__repr__` formats as ``. `@on_error` adds the only `try`/`except Exception` in the codebase's middleware seam — `CancelledError` flows past untouched. + +**Tech Stack:** Python 3.11 floor. No new dependencies, no pyproject.toml changes. + +**Branch:** `story/2-2-phase-shortcut-decorators` (already created; spec commit `6cfc9fa` is on it). + +**Spec:** `docs/superpowers/specs/2026-05-31-phase-shortcut-decorators-design.md`. + +--- + +## File Structure + +**Modified files:** +- `src/httpware/middleware/__init__.py` — append three factory functions (~65 lines added; file grows from 30 to ~95 lines). Update `__all__`. +- `src/httpware/__init__.py` — import and re-export `before_request`, `after_response`, `on_error`. Update `__all__`. +- `docs/engineering.md` — fix line 145 stale decorator names. +- `CHANGELOG.md` — append Story 2.2 bullet under `[Unreleased]` / `### Added`. +- `tests/test_middleware.py` — append 10 new tests (file grows from 14 → 24 tests). + +**Files untouched:** Every other source file. Story 2-2 is purely additive on top of Story 2-1. + +--- + +## Task 1: `@before_request` decorator + +TDD cycle: write the behavioral test for request transformation, then implement the smallest factory that satisfies it. + +**Files:** +- Modify: `src/httpware/middleware/__init__.py` (append factory) +- Modify: `tests/test_middleware.py` (append test) + +- [ ] **Step 1: Add the failing test** + +Append to `tests/test_middleware.py`. The existing imports already include `Request`, `Response`, `Middleware`, `Next`, `compose`, `_OkTransport`, `_make_request`. You will need to import `before_request`: + +```python +from httpware.middleware import before_request + + +async def test_before_request_transforms_request() -> None: + """@before_request wraps an async request transform; downstream sees the mutation.""" + + @before_request + async def stamp(request: Request) -> Request: + return request.with_header("x-trace", "abc123") + + seen: list[Request] = [] + + 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" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_middleware.py::test_before_request_transforms_request -v` +Expected: `ImportError: cannot import name 'before_request' from 'httpware.middleware'`. + +- [ ] **Step 3: Implement `before_request`** + +Append to `src/httpware/middleware/__init__.py` (after the `Middleware` class, before `__all__`): + +```python +def before_request(f: Callable[[Request], Awaitable[Request]]) -> Middleware: + """Wrap an async request transform into a Middleware. + + The decorated function receives the incoming Request and returns a + (possibly modified) Request, which is then forwarded down the chain. + """ + + class _BeforeRequestMiddleware: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + return await next(await f(request)) + + def __repr__(self) -> str: + return f"" + + return _BeforeRequestMiddleware() +``` + +Update `__all__` at the bottom of the file from `["Middleware", "Next"]` to `["Middleware", "Next", "before_request"]`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_middleware.py::test_before_request_transforms_request -v` +Expected: PASS. + +- [ ] **Step 5: Run the full test_middleware.py to confirm no regressions** + +Run: `uv run pytest tests/test_middleware.py -v` +Expected: 15 passed (14 prior + 1 new). + +- [ ] **Step 6: Lint and ty** + +Run: `uv run ruff check src/httpware/middleware/__init__.py tests/test_middleware.py` +Expected: All checks passed. + +Run: `uv run ty check src/httpware/middleware/__init__.py` +Expected: All checks passed. + +If ruff/`ty` flags the inner class for any reason, the standard mitigation is to mark suppressions with `# ty: ignore[]` or `# noqa: ` on the trigger line — but none expected. The structural Protocol match should work because the class has an `async __call__(self, request, next) -> Response` matching `Middleware`. + +- [ ] **Step 7: Commit** + +```bash +git add src/httpware/middleware/__init__.py tests/test_middleware.py +git commit -m "$(cat <<'EOF' +feat(story-2.2): @before_request decorator factory + +Wraps an async f(Request) -> Request into a Middleware that applies f +then forwards the (possibly transformed) request down the chain via +await next(...). Returns a private _BeforeRequestMiddleware instance +with a __repr__ that surfaces the original function name. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: `@after_response` decorator + +TDD cycle for the response-transform variant. Mirrors Task 1's shape. + +**Files:** +- Modify: `src/httpware/middleware/__init__.py` (append factory) +- Modify: `tests/test_middleware.py` (append test) + +- [ ] **Step 1: Add the failing test** + +Append to `tests/test_middleware.py`. Add `after_response` to the existing `from httpware.middleware import before_request` line so it reads `from httpware.middleware import after_response, before_request`: + +```python +async def test_after_response_transforms_response() -> None: + """@after_response wraps an async response transform; caller sees the modification.""" + + @after_response + async def add_header(request: Request, response: Response) -> Response: + return Response( + status=response.status, + headers={**response.headers, "x-trace": "abc123"}, + content=response.content, + url=response.url, + elapsed=response.elapsed, + ) + + response = await compose([add_header], _OkTransport())(_make_request()) + + assert response.headers["x-trace"] == "abc123" + assert response.headers["x-from"] == "transport" # original still present +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_middleware.py::test_after_response_transforms_response -v` +Expected: `ImportError: cannot import name 'after_response' from 'httpware.middleware'`. + +- [ ] **Step 3: Implement `after_response`** + +Append to `src/httpware/middleware/__init__.py` after the `before_request` factory: + +```python +def after_response(f: Callable[[Request, Response], Awaitable[Response]]) -> Middleware: + """Wrap an async response transform into a Middleware. + + The decorated function receives the original Request and the Response + returned by the chain, and returns a (possibly modified) Response. + """ + + class _AfterResponseMiddleware: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + response = await next(request) + return await f(request, response) + + def __repr__(self) -> str: + return f"" + + return _AfterResponseMiddleware() +``` + +Update `__all__` from `["Middleware", "Next", "before_request"]` to `["Middleware", "Next", "after_response", "before_request"]`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_middleware.py::test_after_response_transforms_response -v` +Expected: PASS. + +- [ ] **Step 5: Run the full test_middleware.py** + +Run: `uv run pytest tests/test_middleware.py -v` +Expected: 16 passed (15 prior + 1 new). + +- [ ] **Step 6: Lint and ty** + +Run: `uv run ruff check src/httpware/middleware/__init__.py tests/test_middleware.py` +Run: `uv run ty check src/httpware/middleware/__init__.py` +Expected: both clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/httpware/middleware/__init__.py tests/test_middleware.py +git commit -m "$(cat <<'EOF' +feat(story-2.2): @after_response decorator factory + +Wraps an async f(Request, Response) -> Response into a Middleware that +awaits next(...) then applies f to the result. Returns a private +_AfterResponseMiddleware instance with the standard __repr__. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: `@on_error` decorator + +The substantive decorator — adds the one `try`/`except Exception` in the seam. Four behavioral tests pin the contract: returning a Response swallows the exception, returning None re-raises, CancelledError flows past, and the handler receives the original exception instance. + +**Files:** +- Modify: `src/httpware/middleware/__init__.py` (append factory) +- Modify: `tests/test_middleware.py` (append fixture + 4 tests) + +- [ ] **Step 1: Add the failing tests** + +Append to `tests/test_middleware.py`. Add `on_error` to the existing import: `from httpware.middleware import after_response, before_request, on_error`. Then append: + +```python +class _FailingTransport: + """Transport whose __call__ raises a chosen exception.""" + + def __init__(self, exc: BaseException) -> None: + self._exc = exc + + async def __call__(self, request: Request) -> Response: + raise self._exc + + def stream(self, request: Request): # pragma: no cover - not exercised in 2-2 + raise NotImplementedError + + async def aclose(self) -> None: # pragma: no cover - not exercised in 2-2 + return None + + +async def test_on_error_returns_response_swallows_exception() -> None: + """When the handler returns a Response, the caller gets it; no exception escapes.""" + + @on_error + async def recover(request: Request, exc: Exception) -> Response | None: + return Response( + status=503, + headers={"x-recovered": "true"}, + content=b"recovered", + url=request.url, + elapsed=0.0, + ) + + transport = _FailingTransport(RuntimeError("boom")) + response = await compose([recover], transport)(_make_request()) + + assert response.status == 503 + assert response.headers["x-recovered"] == "true" + assert response.content == b"recovered" + + +async def test_on_error_returns_none_reraises() -> None: + """When the handler returns None, the original exception is re-raised with traceback intact.""" + + @on_error + async def pass_through(request: Request, exc: Exception) -> Response | None: + return None + + transport = _FailingTransport(RuntimeError("boom")) + + with pytest.raises(RuntimeError, match="boom"): + await compose([pass_through], transport)(_make_request()) + + +async def test_on_error_does_not_catch_cancelled_error() -> None: + """asyncio.CancelledError is not Exception; the handler must not be invoked.""" + + invocations: list[Exception] = [] + + @on_error + async def should_not_run(request: Request, exc: Exception) -> Response | None: + invocations.append(exc) + return None + + class Cancel: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await compose([should_not_run, Cancel()], _OkTransport())(_make_request()) + + assert invocations == [] + + +async def test_on_error_handler_receives_correct_exception_instance() -> None: + """The handler's `exc` parameter is the same instance the transport raised.""" + + raised = RuntimeError("specific instance") + seen: list[Exception] = [] + + @on_error + async def capture(request: Request, exc: Exception) -> Response | None: + seen.append(exc) + return None + + with pytest.raises(RuntimeError): + await compose([capture], _FailingTransport(raised))(_make_request()) + + assert seen == [raised] + assert seen[0] is raised +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_middleware.py -k "on_error" -v` +Expected: 4 errors with `ImportError: cannot import name 'on_error' from 'httpware.middleware'`. + +- [ ] **Step 3: Implement `on_error`** + +Append to `src/httpware/middleware/__init__.py` after the `after_response` factory: + +```python +def on_error(f: Callable[[Request, Exception], Awaitable[Response | None]]) -> Middleware: + """Wrap an async error handler into a Middleware. + + Catches Exception (not BaseException, so asyncio.CancelledError + propagates). If the handler returns a Response, that Response is + returned to the caller. If the handler returns None, the original + exception is re-raised. + """ + + class _OnErrorMiddleware: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + try: + return await next(request) + except Exception as exc: + result = await f(request, exc) + if result is None: + raise + return result + + def __repr__(self) -> str: + return f"" + + return _OnErrorMiddleware() +``` + +Update `__all__` from `["Middleware", "Next", "after_response", "before_request"]` to `["Middleware", "Next", "after_response", "before_request", "on_error"]`. + +- [ ] **Step 4: Run on_error tests to verify they pass** + +Run: `uv run pytest tests/test_middleware.py -k "on_error" -v` +Expected: 4 passed. + +- [ ] **Step 5: Run the full test_middleware.py** + +Run: `uv run pytest tests/test_middleware.py -v` +Expected: 20 passed (16 prior + 4 new). + +- [ ] **Step 6: Lint and ty** + +Run: `uv run ruff check src/httpware/middleware/__init__.py tests/test_middleware.py` +Run: `uv run ty check src/httpware/middleware/__init__.py` +Expected: both clean. + +If ruff flags BLE001 ("bare blind except") on the `except Exception as exc:` line, suppress with `# noqa: BLE001` and add a one-line code comment: `# We catch Exception deliberately; CancelledError is BaseException and propagates.` `BLE001` targets `except Exception` specifically. + +- [ ] **Step 7: Commit** + +```bash +git add src/httpware/middleware/__init__.py tests/test_middleware.py +git commit -m "$(cat <<'EOF' +feat(story-2.2): @on_error decorator factory + +Wraps an async f(Request, Exception) -> Response | None into a +Middleware. Catches Exception (not BaseException, so CancelledError +propagates). If the handler returns a Response, that becomes the +caller's response; if it returns None, the original exception is +re-raised with traceback intact via bare `raise`. + +Four tests pin the contract: recovery via Response, re-raise via None, +CancelledError flows past untouched, handler receives the original +exception instance. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Cross-decorator behavior tests + +Verify the three decorators interoperate: each satisfies the `Middleware` Protocol, mixes correctly in a `compose()` chain alongside class-based middleware, and renders a useful `repr()`. No new production code is expected. + +**Files:** +- Modify: `tests/test_middleware.py` (append 3 tests) + +- [ ] **Step 1: Add the tests** + +Append to `tests/test_middleware.py`: + +```python +def test_decorators_satisfy_middleware_protocol() -> None: + """Each decorator returns an object that isinstance() recognizes as Middleware.""" + + @before_request + async def br(request: Request) -> Request: + return request + + @after_response + async def ar(request: Request, response: Response) -> Response: + return response + + @on_error + async def oe(request: Request, exc: Exception) -> Response | None: + return None + + assert isinstance(br, Middleware) + assert isinstance(ar, Middleware) + assert isinstance(oe, Middleware) + + +async def test_decorated_middlewares_compose_in_chain() -> None: + """Phase decorators interoperate with class-based middleware in one compose() call.""" + + @before_request + async def stamp(request: Request) -> Request: + return request.with_header("x-stamp", "1") + + @after_response + async def tag(request: Request, response: Response) -> Response: + return Response( + status=response.status, + headers={**response.headers, "x-tag": "1"}, + content=response.content, + url=response.url, + elapsed=response.elapsed, + ) + + seen_headers: list[str] = [] + + class Inspect: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + seen_headers.append(request.headers.get("x-stamp", "")) + return await next(request) + + response = await compose([stamp, Inspect(), tag], _OkTransport())(_make_request()) + + assert seen_headers == ["1"] # stamp ran before Inspect + assert response.headers["x-tag"] == "1" # tag ran after the chain + + +def test_repr_shows_original_function_name() -> None: + """repr() includes the phase name and the original user function's qualname.""" + + @before_request + async def my_stamp(request: Request) -> Request: + return request + + text = repr(my_stamp) + assert "before_request" in text + assert "my_stamp" in text +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `uv run pytest tests/test_middleware.py -v` +Expected: 23 passed (20 prior + 3 new). + +- [ ] **Step 3: Lint** + +Run: `uv run ruff check tests/test_middleware.py` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_middleware.py +git commit -m "$(cat <<'EOF' +test(story-2.2): cross-decorator behavior (Protocol, chain, repr) + +Three tests verify the three decorators interoperate: +- isinstance() recognizes each as Middleware +- a mixed chain of @before_request + class middleware + @after_response + applies each phase in the correct position +- repr() surfaces the phase name and the original user function's + qualname for debug-friendly chain inspection + +No production code changes. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Public exports, engineering.md fix, CHANGELOG + +Wire the three decorators into the package root, fix the stale naming in `engineering.md`, and record the change in `CHANGELOG.md`. One re-export test. + +**Files:** +- Modify: `src/httpware/__init__.py` +- Modify: `docs/engineering.md` +- Modify: `CHANGELOG.md` +- Modify: `tests/test_middleware.py` (append 1 re-export test) + +- [ ] **Step 1: Add the failing re-export test** + +Append to `tests/test_middleware.py`: + +```python +def test_decorators_reexported_at_package_root() -> None: + """`from httpware import before_request, after_response, on_error` works.""" + + import httpware # noqa: PLC0415 + + assert httpware.before_request is before_request + assert httpware.after_response is after_response + assert httpware.on_error is on_error + assert "before_request" in httpware.__all__ + assert "after_response" in httpware.__all__ + assert "on_error" in httpware.__all__ +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_middleware.py::test_decorators_reexported_at_package_root -v` +Expected: `AttributeError: module 'httpware' has no attribute 'before_request'`. + +- [ ] **Step 3: Update `src/httpware/__init__.py`** + +Find the existing line `from httpware.middleware import Middleware, Next` and replace it with: + +```python +from httpware.middleware import Middleware, Next, after_response, before_request, on_error +``` + +In `__all__`, the existing list ends with `"UnprocessableEntityError"`. Append the three lowercase names after it (lowercase sorts after uppercase in ASCII): + +```python +__all__ = [ + # ... existing entries unchanged ... + "UnprocessableEntityError", + "after_response", + "before_request", + "on_error", +] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_middleware.py::test_decorators_reexported_at_package_root -v` +Expected: PASS. + +- [ ] **Step 5: Fix the engineering.md roadmap line** + +Edit `docs/engineering.md` line 145. The current text reads: + +``` +- **2-2** Phase shortcut decorators (`@on_request`, `@on_response`, `@on_error`). +``` + +Replace with: + +``` +- **2-2** Phase shortcut decorators (`@before_request`, `@after_response`, `@on_error`). +``` + +- [ ] **Step 6: Append a CHANGELOG bullet** + +Edit `CHANGELOG.md`. The `## [Unreleased]` / `### Added` section ends with the Story 2.1 bullet about the `Middleware` protocol and `compose`. Append a new bullet immediately after it (before the `[Unreleased]: ...` reference link line at the bottom of the file): + +```markdown +- Phase-shortcut decorators `@before_request`, `@after_response`, `@on_error` for lifecycle hooks without authoring a full `Middleware` class. `@on_error` catches `Exception` only (so `asyncio.CancelledError` propagates); its handler may return a `Response` to recover or `None` to re-raise (Story 2.2). +``` + +- [ ] **Step 7: 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 8: Commit** + +```bash +git add src/httpware/__init__.py docs/engineering.md CHANGELOG.md tests/test_middleware.py +git commit -m "$(cat <<'EOF' +feat(story-2.2): re-export decorators; fix engineering.md naming; CHANGELOG + +Adds before_request, after_response, on_error to httpware/__init__.py +imports and __all__ so consumers can `from httpware import …` in +addition to the subpackage path. + +Fixes docs/engineering.md §8 line 145 to reflect the canonical +@before_request / @after_response / @on_error names (it had stale +@on_request / @on_response from the distillation). + +CHANGELOG records the Story 2.2 surface. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Verify, push, PR, merge + +End-to-end sanity check, push the branch, open the PR, wait for CI, merge. + +- [ ] **Step 1: Run the full test suite with coverage** + +Run: `just test` +Expected: 184 passed (174 baseline post-2-1 + 10 new), 1 deselected (perf bench), 100% line coverage including the new decorator factories. + +The Protocol method body `...` line is excluded from coverage automatically; if the new inner-class `__call__` bodies report uncovered lines, the tests are insufficient — back up and add cases. None should be uncovered: each test exercises every decorator's full body. + +- [ ] **Step 2: Run full lint and type checks** + +Run: `just lint-ci` +Expected: `eof-fixer`, `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 (every change committed). + +- [ ] **Step 4: Review the branch diff** + +Run: `git log --oneline main..HEAD` +Expected: six or seven commits — spec (`docs(story-2.2): design...`), Task 1, Task 2, Task 3, Task 4, Task 5. + +Run: `git diff --stat main..HEAD` +Expected: changes to `CHANGELOG.md`, `docs/engineering.md`, `docs/superpowers/specs/2026-05-31-phase-shortcut-decorators-design.md`, `docs/superpowers/plans/2026-05-31-phase-shortcut-decorators-plan.md`, `src/httpware/__init__.py`, `src/httpware/middleware/__init__.py`, `tests/test_middleware.py`. No other source files touched. + +- [ ] **Step 5: Stage and commit the plan file** + +The plan file at `docs/superpowers/plans/2026-05-31-phase-shortcut-decorators-plan.md` is still untracked. Stage and commit it on this branch so the merge captures the plan alongside the spec. + +```bash +git add docs/superpowers/plans/2026-05-31-phase-shortcut-decorators-plan.md +git commit -m "docs(story-2.2): implementation plan for phase-shortcut decorators + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +- [ ] **Step 6: Push the branch** + +Run: `git push -u origin story/2-2-phase-shortcut-decorators` +Expected: push succeeds; GitHub prints a "Create a pull request for ..." URL. + +- [ ] **Step 7: Open the PR** + +```bash +gh pr create --title "feat(story-2.2): phase-shortcut decorators @before_request, @after_response, @on_error" --body "$(cat <<'EOF' +## Summary + +- Adds three phase-shortcut decorators in `src/httpware/middleware/__init__.py` for writing lifecycle hooks without authoring a full `Middleware` class: + - `@before_request` wraps `async f(Request) -> Request` and forwards the transformed request to the chain. + - `@after_response` wraps `async f(Request, Response) -> Response` and applies `f` to the response. + - `@on_error` wraps `async f(Request, Exception) -> Response | None`, catches `Exception` only (`CancelledError` propagates), returns the handler's `Response` or re-raises if the handler returns `None`. +- Each decorator returns a private class instance with `f` captured via closure and a `__repr__` of the form `` for clean chain inspection. +- All three exported at both `httpware.middleware.*` and `httpware.*`. +- 10 new tests in `tests/test_middleware.py` (24 total): request and response transformations, on_error swallow/re-raise paths, `CancelledError` non-capture, exception identity, Protocol satisfaction, mixed-chain composition, `repr()` content, package-root re-export. + +Bundled-in doc fix: `docs/engineering.md` §8 line 145 had stale `@on_request`/`@on_response` names from the distillation — corrected to the canonical `@before_request`/`@after_response`. + +Out of scope (subsequent stories): `Request.with_*` helper expansion (2-3), auth coercion (2-4), AsyncClient wiring (2-5). + +Spec + plan: `docs/superpowers/specs/2026-05-31-phase-shortcut-decorators-design.md`, `docs/superpowers/plans/2026-05-31-phase-shortcut-decorators-plan.md`. + +## Test plan + +- [x] `just test` — 184 passed, 1 deselected, 100% line coverage including the new factories. +- [x] `just lint-ci` clean. +- [x] `tests/test_no_httpx2_leakage.py` still passes. +- [x] `from httpware import before_request, after_response, on_error` and the subpackage path both resolve. +- [ ] CI green on all matrix entries (3.11/3.12/3.13/3.14 + lint). + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 8: Wait for CI** + +Run: `gh pr checks ` (the number is printed by `gh pr create`). +Expected: all five jobs green (`lint`, `pytest (3.11)`, `pytest (3.12)`, `pytest (3.13)`, `pytest (3.14)`). + +Codecov uploads on `pytest (3.14)` have shown a transient EPIPE failure in this repo. If 3.14 fails on the `Run codecov/codecov-action@v4.0.1` step (not on pytest itself), re-run with `gh run rerun --failed` and re-check. + +If a pytest step fails on a specific Python version, identify the test and version locally with `uv run --python 3.X pytest …` and address; pure-Python `Protocol` / `TypeAlias` / `Callable` shape is stable across 3.11–3.14, so failures more likely indicate test fragility than version differences. + +- [ ] **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; the Story 2.2 history visible below. + +Story 2-2 is complete. Story 2-3 (`Request` immutability helper expansion) is the next normal-flow item. + +--- + +## Definition of done + +- `src/httpware/middleware/__init__.py` exports `before_request`, `after_response`, `on_error` in addition to `Middleware` and `Next`. +- `src/httpware/__init__.py` re-exports the three new names and adds them to `__all__` in alphabetic position (after `"UnprocessableEntityError"`). +- `docs/engineering.md` §8 line 145 reads `@before_request`, `@after_response`, `@on_error` — the stale `@on_request`/`@on_response` is gone. +- `CHANGELOG.md` has a Story 2.2 bullet under `[Unreleased]` / `### Added`. +- `tests/test_middleware.py` contains 24 tests (14 carried forward from Story 2-1 + 10 new); all pass. +- `just test` shows 184 passed, 1 deselected, 100% line coverage. +- `just lint-ci` clean. +- `tests/test_no_httpx2_leakage.py` still passes. +- Both the spec and the plan are committed on `story/2-2-phase-shortcut-decorators` and land via a single PR. diff --git a/docs/superpowers/specs/2026-05-31-phase-shortcut-decorators-design.md b/docs/superpowers/specs/2026-05-31-phase-shortcut-decorators-design.md new file mode 100644 index 0000000..afc4f92 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-phase-shortcut-decorators-design.md @@ -0,0 +1,235 @@ +# Phase-shortcut decorators (design) + +- **Date:** 2026-05-31 +- **Status:** approved, ready for plan +- **Scope:** Story 2-2 (second story of Epic 2). Defines `@before_request`, `@after_response`, and `@on_error` decorators that wrap simple async user functions into `Middleware`-conforming instances. Out of scope: AsyncClient wiring (2-5), `Request.with_*` helpers beyond what exists (2-3), auth coercion (2-4). +- **Roadmap pointer:** `docs/engineering.md` §8 "Epic 2 — Compose request-handling logic via middleware". + +## Why + +Most middleware does one thing — stamp a request, log a response, recover from an error. Forcing every consumer to author a full `Middleware` class for those cases is friction. The phase-shortcut decorators wrap the common patterns: one async function in, one `Middleware` instance out, ready to drop into the chain. + +The shape is decided by the archived epic spec (`docs/archive/epics.md` Epic 2 → Story 2.2). This spec ports that design forward with three small choices that the archive left open: naming consistency, file location, and implementation shape. + +## Decisions + +| Decision | Choice | +| --- | --- | +| Decorator names | `@before_request`, `@after_response`, `@on_error`. Matches the archived epic spec. `before/after` for phases that fire around a successful response; `on_error` for the event-driven error case. | +| Location | All three live in `src/httpware/middleware/__init__.py` alongside `Middleware` and `Next`. The file grows from ~30 to ~95 lines, all on the same seam. | +| Implementation shape | Each decorator factory defines a private class (e.g., `_BeforeRequestMiddleware`) inside its body, instantiates it, returns the instance. Per-phase classes keep `__call__` bodies single-purpose and give each decorated middleware a distinct `__repr__`. | +| User-function sync/async | Async only. The user writes `async def f(...) -> ...`; sync wrappers are the user's responsibility. | +| `@on_error` callback type | `Callable[[Request, Exception], Awaitable[Response | None]]`. Archive said `BaseException`; that was misleading since the chain only catches `Exception`. `Exception` is the accurate type. | +| `@on_error` exception scope | Catches `Exception`, not `BaseException`. `asyncio.CancelledError` (and `SystemExit`, `KeyboardInterrupt`) propagate untouched. | +| `@on_error` return contract | If the handler returns a `Response`, that Response is returned to the caller. If it returns `None`, the original exception is re-raised (bare `raise` to preserve traceback). | +| `BaseExceptionGroup` carve-out | None. PEP 654 `ExceptionGroup` is a subclass of `Exception` and is caught like any other. Users carve out groups themselves if needed. | +| Public exports | `before_request`, `after_response`, `on_error` exported from `httpware.middleware` and re-exported at `httpware`. Matches the existing `Middleware` / `Next` re-export pattern. | +| Roadmap doc fix | Bundled in: `docs/engineering.md` §8 says `(@on_request, @on_response, @on_error)`. Rewrite to `(@before_request, @after_response, @on_error)` to match the spec. | +| Scope | Strict — no AsyncClient wiring, no extra `Request` helpers, no auth coercion. Those land in stories 2-3 through 2-5. | + +## File structure + +**Modified files:** + +``` +src/httpware/middleware/__init__.py # add 3 decorator factories + 3 private classes (~65 lines added) +src/httpware/__init__.py # re-export 3 names; extend __all__ +docs/engineering.md # fix §8 line 142 decorator names +CHANGELOG.md # add Story 2.2 bullet under [Unreleased] / ### Added +tests/test_middleware.py # 10 new tests appended (14 → 24) +``` + +**Files not touched:** every other source file. Story 2-2 is purely additive on top of Story 2-1. + +## Public surface + +`from httpware.middleware import before_request, after_response, on_error` (and re-exported at `httpware.*`). + +```python +def before_request( + f: Callable[[Request], Awaitable[Request]], +) -> Middleware: ... + + +def after_response( + f: Callable[[Request, Response], Awaitable[Response]], +) -> Middleware: ... + + +def on_error( + f: Callable[[Request, Exception], Awaitable[Response | None]], +) -> Middleware: ... +``` + +All three are **sync factories** — called once when decorating, returning a `Middleware` instance. The user function is async. + +Usage: + +```python +from httpware.middleware import before_request, after_response, on_error + +@before_request +async def add_trace_id(request: Request) -> Request: + return request.with_header("x-trace-id", uuid4().hex) + +@after_response +async def log_response(request: Request, response: Response) -> Response: + logger.info("%s %s -> %s", request.method, request.url, response.status) + return response + +@on_error +async def fallback(request: Request, exc: Exception) -> Response | None: + if isinstance(exc, SomeTransientError): + return cached_response_for(request) + return None # re-raise + +client = AsyncClient(middleware=[add_trace_id, log_response, fallback], ...) +``` + +## Implementation + +Append to `src/httpware/middleware/__init__.py` after the existing `Middleware` Protocol: + +```python +def before_request(f: Callable[[Request], Awaitable[Request]]) -> Middleware: + """Wrap an async request transform into a Middleware. + + The decorated function receives the incoming Request and returns a + (possibly modified) Request, which is then forwarded down the chain. + """ + + class _BeforeRequestMiddleware: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + return await next(await f(request)) + + def __repr__(self) -> str: + return f"" + + return _BeforeRequestMiddleware() + + +def after_response(f: Callable[[Request, Response], Awaitable[Response]]) -> Middleware: + """Wrap an async response transform into a Middleware. + + The decorated function receives the original Request and the Response + returned by the chain, and returns a (possibly modified) Response. + """ + + class _AfterResponseMiddleware: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + response = await next(request) + return await f(request, response) + + def __repr__(self) -> str: + return f"" + + return _AfterResponseMiddleware() + + +def on_error(f: Callable[[Request, Exception], Awaitable[Response | None]]) -> Middleware: + """Wrap an async error handler into a Middleware. + + Catches Exception (not BaseException, so asyncio.CancelledError + propagates). If the handler returns a Response, that Response is + returned to the caller. If the handler returns None, the original + exception is re-raised. + """ + + class _OnErrorMiddleware: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + try: + return await next(request) + except Exception as exc: + result = await f(request, exc) + if result is None: + raise + return result + + def __repr__(self) -> str: + return f"" + + return _OnErrorMiddleware() +``` + +Update `__all__` in the same file from `["Middleware", "Next"]` to: + +```python +__all__ = ["Middleware", "Next", "after_response", "before_request", "on_error"] +``` + +Update `src/httpware/__init__.py`: +- Change the import line `from httpware.middleware import Middleware, Next` to `from httpware.middleware import Middleware, Next, after_response, before_request, on_error`. +- Insert `"after_response"`, `"before_request"`, `"on_error"` into the `__all__` list. The existing `__all__` is sorted by ASCII order (uppercase < lowercase), so lowercase entries sort to the end of the list — append the three new names after the existing final entry `"UnprocessableEntityError"`, in alphabetic order: `"after_response"`, then `"before_request"`, then `"on_error"`. + +Update `docs/engineering.md` §8 line ~142: +- From: `**2-2** Phase shortcut decorators (\`@on_request\`, \`@on_response\`, \`@on_error\`).` +- To: `**2-2** Phase shortcut decorators (\`@before_request\`, \`@after_response\`, \`@on_error\`).` + +Update `CHANGELOG.md`. New bullet under `[Unreleased]` / `### Added`: + +```markdown +- Phase-shortcut decorators `@before_request`, `@after_response`, `@on_error` for lifecycle hooks without authoring a full `Middleware` class. `@on_error` catches `Exception` only (so `asyncio.CancelledError` propagates); its handler may return a `Response` to recover or `None` to re-raise (Story 2.2). +``` + +## Notes on the implementation + +- **Each decorator returns an instance, not a class.** The decorated name binds to one specific `Middleware` instance ready to drop into a chain. Calling `before_request(f)` a second time produces a distinct instance over the same `f`. +- **`__repr__` uses `f.__qualname__`** so `repr(add_trace_id)` is ``, not the default `<_BeforeRequestMiddleware object at 0x...>`. Makes a chain print cleanly in logs. +- **Bare `raise` inside `except Exception as exc:`** preserves the original exception type, value, and traceback. No `raise exc` (which would clobber the traceback) or `raise X from exc` (which would chain). +- **`CancelledError` flows past `except Exception:` untouched.** That's the load-bearing property; the `test_on_error_does_not_catch_cancelled_error` test pins it. +- **`f` is captured by closure on the inner class.** Each decorator instance owns one `f`; no shared state, no late-binding hazards. +- **No `# ty: ignore` expected.** If `ty` flags the `Callable[..., Awaitable[...]]` annotations or the class-as-return-Middleware structural check, the fallback is to type-narrow with an explicit cast — but should not be needed. + +## Testing + +Append to `tests/test_middleware.py`. The file already has the `_OkTransport`, `_make_request`, and `compose` fixtures from Story 2-1 — reuse them. + +Approximate test list: + +| Test | Verifies | +| --- | --- | +| `test_before_request_transforms_request` | `@before_request` user fn mutates request via `with_header`; downstream chain sees the mutation. | +| `test_after_response_transforms_response` | `@after_response` user fn rebuilds Response with extra header; caller sees the modification. | +| `test_on_error_returns_response_swallows_exception` | Transport raises; `@on_error` returns a synthesized Response; caller gets that Response, no exception. | +| `test_on_error_returns_none_reraises` | Transport raises; `@on_error` returns `None`; original exception bubbles to the caller. | +| `test_on_error_does_not_catch_cancelled_error` | Inner middleware raises `asyncio.CancelledError`; `@on_error` handler is NOT invoked; `CancelledError` propagates. | +| `test_on_error_handler_receives_correct_exception_instance` | Handler's `exc` parameter is the same instance the transport raised (identity check). | +| `test_decorators_satisfy_middleware_protocol` | `isinstance(before_request(f), Middleware)` and same for the other two. | +| `test_decorated_middlewares_compose_in_chain` | Mix `@before_request`, `@after_response`, and a plain class middleware in one `compose()`; ordering correctness. | +| `test_repr_shows_original_function_name` | `repr(before_request(my_func))` contains `"before_request"` and `"my_func"`. | +| `test_decorators_reexported_at_package_root` | `from httpware import before_request, after_response, on_error` works. | + +Ten new tests. Total `tests/test_middleware.py` grows from 14 to 24. + +**Coverage expectation:** 100% line coverage on the three new decorator factories and their inner classes. The Protocol method body `...` already excluded by the existing pattern. + +**No `respx`, no mocking the transport.** Use the existing `_OkTransport` and a small `_FailingTransport` defined locally in the new test block. + +## Constraints and invariants + +- **No `httpx2` import.** None of the modified files import `httpx2`. +- **No `from __future__ import annotations`.** PEP 604/585 syntax is native. +- **No `print()`, no `logging.basicConfig`.** Decorators are pure transforms; emission belongs in observability (Epic 5). +- **No `# type: ignore`.** Use `# ty: ignore[]` if a suppression is strictly needed; none expected. +- **`# noqa: A002` on `next` parameter** stays consistent with Story 2-1's protocol body. + +## Risks and mitigations + +| Risk | Mitigation | +| --- | --- | +| `ty` complains that the inner class doesn't satisfy `Middleware` because its `__call__` is bound on an instance. | The Story 2-1 `Middleware` Protocol takes `self, request, next`. Inner classes use the same signature. If `ty` still rejects on structural grounds, fall back to explicit subclassing of `Middleware` (it's a runtime-checkable Protocol, so `class _BeforeRequestMiddleware(Middleware):` is legal). Decided at implementation time. | +| User decorates a sync function. | The type signature forces async. If ruff/`ty` doesn't catch it at import time, a sync function would return a `Request` directly (not awaitable), and the `await f(request)` inside the decorator would raise `TypeError: object Request can't be used in 'await' expression` at runtime. Acceptable — the error is loud and immediate. | +| `@on_error` handler itself raises. | The handler's exception escapes naturally (no catch-and-suppress in the decorator). Replaces the original exception in the traceback chain by Python's implicit `__context__` link. Documented behavior of `try/except` in Python; no special handling. | +| `BaseExceptionGroup` surprises a user who expected groups to bypass `@on_error`. | The decorator's docstring says "Catches Exception"; `BaseExceptionGroup` is an `Exception`. Users who need to special-case groups (e.g., to re-raise a CancelledError-bearing group) carve it out inside their handler. Not the framework's job to second-guess. | + +## Definition of done + +- `src/httpware/middleware/__init__.py` exports `before_request`, `after_response`, `on_error` in addition to the existing `Middleware` and `Next`. +- `src/httpware/__init__.py` re-exports the three new names and adds them to `__all__`. +- `docs/engineering.md` §8 line 142 reflects the corrected decorator names. +- `CHANGELOG.md` has a Story 2.2 bullet under `[Unreleased]` / `### Added`. +- `tests/test_middleware.py` contains 10 new tests; all 24 tests pass. +- `just test` shows the increment from baseline; 100% line coverage on the new code. +- `just lint-ci` clean. +- `tests/test_no_httpx2_leakage.py` still passes. +- Story 2-2 lands as a single PR off `main` via the branch `story/2-2-phase-shortcut-decorators`. diff --git a/src/httpware/__init__.py b/src/httpware/__init__.py index 3933018..8636542 100644 --- a/src/httpware/__init__.py +++ b/src/httpware/__init__.py @@ -21,7 +21,7 @@ UnauthorizedError, UnprocessableEntityError, ) -from httpware.middleware import Middleware, Next +from httpware.middleware import Middleware, Next, after_response, before_request, on_error from httpware.request import Request from httpware.response import Response, StreamResponse from httpware.transports import Transport @@ -57,4 +57,7 @@ "TransportError", "UnauthorizedError", "UnprocessableEntityError", + "after_response", + "before_request", + "on_error", ] diff --git a/src/httpware/middleware/__init__.py b/src/httpware/middleware/__init__.py index 3d94fa6..c567159 100644 --- a/src/httpware/middleware/__init__.py +++ b/src/httpware/middleware/__init__.py @@ -26,4 +26,64 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 ... -__all__ = ["Middleware", "Next"] +def before_request(f: Callable[[Request], Awaitable[Request]]) -> Middleware: + """Wrap an async request transform into a Middleware. + + The decorated function receives the incoming Request and returns a + (possibly modified) Request, which is then forwarded down the chain. + """ + + class _BeforeRequestMiddleware: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + return await next(await f(request)) + + def __repr__(self) -> str: + return f"" # ty: ignore[unresolved-attribute] + + return _BeforeRequestMiddleware() + + +def after_response(f: Callable[[Request, Response], Awaitable[Response]]) -> Middleware: + """Wrap an async response transform into a Middleware. + + The decorated function receives the original Request and the Response + returned by the chain, and returns a (possibly modified) Response. + """ + + class _AfterResponseMiddleware: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + response = await next(request) + return await f(request, response) + + def __repr__(self) -> str: + return f"" # ty: ignore[unresolved-attribute] + + return _AfterResponseMiddleware() + + +def on_error(f: Callable[[Request, Exception], Awaitable[Response | None]]) -> Middleware: + """Wrap an async error handler into a Middleware. + + Catches Exception (not BaseException, so asyncio.CancelledError + propagates). If the handler returns a Response, that Response is + returned to the caller. If the handler returns None, the original + exception is re-raised. + """ + + class _OnErrorMiddleware: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + try: + return await next(request) + except Exception as exc: + result = await f(request, exc) + if result is None: + raise + return result + + def __repr__(self) -> str: + return f"" # ty: ignore[unresolved-attribute] + + return _OnErrorMiddleware() + + +__all__ = ["Middleware", "Next", "after_response", "before_request", "on_error"] diff --git a/tests/test_middleware.py b/tests/test_middleware.py index ca57690..d3c4f82 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -7,8 +7,9 @@ import pytest +import httpware from httpware._internal.chain import compose -from httpware.middleware import Middleware, Next +from httpware.middleware import Middleware, Next, after_response, before_request, on_error from httpware.request import Request from httpware.response import Response, StreamResponse @@ -265,11 +266,218 @@ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A00 assert count == 3 # noqa: PLR2004 +async def test_before_request_transforms_request() -> None: + """@before_request wraps an async request transform; downstream sees the mutation.""" + + @before_request + async def stamp(request: Request) -> Request: + return request.with_header("x-trace", "abc123") + + seen: list[Request] = [] + + 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_after_response_transforms_response() -> None: + """@after_response wraps an async response transform; caller sees the modification.""" + + @after_response + async def add_header(request: Request, response: Response) -> Response: # noqa: ARG001 + return Response( + status=response.status, + headers={**response.headers, "x-trace": "abc123"}, + content=response.content, + url=response.url, + elapsed=response.elapsed, + ) + + response = await compose([add_header], _OkTransport())(_make_request()) + + assert response.headers["x-trace"] == "abc123" + assert response.headers["x-from"] == "transport" # original still present + + 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__ + + +class _FailingTransport: + """Transport whose __call__ raises a chosen exception.""" + + def __init__(self, exc: BaseException) -> None: + self._exc = exc + + async def __call__(self, request: Request) -> Response: # noqa: ARG002 + raise self._exc + + def stream( # pragma: no cover - not exercised in 2-2 + self, request: Request + ) -> AbstractAsyncContextManager[StreamResponse]: + raise NotImplementedError + + async def aclose(self) -> None: # pragma: no cover - not exercised in 2-2 + return None + + +async def test_on_error_returns_response_swallows_exception() -> None: + """When the handler returns a Response, the caller gets it; no exception escapes.""" + + @on_error + async def recover(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 + return Response( + status=503, + headers={"x-recovered": "true"}, + content=b"recovered", + url=request.url, + elapsed=0.0, + ) + + transport = _FailingTransport(RuntimeError("boom")) + response = await compose([recover], transport)(_make_request()) + + assert response.status == 503 # noqa: PLR2004 + assert response.headers["x-recovered"] == "true" + assert response.content == b"recovered" + + +async def test_on_error_returns_none_reraises() -> None: + """When the handler returns None, the original exception is re-raised with traceback intact.""" + + @on_error + async def pass_through(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 + return None + + transport = _FailingTransport(RuntimeError("boom")) + + with pytest.raises(RuntimeError, match="boom"): + await compose([pass_through], transport)(_make_request()) + + +async def test_on_error_does_not_catch_cancelled_error() -> None: + """asyncio.CancelledError is not Exception; the handler must not be invoked.""" + invocations: list[Exception] = [] + + @on_error + async def should_not_run(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 + invocations.append(exc) + return None + + class Cancel: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002, ARG002 + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await compose([should_not_run, Cancel()], _OkTransport())(_make_request()) + + assert invocations == [] + + +async def test_on_error_handler_receives_correct_exception_instance() -> None: + """The handler's `exc` parameter is the same instance the transport raised.""" + raised = RuntimeError("specific instance") + seen: list[Exception] = [] + + @on_error + async def capture(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 + seen.append(exc) + return None + + with pytest.raises(RuntimeError): + await compose([capture], _FailingTransport(raised))(_make_request()) + + assert seen == [raised] + assert seen[0] is raised + + +def test_decorators_satisfy_middleware_protocol() -> None: + """Each decorator returns an object that isinstance() recognizes as Middleware.""" + + @before_request + async def br(request: Request) -> Request: + return request + + @after_response + async def ar(request: Request, response: Response) -> Response: # noqa: ARG001 + return response + + @on_error + async def oe(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 + return None + + assert isinstance(br, Middleware) + assert isinstance(ar, Middleware) + assert isinstance(oe, Middleware) + + +async def test_decorated_middlewares_compose_in_chain() -> None: + """Phase decorators interoperate with class-based middleware in one compose() call.""" + + @before_request + async def stamp(request: Request) -> Request: + return request.with_header("x-stamp", "1") + + @after_response + async def tag(request: Request, response: Response) -> Response: # noqa: ARG001 + return Response( + status=response.status, + headers={**response.headers, "x-tag": "1"}, + content=response.content, + url=response.url, + elapsed=response.elapsed, + ) + + seen_headers: list[str] = [] + + class Inspect: + async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 + seen_headers.append(request.headers.get("x-stamp", "")) + return await next(request) + + response = await compose([stamp, Inspect(), tag], _OkTransport())(_make_request()) + + assert seen_headers == ["1"] # stamp ran before Inspect + assert response.headers["x-tag"] == "1" # tag ran after the chain + + +def test_repr_shows_original_function_name() -> None: + """repr() includes the phase name and the original user function's qualname.""" + + @before_request + async def my_stamp(request: Request) -> Request: + return request + + @after_response + async def my_tag(request: Request, response: Response) -> Response: # noqa: ARG001 + return response + + @on_error + async def my_recover(request: Request, exc: Exception) -> Response | None: # noqa: ARG001 + return None + + assert "before_request" in repr(my_stamp) + assert "my_stamp" in repr(my_stamp) + assert "after_response" in repr(my_tag) + assert "my_tag" in repr(my_tag) + assert "on_error" in repr(my_recover) + assert "my_recover" in repr(my_recover) + + +def test_decorators_reexported_at_package_root() -> None: + """`from httpware import before_request, after_response, on_error` works.""" + assert httpware.before_request is before_request + assert httpware.after_response is after_response + assert httpware.on_error is on_error + assert "before_request" in httpware.__all__ + assert "after_response" in httpware.__all__ + assert "on_error" in httpware.__all__