diff --git a/CLAUDE.md b/CLAUDE.md index be74f91..ff7d076 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,12 +10,12 @@ Guidance for AI agents (Claude Code, etc.) working in this repository. - [`architecture/`](architecture/) (repo root) — the per-capability living truth, one file per capability ([`architecture/README.md`](architecture/README.md) is the index). The promotion target on every ship. **Read the relevant file before changing that capability.** - [`planning/README.md`](planning/README.md) — the planning convention (Quick path + the two-axis convention) and the generated change Index. -- [`planning/changes//`](planning/changes/) — per-change bundles (`design.md` + `plan.md`, or `change.md` for the lightweight lane). +- [`planning/changes/.md`](planning/changes/) — flat change files, one per change (design template for the full lane, change template for the lightweight lane). - [`planning/decisions/`](planning/decisions/), [`planning/audits/`](planning/audits/), [`planning/retros/`](planning/retros/), [`planning/releases/`](planning/releases/), [`planning/deferred.md`](planning/deferred.md) — decisions, findings sweeps, retrospectives, release notes, and deferred items. ## Workflow -Planning follows the portable two-axis convention — `architecture/` (repo root) is the living **truth home** and promotion target; `planning/changes/` holds the per-change bundles. **Start at the [Quick path](planning/README.md#quick-path-start-here)** in `planning/README.md` to choose a lane (Full / Lightweight / Tiny), create a bundle, and ship — that file is the authoritative spec. Run `just check-planning` to validate bundles and `just index` to print the change listing. +Planning follows the portable two-axis convention — `architecture/` (repo root) is the living **truth home** and promotion target; `planning/changes/` holds the flat change files. **Start at the [Quick path](planning/README.md#quick-path-start-here)** in `planning/README.md` to choose a lane (Full / Lightweight / Tiny), create a change file, and ship — that file is the authoritative spec. Run `just check-planning` to validate changes and `just index` to print the change listing. ## Commands diff --git a/Justfile b/Justfile index 1c50413..5f3a062 100644 --- a/Justfile +++ b/Justfile @@ -21,7 +21,7 @@ lint-ci: index: uv run python planning/index.py -# Validate planning bundles + decisions; CI runs this via lint-ci. +# Validate planning changes + decisions; CI runs this via lint-ci. check-planning: uv run python planning/index.py --check diff --git a/planning/.convention-version b/planning/.convention-version index 3eefcb9..227cea2 100644 --- a/planning/.convention-version +++ b/planning/.convention-version @@ -1 +1 @@ -1.0.0 +2.0.0 diff --git a/planning/README.md b/planning/README.md index f74a081..f95481d 100644 --- a/planning/README.md +++ b/planning/README.md @@ -12,25 +12,22 @@ the repo root; this directory records *how it got there*. **1. Choose a lane — first matching rule wins:** 1. Any of: needs design judgment · new file/module · public-API change · - cross-cutting or multi-file · non-trivial test design → **Full** - (`design.md` + `plan.md`) + cross-cutting or multi-file · non-trivial test design → **Full** (design template) 2. Purely mechanical: typo · dep bump · linter/formatter/CI tweak · - mechanical rename · single-line config → **Tiny** (no bundle, conventional + mechanical rename · single-line config → **Tiny** (no change file, conventional commit) 3. Small-but-real, none of the above: ≲30 LOC net · ≤2 files · no new file · - no public-API change · one straightforward test → **Lightweight** - (`change.md`) + no public-API change · one straightforward test → **Lightweight** (change template) -Ambiguous between two? Take the heavier. A `change.md` that outgrows its lane -splits into `design.md` + `plan.md`. +Ambiguous between two? Take the heavier. A lightweight change file that outgrows its lane is rewritten from the design template. -**2. Create the bundle** (Full / Lightweight only): -`planning/changes/YYYY-MM-DD.NN-/`, where `.NN` is a zero-padded -intra-day counter. Copy the matching template from +**2. Create the change file** (Full / Lightweight only): +`planning/changes/YYYY-MM-DD.NN-.md`, where `.NN` is a zero-padded +intra-day counter — copied from the matching template (design or change) in [`_templates/`](_templates/). **3. Ship in the implementing PR:** hand-edit the affected -`architecture/.md`, finalize the bundle's `summary:` to the +`architecture/.md`, finalize the change file's `summary:` to the realized result, and run `just check-planning` before pushing. ## Conventions @@ -43,20 +40,41 @@ realized result, and run `just check-planning` before pushing. ### Two axes, never mixed -- **`architecture/` (repo root) — the present.** One file per capability, - living prose, updated in the same PR that ships the change. The truth home. -- **`planning/changes/` — the past-and-pending.** One folder per change, +- **`architecture/` (repo root) — the present.** One file per capability, plus + a single `glossary.md` (the ubiquitous language); living prose, updated in the + same PR that ships the change. The truth home. +- **`planning/changes/` — the past-and-pending.** One file per change, kept in place after ship. A change **promotes** its conclusions into the affected `architecture/.md` by hand **in the implementing PR, alongside the code** — the edit rides in the same diff and is reviewed with it, never applied as a separate post-merge step. That hand-edit is what keeps `architecture/` -true; the bundle stays in `changes/` as the *why*. +true; the change file stays in `changes/` as the *why*. -### Change bundles +### Glossary -A change is a folder `changes/YYYY-MM-DD.NN-/`: +`architecture/glossary.md` is the project's **ubiquitous language** — one page +defining the domain terms that code, specs, and capability pages all share. Like +the capability files beside it, it is living prose with **no frontmatter**, dated +by git, and authored lazily: it appears when the first term is worth pinning down. + +Each entry is a term, a one-or-two-sentence definition of what it *is* (not what +it does), and an optional `_Avoid_:` line naming the synonyms to reject: + +```md +**Timer**: +A scheduled future delivery, identified by a timer id. +_Avoid_: job, task, alarm +``` + +Keep it a glossary, not a spec — no implementation detail. A change that +introduces or sharpens a term updates `glossary.md` in the same PR, the same way +a behavior change promotes into a capability file. + +### Change files + +A change is a file `changes/YYYY-MM-DD.NN-.md`: - `YYYY-MM-DD` — proposal date; `.NN` — zero-padded intra-day counter (`.01`, `.02`, …) that breaks same-date ties so the timeline sorts stably. @@ -65,25 +83,44 @@ A change is a folder `changes/YYYY-MM-DD.NN-/`: `summary` is written when the change is created (the intent one-liner) and **finalized at ship** to state the realized result — set in the implementing PR, alongside the code and the `architecture/` promotion. No post-merge -bookkeeping, no folder move. `date` and `slug` are never written — they are -read from the bundle's directory name. +bookkeeping, no file move. `date` and `slug` are never written — they are +read from the file name. ### Three lanes | Lane | Artifacts | Use when | |------|-----------|----------| -| **Full** | `design.md` + `plan.md` | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | -| **Lightweight** | `change.md` | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | +| **Full** | one change file from the design template | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | +| **Lightweight** | one change file from the change template | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | | **Tiny** | none — conventional commit | typo, dep bump, linter/formatter/CI tweak, mechanical rename, single-line config | -Heavier lane wins on ambiguity. A `change.md` that outgrows its lane splits -into `design.md` + `plan.md`. +Heavier lane wins on ambiguity. A lightweight change file that outgrows its lane is rewritten from the design template. + +### Plans are ephemeral + +The executable plan — task checklists, embedded code, commit sequences, +whatever the executor needs — is a working artifact, not history. Keep it out +of `changes/` and out of version control (git-ignored scratch, e.g. +`.superpowers/`). Once the change ships, the diff and the PR are the record +of execution; a committed plan duplicates them. `check-planning` rejects +anything in `changes/` that is not a flat change file. + +### Lean specs + +The change file is the single home of a change's rationale: + +- The PR body summarizes and links to the change file — it never restates it. +- Rejected alternatives live in `decisions/` and are referenced, not retold. +- Show a sketch when the design needs code; never the full diff-to-be. +- Delete template sections that don't apply — an empty section is ceremony. +- Most designs fit well under ~700 words; length must buy information. ### Artifacts at a glance -- **`design.md`** — the spec: the *thinking* (why, design, trade-offs, scope). -- **`plan.md`** — the plan: the *sequencing* (the executor's task checklist). -- **`change.md`** — both, condensed, for the lightweight lane. +- **design template** — the spec: the *thinking* (why, design, trade-offs, + scope); the change file it produces is the single home of rationale (see + [Lean specs](#lean-specs)). +- **change template** — the condensed spec for the lightweight lane. - **`releases/.md`** — per-release user-facing notes. - **`audits/-.md`** — findings from a code/docs/bug-hunt sweep; spawns fix changes. @@ -97,11 +134,10 @@ Templates live in [`_templates/`](_templates/). ### Frontmatter -`date` and `slug` are **derived from the directory / file name** — never +`date` and `slug` are **derived from the file name** — never repeated in frontmatter. So: -- `design.md` / `change.md`: `summary` (single line) only. -- `plan.md`: **no frontmatter** — its identity is the bundle directory. +- `changes/*.md`: `summary` (single line) only. - `decisions/*.md`: `status` (accepted|superseded), `summary`, and optional `supersedes` / `superseded_by`. - Files in `architecture/` carry **no** frontmatter — living prose, dated by git. @@ -113,8 +149,8 @@ only field the index renders. ## Index The listing is **generated**, not maintained — run `just index` to print it: -changes as a flat newest-first list, then decisions newest-first. Each bundle's -`summary` frontmatter (and the directory name, which supplies `date`/`slug`) is +changes as a flat newest-first list, then decisions newest-first. Each change's +`summary` frontmatter (and the file name, which supplies `date`/`slug`) is the single source of truth; there is no committed copy to drift. ## Other diff --git a/planning/_templates/change.md b/planning/_templates/change.md index d4c8962..5aa7e81 100644 --- a/planning/_templates/change.md +++ b/planning/_templates/change.md @@ -5,8 +5,8 @@ summary: One line — shown in the generated index. Written at creation; finaliz # Change: One-line capitalized title **Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API -change, a single straightforward test. If it outgrows this, split into -`design.md` + `plan.md`. +change, a single straightforward test. If it outgrows this, rewrite it from +the design template. ## Goal diff --git a/planning/_templates/design.md b/planning/_templates/design.md index d63e22d..17dbee1 100644 --- a/planning/_templates/design.md +++ b/planning/_templates/design.md @@ -4,6 +4,10 @@ summary: One line — shown in the generated index. Written at creation; finaliz # Design: One-line capitalized title + + ## Summary One paragraph. What changes, at the level a reader needs to decide if this @@ -12,37 +16,23 @@ spec is worth reading in full. ## Motivation Why now. What is broken or missing. Concrete observations / numbers, not -abstract complaints. Link to memory entries or earlier specs when relevant. - -## Non-goals - -What is deliberately out of scope and (when nontrivial) why. Each item is -a sentence; one line each. +abstract complaints. ## Design -### 1. - What changes, in enough detail that a reader who has not seen the codebase -can follow. Code samples / diagrams welcome. +can follow. Sketches and interface fragments welcome; never the full +diff-to-be. Reference rejected alternatives in `decisions/` instead of +retelling them. -### 2. - -... - -## Operations - -Out-of-repo steps (DNS, infra, external account changes). Omit if none. - -## Out of scope +## Non-goals -Already covered above under Non-goals if appropriate. Repeat-list of -explicitly-excluded follow-ups belongs here when the list is long. +What is deliberately out of scope and (when nontrivial) why. One line each. ## Testing -How we know it landed correctly. New pytest? Smoke check on live URL? -Lint pass? Be specific. +How we know it landed correctly. Be specific: the command and the expected +signal. ## Risk diff --git a/planning/_templates/glossary.md b/planning/_templates/glossary.md new file mode 100644 index 0000000..82385c3 --- /dev/null +++ b/planning/_templates/glossary.md @@ -0,0 +1,15 @@ +# Glossary + +The project's ubiquitous language — the domain terms that code, specs, and +capability pages share. Living prose, no frontmatter, dated by git. Each entry is +a term, what it *is* (not what it does), and the synonyms to avoid. No +implementation detail; this is a glossary, not a spec. + +**Term**: +A one-or-two-sentence definition of what it is. +_Avoid_: rejected-synonym, another-one + +**Another term**: +Define what it is, tightly. Group related terms under `##` subheadings when +natural clusters emerge; a flat list is fine when they don't. +_Avoid_: … diff --git a/planning/_templates/plan.md b/planning/_templates/plan.md deleted file mode 100644 index 132d720..0000000 --- a/planning/_templates/plan.md +++ /dev/null @@ -1,46 +0,0 @@ -# — 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:** One sentence — what shipping this plan achieves. No design -rationale; link to the spec for that. - -**Spec:** [`design.md`](./design.md) - -**Branch:** `feat/my-change` (or `fix/`, `chore/`, etc.) - -**Commit strategy:** Per-task commits / single commit / squash on merge. -Whichever fits. - ---- - -### Task 1: - -**Files:** -- Modify: `path/to/file.py` -- Create: `path/to/new.py` - -One sentence on what this task accomplishes. No deeper reasoning — that's -in the spec. - -- [ ] **Step 1: ** - - Run / edit / verify command. Expected output. - -- [ ] **Step 2: ** - - ... - -- [ ] **Step 3: Commit** - - ```bash - git add path/to/file.py - git commit -m ": " - ``` - ---- - -### Task 2: ... diff --git a/planning/changes/2026-05-31.01-bmad-to-superpowers-transition/design.md b/planning/changes/2026-05-31.01-bmad-to-superpowers-transition.md similarity index 100% rename from planning/changes/2026-05-31.01-bmad-to-superpowers-transition/design.md rename to planning/changes/2026-05-31.01-bmad-to-superpowers-transition.md diff --git a/planning/changes/2026-05-31.01-bmad-to-superpowers-transition/plan.md b/planning/changes/2026-05-31.01-bmad-to-superpowers-transition/plan.md deleted file mode 100644 index 12ff9bb..0000000 --- a/planning/changes/2026-05-31.01-bmad-to-superpowers-transition/plan.md +++ /dev/null @@ -1,661 +0,0 @@ -# bmad → superpowers transition 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:** Cutover the `httpware` project from bmad to superpowers in a single PR. After merge, all future work runs through the superpowers flow (brainstorming → writing-plans → executing-plans → requesting-code-review). - -**Architecture:** Docs-only refactor. Move five large bmad planning files plus the `docs/stories/` directory into `docs/archive/`. Write one new distilled `docs/dev/engineering.md` (~250–350 lines) sourcing content from the archived docs and `CLAUDE.md`. Update `CLAUDE.md` to point at the new structure. Delete `.review-tmp/`. No source code changes. - -**Tech Stack:** Markdown only. Git for renames. `just lint` and `just test` for regression verification (they should remain green; the cutover touches no source). - -**Branch:** `chore/bmad-to-superpowers-transition` (already exists; the brainstorming spec is the only commit on it as of plan creation). - -**Prereqs already satisfied:** -- Story 1-5 merged to `main` (PR #5). -- Branch rebased onto post-1-5 `main`. -- Spec at `planning/specs/2026-05-31-bmad-to-superpowers-transition-design.md` (committed). -- This plan file exists at `planning/plans/2026-05-31-bmad-to-superpowers-transition-plan.md` (untracked until Task 6 stages it). - ---- - -## File Structure - -**Files created:** -- `docs/dev/engineering.md` — the distilled doc (~250–350 lines). Single source for design rationale + roadmap. -- `docs/archive/README.md` — ~1 paragraph framing the archive as historical reference. -- `planning/plans/2026-05-31-bmad-to-superpowers-transition-plan.md` — this file. - -**Files moved (git mv, preserves history):** -- `docs/prd.md` → `docs/archive/prd.md` -- `docs/architecture.md` → `docs/archive/architecture.md` -- `docs/epics.md` → `docs/archive/epics.md` -- `docs/product-brief-httpware.md` → `docs/archive/product-brief-httpware.md` -- `docs/product-brief-httpware-distillate.md` → `docs/archive/product-brief-httpware-distillate.md` -- `docs/stories/` → `docs/archive/stories/` (entire directory) - -**Files modified:** -- `CLAUDE.md` — rewrite the "Project Overview" section and add a one-liner about the per-feature flow. Keep the "Architecture invariants (CI-enforced)", "Commands", "Code conventions", "Module layout", "Protocol seams", "Testing", and "When in doubt" sections verbatim except for archived-path references. - -**Files deleted:** -- `.review-tmp/` (entire directory) — bmad code-review artifact dump. - -**Files untouched (deliberate):** -- `planning/deferred-work.md` — kept at repo root as the active deferral log. -- All `src/httpware/**`, `tests/**`, `pyproject.toml`, `Justfile`, `.github/**`, `CHANGELOG.md`, `README.md`, `CONTRIBUTING.md`, `SECURITY.md`, `LICENSE` — no source/CI changes. - ---- - -## Task 1: Move bmad artifacts to `docs/archive/` - -**Files:** -- Move: `docs/prd.md` → `docs/archive/prd.md` -- Move: `docs/architecture.md` → `docs/archive/architecture.md` -- Move: `docs/epics.md` → `docs/archive/epics.md` -- Move: `docs/product-brief-httpware.md` → `docs/archive/product-brief-httpware.md` -- Move: `docs/product-brief-httpware-distillate.md` → `docs/archive/product-brief-httpware-distillate.md` -- Move: `docs/stories/` → `docs/archive/stories/` - -- [ ] **Step 1: Verify branch and working-tree state** - -Run: `git branch --show-current && git status --short` -Expected: branch is `chore/bmad-to-superpowers-transition`. Working tree has untracked `planning/plans/2026-05-31-bmad-to-superpowers-transition-plan.md` (this file) and nothing else. - -If anything else is dirty, stop and resolve before proceeding. - -- [ ] **Step 2: Create the archive directory** - -Run: `mkdir -p docs/archive` -Expected: silent success. Verify with `ls -d docs/archive`. - -- [ ] **Step 3: Move the five top-level bmad docs with git mv** - -Run: -```bash -git mv docs/prd.md docs/archive/prd.md -git mv docs/architecture.md docs/archive/architecture.md -git mv docs/epics.md docs/archive/epics.md -git mv docs/product-brief-httpware.md docs/archive/product-brief-httpware.md -git mv docs/product-brief-httpware-distillate.md docs/archive/product-brief-httpware-distillate.md -``` - -Expected: each command silent on success. - -- [ ] **Step 4: Move the stories directory** - -Run: `git mv docs/stories docs/archive/stories` -Expected: silent success. - -- [ ] **Step 5: Verify the move** - -Run: `git status --short | head -20` -Expected: six rename entries (`R docs/prd.md -> docs/archive/prd.md` and so on, including the stories directory contents shown as individual renames). Untracked plan file still listed. - -Run: `ls docs/` -Expected: `archive/`, `deferred-work.md`, `superpowers/`. (No `prd.md`, `architecture.md`, etc., at the top of `docs/`.) - -Run: `ls docs/archive/` -Expected: `architecture.md epics.md prd.md product-brief-httpware-distillate.md product-brief-httpware.md stories/`. - ---- - -## Task 2: Write `docs/archive/README.md` - -**Files:** -- Create: `docs/archive/README.md` - -- [ ] **Step 1: Write the archive README** - -Contents: - -```markdown -# Archive - -This directory contains the bmad-era planning artifacts for `httpware`: - -- `prd.md` — 47 functional and 25 non-functional requirements. -- `architecture.md` — twelve architectural decisions, the five protocol seams, full module layout. -- `epics.md` — six epics with 32 stories. -- `product-brief-httpware.md` and `product-brief-httpware-distillate.md` — executive brief and detail pack from the predecessor `community-of-python/base-client` scoping exercise. -- `stories/` — per-story specs (1-1 through 1-5) and the retired `sprint-status.yaml`. - -These files are **historical reference, not authoritative**. The load-bearing decisions were distilled into [`../engineering.md`](../engineering.md) on 2026-05-31 when the project switched workflows from bmad to superpowers. Consult these archived files only when you need: - -- Original rationale behind a decision (e.g., "why did we choose `httpx2` over `aiohttp`?"). -- The specific FR/NFR numbers that a future spec wants to cite (e.g., `archive/prd.md#NFR-12`). -- The Given/When/Then acceptance criteria from a completed story. - -For everything else — invariants, seams, module layout, conventions, the remaining roadmap — read `../engineering.md` and `../../CLAUDE.md`. -``` - -- [ ] **Step 2: Verify it exists** - -Run: `wc -l docs/archive/README.md` -Expected: ~18 lines. - ---- - -## Task 3: Write `docs/dev/engineering.md` - -This is the largest task. The doc has nine sections (per the spec). Build it section by section. Final length target: 250–350 lines. - -**Files:** -- Create: `docs/dev/engineering.md` - -**Source material:** -- `CLAUDE.md` — invariants and conventions (current file at repo root). -- `docs/archive/architecture.md` — protocol seams details (lines 198–225 transport, 225–270 middleware, 270–294 validation/decoding), module layout, exception contract context. -- `docs/archive/prd.md` — project intent, optional-extras pattern. -- `docs/archive/stories/sprint-status.yaml` — full backlog for the roadmap section. -- `src/httpware/` — current module tree (post-1.5), needed for accurate Section 5. - -- [ ] **Step 1: Write Section 1 — Project intent (3–4 sentences)** - -Append to `docs/dev/engineering.md`: - -```markdown -# `httpware` engineering notes - -This doc is the single distilled reference for `httpware` design rationale, protocol seams, and remaining roadmap. It complements [`../CLAUDE.md`](../CLAUDE.md): `CLAUDE.md` holds AI-enforced invariants and operational commands; this file holds the reasoning and the structural map. Historical planning artifacts live in [`archive/`](./archive/) and are cited only for original rationale. - -## 1. Project intent - -`httpware` is a Python async HTTP client framework for building resilient service clients. It supersedes `community-of-python/base-client` and ships under the `modern-python` org. The framework owns the abstraction layer above the underlying HTTP client (`httpx2` by default); consumers never import the transport directly. -``` - -- [ ] **Step 2: Write Section 2 — Architectural invariants (with "why" per item)** - -Append: - -```markdown -## 2. Architectural invariants (CI-enforced) - -These are non-negotiable. CI rejects PRs that violate them. The "why" exists so future contributors can judge edge cases instead of blindly following the rule. - -- **No `httpx2` leakage outside `src/httpware/transports/httpx2.py`.** *Why:* the whole point of the framework is to own the abstraction above the underlying client. Any consumer that imports `httpx2` directly defeats the abstraction and pins us to the current transport choice. -- **No `httpx2` private API.** *Why:* private symbols can change between patch releases. We accept the public-API surface as the contract. -- **No `from __future__ import annotations`.** *Why:* Python 3.11+ floor. PEP 604/585 syntax is native; the future-import would only add noise and inconsistency. -- **No `print()`.** *Why:* ruff-enforced. Libraries log; they do not print to stdout. Stray prints leak into consumer applications. -- **No global logging config.** *Why:* `logging.basicConfig()` from a library mutates the consumer's logging tree. We only acquire `logging.getLogger("httpware")` or namespaced child loggers and let consumers configure handlers. -- **Type suppressions use `# ty: ignore[]`.** *Why:* this project uses `ty`, not `mypy`. `# type: ignore` is silently accepted by `ty` but ambiguous; `# ty: ignore[]` is checked and rule-specific. -``` - -- [ ] **Step 3: Write Section 3 — The five protocol seams** - -Append: - -```markdown -## 3. The five protocol seams - -A protocol seam is a documented internal boundary. AI agents and contributors must respect it — never cross a seam except through its protocol. - -### Seam 1: `Middleware ↔ Transport` - -- **Where:** `src/httpware/middleware/` (chain) ↔ `src/httpware/transports/` (any `Transport` implementation). -- **Contract:** the chain bottom calls `transport.__call__(request) -> Response`. -- **Rule:** middleware never instantiates a transport; the `AsyncClient` injects it at construction. - -### Seam 2: `AsyncClient ↔ Middleware` - -- **Where:** `src/httpware/client.py` ↔ `src/httpware/middleware/`. -- **Contract:** the middleware chain is composed at `AsyncClient.__init__` and frozen for the client's lifetime. -- **Rule:** mutating the chain after construction is not supported. Per-request middleware is expressed via the `Request` extensions field, not by rebuilding the chain. - -### Seam 3: `AsyncClient ↔ ResponseDecoder` - -- **Where:** `src/httpware/client.py` ↔ `src/httpware/decoders/`. -- **Contract:** the decoder is invoked when the caller passes `response_model=`. The protocol is `decode(content: bytes, model: type[T]) -> T`. -- **Rule:** the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (`json.loads` then `validate_python`) is rejected — see `archive/architecture.md` Validation & Decoding for rationale. - -### Seam 4: `Httpx2Transport ↔ httpx2` - -- **Where:** `src/httpware/transports/httpx2.py` is the only file that may import `httpx2`. -- **Contract:** `httpx2` exceptions are mapped to `httpware` exceptions at this seam. -- **Rule:** CI grep checks enforce zero `httpx2` imports outside this file and zero `httpx2._` private references anywhere. - -### Seam 5: `httpware ↔ optional extras` - -- **Where:** `pyproject.toml` extras (`[project.optional-dependencies]`) ↔ the adapter modules that import them. -- **Contract:** each optional dependency is imported only inside its own dedicated module (e.g., `pydantic` in `decoders/pydantic.py`; `msgspec` in `decoders/msgspec.py` when 1-6 lands; `opentelemetry` in `middleware/observability/otel.py` when 5-4 lands). -- **Rule:** never import an extra at package top-level. The package must import cleanly when the extra is not installed. -``` - -- [ ] **Step 4: Write Section 4 — Exception contract** - -Append: - -```markdown -## 4. Exception contract - -All `httpware` HTTP exceptions are constructed with **keyword arguments only**. The mandatory fields on every `StatusError` (and its 4xx/5xx subclasses) are: - -| Field | Type | Source | -| --- | --- | --- | -| `status` | `int` | response status code | -| `body` | `bytes` | full response body | -| `headers` | `Mapping[str, str]` | lowercased response headers (v0 contract) | -| `json` | `Any \| None` | parsed JSON if `application/json` content-type; else `None` | -| `request_method` | `str` | uppercased request method | -| `request_url` | `str` | request URL, may include userinfo (Redactor sanitizes — Story 5.3) | - -The mapping table from `httpx2` errors to `httpware` errors lives at Seam 4 (`src/httpware/transports/httpx2.py`). Status-keyed exceptions are looked up via the `STATUS_TO_EXCEPTION` table in `src/httpware/errors.py`. - -Constructing any of these exceptions positionally is a programming error caught by `ty`. The keyword-only signature is enforced via `__init__` definitions, not docstrings. -``` - -- [ ] **Step 5: Write Section 5 — Module layout** - -Read the current `src/httpware/` tree. - -Run: `find src/httpware -type d -o -name '*.py' | sort` - -Append (verifying tree against actual output): - -```markdown -## 5. Module layout - -Current tree (post-story-1.5): - -```text -src/httpware/ -├── __init__.py # public exports + __all__ -├── py.typed -├── config.py # Limits, Timeout, ClientConfig -├── request.py # Request + with_* helpers -├── response.py # Response (StreamResponse pending in Epic 4) -├── errors.py # status-keyed exception hierarchy -├── decoders/ -│ ├── __init__.py # ResponseDecoder protocol (Seam 3) -│ └── pydantic.py # PydanticDecoder adapter -└── transports/ - ├── __init__.py # Transport protocol - └── httpx2.py # Httpx2Transport adapter (Seam 4) -``` - -Planned modules (filled in as the roadmap lands): - -```text -src/httpware/ -├── client.py # AsyncClient (Story 1.7) -├── decoders/msgspec.py # MsgspecDecoder via extra (Story 1.6) -├── transports/recorded.py # RecordedTransport for testing (Story 1.8) -├── middleware/ # protocols + built-in middleware (Epic 2) -│ ├── __init__.py # Middleware protocol, Next type -│ ├── chain.py # chain composition -│ ├── auth.py # auth coercion (Story 2.4) -│ ├── timeout.py # per-attempt timeout (Story 3.1) -│ ├── retry.py # retry + RetryBudget (Stories 3.2–3.4) -│ ├── bulkhead.py # concurrency limit (Story 3.5) -│ └── observability/ # Layer 1 emission + OTEL (Epic 5) -└── _internal/ # private cross-module helpers -``` -``` - -- [ ] **Step 6: Write Section 6 — Testing patterns** - -Append: - -```markdown -## 6. Testing patterns - -- **`pytest-asyncio` auto mode.** Async test functions do not require `@pytest.mark.asyncio`. The setting lives in `pyproject.toml` under `[tool.pytest.ini_options]`. -- **`RecordedTransport` for transport mocking, not `respx`.** Once Story 1.8 lands, transport-level tests instantiate `RecordedTransport` (shipped with the library) instead of patching `httpx2` calls. This keeps tests aligned with the public seam and avoids `respx`'s private-API risk. -- **Hypothesis property-based tests** for concurrency-sensitive code: `RetryBudget`, `Bulkhead`, retry interleaving. Files are named `test_*_props.py` so they are easy to grep and treat separately in CI. -- **Performance tests are opt-in.** The `perf` pytest marker is registered in `pyproject.toml`; the default `addopts` line includes `-m 'not perf'`. Run benchmarks explicitly with `pytest -m perf`. -- **Coverage is 100% line coverage.** The five merged stories ship at 100% line coverage. New code is expected to maintain this. -``` - -- [ ] **Step 7: Write Section 7 — Optional-extras pattern** - -Append: - -```markdown -## 7. Optional-extras pattern - -`httpware` core has a small dependency set. Capabilities that pull in heavyweight dependencies (`pydantic`, `msgspec`, `opentelemetry`) live behind extras declared in `pyproject.toml`: - -```toml -[project.optional-dependencies] -pydantic = ["pydantic>=2"] -msgspec = ["msgspec>=0.18"] -otel = ["opentelemetry-api>=1.20", "opentelemetry-sdk>=1.20"] -``` - -Each extra's code lives in a single dedicated module (e.g., `decoders/pydantic.py`, `decoders/msgspec.py`, `middleware/observability/otel.py`). The `import` of the extra happens **inside** that module — never at package top level. This way, `import httpware` works cleanly without the extras installed, and the seam stays observable: grep for `import pydantic` should return exactly one file. - -Caller-facing pattern: consumers select the implementation by passing it explicitly, e.g., `AsyncClient(decoder=PydanticDecoder())`. There is no auto-detection or implicit registry. -``` - -- [ ] **Step 8: Write Section 8 — Remaining roadmap** - -Append: - -```markdown -## 8. Remaining roadmap - -Twenty-seven stories remain. Topic slugs in `planning/specs/` and `planning/plans/` use kebab-case descriptions, not the story IDs — these IDs are kept here only as a stable mapping to the archived epic specs (`archive/epics.md`). - -### Epic 1 — Make typed HTTP requests with sensible defaults - -- **1-6** `msgspec` decoder via extras — second `ResponseDecoder` adapter, opt-in. -- **1-7** `AsyncClient` with HTTP methods, `response_model`, `with_options`, lifecycle — the main public surface. -- **1-8** `RecordedTransport` for testing — ships with the library; replaces `respx` for transport-level tests. - -### 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-3** `Request` immutability helpers (`with_headers`, `with_cookie`, `with_extension`, etc.). -- **2-4** Auth coercion as middleware. -- **2-5** Wire middleware into `AsyncClient`. - -### Epic 3 — Survive upstream failures with composable resilience - -- **3-1** Per-attempt timeout middleware. -- **3-2** Retry middleware. -- **3-3** `RetryBudget` data structure. -- **3-4** `RetryBudget` middleware integration. -- **3-5** `Bulkhead` middleware. -- **3-6** Document the extension slot for custom resilience policies. - -### Epic 4 — Stream responses without buffering - -- **4-1** `StreamResponse` type. -- **4-2** Transport stream implementation in `Httpx2Transport`. -- **4-3** `AsyncClient.stream` context manager. - -### Epic 5 — Observe and instrument the client - -- **5-1** Layer 1 observability — middleware lifecycle hooks. -- **5-2** Wire emission into resilience middlewares. -- **5-3** `Redactor` class and integration (closes deferred work on URL/header/userinfo sanitization). -- **5-4** OpenTelemetry middleware via the `otel` extra. -- **5-5** Logging policy enforcement (CI grep on `logging.basicConfig`, `logging.getLogger()` without a name). - -### Epic 6 — Ship v1.0 - -- **6-1** Migration guide from `base-client`. -- **6-2** Documentation site (`mkdocs`). -- **6-3** Public benchmark suite. -- **6-4** CI enforcement gates (codify the invariants in Section 2 as CI jobs). -- **6-5** Release flow with Trusted Publishers + Sigstore. - -When work starts on a roadmap item, it gets a superpowers spec at `planning/specs/YYYY-MM-DD--design.md` and a plan at `planning/plans/YYYY-MM-DD--plan.md`. The bmad-era 40KB story specs in `archive/stories/` cover 1-1 through 1-5 and are retired going forward. -``` - -- [ ] **Step 9: Write Section 9 — Deferred work pointer** - -Append: - -```markdown -## 9. Deferred work - -Review-surfaced items that are real but not actionable now live in [`./deferred-work.md`](./deferred-work.md). Each entry cites the originating story and the file/line, and explains why the fix is deferred (cross-story dependency, scope, performance/security tradeoff, etc.). When a deferred item becomes actionable, it migrates into the spec for the story that resolves it. -``` - -- [ ] **Step 10: Verify the engineering doc** - -Run: `wc -l docs/dev/engineering.md` -Expected: between 250 and 350 lines. If significantly shorter, sections were skipped; if longer, tighten the prose. - -Run: `grep -c "^## " docs/dev/engineering.md` -Expected: `9` (nine top-level sections, one per spec requirement). - -Run: `grep -n "TBD\|TODO\|XXX" docs/dev/engineering.md` -Expected: no matches. - -Run (sanity-check internal links): -```bash -grep -oE '\]\([^)]+\)' docs/dev/engineering.md | sort -u -``` -Expected: every relative link points to a file that exists. Specifically `../CLAUDE.md`, `./archive/`, `./deferred-work.md`, `archive/architecture.md` should all be reachable from `docs/`. Verify each: - -```bash -ls ../CLAUDE.md docs/archive planning/deferred-work.md docs/archive/architecture.md -``` - -Each should exist. - ---- - -## Task 4: Update `CLAUDE.md` - -**Files:** -- Modify: `CLAUDE.md` — replace the "Project Overview" section; add a workflow one-liner. - -- [ ] **Step 1: Read current `CLAUDE.md`** - -The current file has these sections (line ranges approximate): -- `# CLAUDE.md` header -- `## Project Overview` — currently references `docs/prd.md`, `docs/architecture.md`, `docs/epics.md`, `docs/product-brief-httpware.md`, `docs/product-brief-httpware-distillate.md`, `docs/stories/`, and notes about the predecessor `community-of-python/base-client`. -- `## Commands` — `just` recipes -- `## Architecture invariants (CI-enforced)` — the six rules -- `## Code conventions` -- `## Module layout` -- `## Protocol seams` -- `## Testing` -- `## When in doubt` - -Read it to get the exact existing text of the "Project Overview" section. - -Run: `sed -n '/^## Project Overview/,/^## Commands/p' CLAUDE.md` - -- [ ] **Step 2: Replace the "Project Overview" section** - -Replace the entire `## Project Overview` section (between the heading and the start of `## Commands`) with: - -```markdown -## Project Overview - -`httpware` is a Python async HTTP client framework for building resilient service clients. It supersedes `community-of-python/base-client` and ships under the `modern-python` org. The framework owns the abstraction layer above the underlying HTTP client (`httpx2` by default); consumers never import the transport. - -**Where to find what:** - -- [`docs/dev/engineering.md`](docs/dev/engineering.md) — the distilled design reference: invariants and *why*, the five protocol seams, exception contract, module layout, testing patterns, optional-extras pattern, remaining roadmap. Read this before adding any new module or extension point. -- [`planning/deferred-work.md`](planning/deferred-work.md) — review-surfaced items that are real but not actionable now. -- [`planning/specs/`](planning/specs/) and [`planning/plans/`](planning/plans/) — per-feature design specs and implementation plans (active work). -- [`docs/archive/`](docs/archive/) — historical bmad-era planning bundle (PRD, architecture, epics, product briefs, per-story specs for 1-1 through 1-5). Consult only for original rationale or specific FR/NFR citations. - -**Per-feature workflow:** brainstorming → spec in `planning/specs/` → writing-plans → plan in `planning/plans/` → executing-plans (or subagent-driven-development) → requesting-code-review → finishing-a-development-branch. Topic slugs are kebab-case descriptions (`msgspec-decoder-adapter`), not story IDs. -``` - -The "Architecture invariants (CI-enforced)", "Code conventions", "Module layout", "Protocol seams", "Testing", "Commands", and "When in doubt" sections stay **verbatim**. These are operational rules and quick references that the AI applies directly; they are not duplicated in `engineering.md` (which holds the rationale, not the rules). - -- [ ] **Step 3: Update the "When in doubt" section** - -The existing "When in doubt" section may reference `base-client/docs/architecture.md`. Update any such reference to `docs/dev/engineering.md` (primary) and `docs/archive/architecture.md` (historical detail). - -Run: `grep -n "base-client/docs\|docs/prd.md\|docs/architecture.md\|docs/epics.md\|docs/stories\|product-brief" CLAUDE.md` - -Each match needs to be rewritten or removed. After fixing, re-run the grep — expected: no matches (every reference to a moved file is gone or rewritten to `docs/archive/`). - -- [ ] **Step 4: Verify the updated CLAUDE.md** - -Run: `wc -l CLAUDE.md` -Expected: roughly 105–120 lines (the original was 104; the new Project Overview is slightly longer than the old one). - -Run: `grep -c "^## " CLAUDE.md` -Expected: matches the original section count (no sections lost). The original had Project Overview, Commands, Architecture invariants (CI-enforced), Code conventions, Module layout, Protocol seams, Testing, When in doubt — count `8`. - -Run: `grep -n "docs/dev/engineering.md\|docs/archive" CLAUDE.md` -Expected: multiple matches in the new Project Overview section. - ---- - -## Task 5: Delete `.review-tmp/` - -**Files:** -- Delete: `.review-tmp/` (entire directory, untracked — never committed). - -- [ ] **Step 1: Verify the directory is untracked** - -Run: `git ls-files .review-tmp/` -Expected: no output (zero tracked files). - -Run: `ls .review-tmp/` -Expected: `story-1-1.diff`, `story-1-2.diff`, `story-1-4.diff`, `story-1-5.bundle.md` (and possibly others). - -- [ ] **Step 2: Delete it** - -Run: `rm -rf .review-tmp/` - -Run: `ls .review-tmp/ 2>&1 | head -2` -Expected: `ls: .review-tmp/: No such file or directory`. - -Run: `git status --short | grep review-tmp || echo "GONE"` -Expected: `GONE`. - ---- - -## Task 6: Verify and commit - -- [ ] **Step 1: Run the test suite** - -Run: `just test` -Expected: 157 passed, 1 deselected (the perf bench), 100% coverage. No changes vs. post-1-5 baseline because no source was touched. - -- [ ] **Step 2: Run lint** - -Run: `just lint-ci` -Expected: clean. No markdown lint is configured; this just confirms the source/CI state is unchanged. - -- [ ] **Step 3: Verify the pre-staging diff** - -Run: `git status --short` - -Expected output (order may vary). The renames from Task 1 are already staged (via `git mv`); the new files from Tasks 2 and 3 and this plan file are untracked; `CLAUDE.md` is modified but unstaged: - -``` -M CLAUDE.md # unstaged modification (space + M) -R docs/architecture.md -> docs/archive/architecture.md # staged rename -R docs/epics.md -> docs/archive/epics.md -R docs/prd.md -> docs/archive/prd.md -R docs/product-brief-httpware-distillate.md -> docs/archive/product-brief-httpware-distillate.md -R docs/product-brief-httpware.md -> docs/archive/product-brief-httpware.md -R docs/stories/1-1-project-scaffold-and-tooling.md -> docs/archive/stories/1-1-project-scaffold-and-tooling.md -R docs/stories/1-2-core-data-types.md -> docs/archive/stories/1-2-core-data-types.md -R docs/stories/1-3-exception-hierarchy-with-plain-fields.md -> docs/archive/stories/1-3-exception-hierarchy-with-plain-fields.md -R docs/stories/1-4-transport-protocol-and-httpx2transport-adapter.md -> docs/archive/stories/1-4-transport-protocol-and-httpx2transport-adapter.md -R docs/stories/1-5-responsedecoder-protocol-and-pydantic-adapter.md -> docs/archive/stories/1-5-responsedecoder-protocol-and-pydantic-adapter.md -R docs/stories/sprint-status.yaml -> docs/archive/stories/sprint-status.yaml -?? docs/archive/README.md # untracked -?? docs/dev/engineering.md -?? planning/plans/2026-05-31-bmad-to-superpowers-transition-plan.md -``` - -No `.review-tmp/` anywhere. No source/`pyproject.toml`/CI changes. - -If any line begins with `??` for a file not in the list above, or any source file appears, stop and resolve. - -- [ ] **Step 4: Stage everything** - -Run: `git add CLAUDE.md docs/` - -(`git add docs/` picks up the new `docs/archive/README.md`, `docs/dev/engineering.md`, and `planning/plans/2026-05-31-...-plan.md` in one step.) - -Re-run: `git status --short` -Expected: every line begins with a capital letter (`A`, `M`, or `R`) in column 1 and a space in column 2 (everything staged, nothing left in the working tree). - -- [ ] **Step 5: Commit** - -Run: -```bash -git commit -m "$(cat <<'EOF' -chore: cutover from bmad to superpowers workflow - -Distills the bmad-era PRD / architecture / epics into a single -docs/dev/engineering.md (~300 lines) covering project intent, the six -CI-enforced invariants with rationale, the five protocol seams, -the exception contract, current + planned module layout, testing -patterns, the optional-extras pattern, the remaining 27-story -roadmap, and a pointer to deferred-work.md. - -Moves prd.md, architecture.md, epics.md, both product briefs, and -the per-story specs (1-1 through 1-5 + sprint-status.yaml) into -docs/archive/ with a README framing them as historical reference. - -Rewrites CLAUDE.md "Project Overview" to point at engineering.md -and the new docs/superpowers/{specs,plans}/ flow. All operational -sections (invariants, conventions, module layout, seams, testing, -commands, when in doubt) are kept verbatim. - -Deletes the .review-tmp/ bmad code-review artifact dump. - -No source / CI changes. 157 tests still pass at 100% coverage. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -Run: `git log --oneline -3` -Expected: the cutover commit on top, then the spec commit (`docs(superpowers): brainstorming spec...`), then the 1-5 merge commit. - ---- - -## Task 7: Push and open the cutover PR - -- [ ] **Step 1: Push the branch** - -Run: `git push -u origin chore/bmad-to-superpowers-transition` -Expected: push succeeds; GitHub prints a "Create a pull request for ..." URL. - -- [ ] **Step 2: Open the PR** - -Run: -```bash -gh pr create --title "chore: cutover from bmad to superpowers workflow" --body "$(cat <<'EOF' -## Summary - -- Distills the bmad-era PRD / architecture / epics into a single `docs/dev/engineering.md` (~300 lines): project intent, six CI-enforced invariants with rationale, the five protocol seams, exception contract, module layout, testing patterns, optional-extras pattern, remaining 27-story roadmap, deferred-work pointer. -- Moves `prd.md`, `architecture.md`, `epics.md`, both product briefs, and `docs/stories/` (1-1 through 1-5 + `sprint-status.yaml`) into `docs/archive/` with a framing README. -- Rewrites `CLAUDE.md` "Project Overview" to point at `engineering.md` and the new `docs/superpowers/{specs,plans}/` flow. Operational sections (invariants, conventions, module layout, seams, testing, commands, when-in-doubt) are kept verbatim. -- Deletes the `.review-tmp/` bmad code-review artifact dump. - -After this lands, future per-feature work uses the superpowers flow: brainstorming → spec in `planning/specs/` → writing-plans → plan in `planning/plans/` → executing-plans (or subagent-driven-development) → requesting-code-review → finishing-a-development-branch. Story IDs are retired as topic slugs; the bmad backlog mapping lives in `engineering.md`'s roadmap section. - -The driving design + plan for this PR are at `planning/specs/2026-05-31-bmad-to-superpowers-transition-design.md` and `planning/plans/2026-05-31-bmad-to-superpowers-transition-plan.md`. - -## Test plan - -- [x] No source or CI changes; the 157-test suite continues to pass at 100% coverage on the branch. -- [x] `just lint-ci` clean. -- [x] `git status --short` shows only renames, three new docs files, one modified `CLAUDE.md`, and the `.review-tmp/` deletion. -- [x] All internal links in `docs/dev/engineering.md` and `CLAUDE.md` resolve to files that exist post-move. -- [ ] CI green on the PR. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -Run: `gh pr view --json url,state | head -5` -Expected: state `OPEN`, URL printed. - -- [ ] **Step 3: Wait for CI** - -Run: `gh pr checks` (or wait for the existing `lint` + `pytest (3.11)` / `pytest (3.12)` / `pytest (3.13)` / `pytest (3.14)` jobs). -Expected: all checks green. The PR touches no source so failures would indicate a CLAUDE.md / engineering.md formatting issue or a broken internal link only. - -- [ ] **Step 4: Merge** - -Once CI is green and the PR is approved (or self-approved if working solo): - -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 commit at HEAD, then the spec commit, then the 1-5 merge. - -The cutover is complete. Task 1 (retrospective code review) is the next normal-flow item. - ---- - -## Definition of done - -- `docs/dev/engineering.md` exists (250–350 lines) and is referenced from `CLAUDE.md`. -- `docs/archive/` contains the six top-level moves plus the `stories/` directory and a framing `README.md`. -- `planning/specs/2026-05-31-bmad-to-superpowers-transition-design.md` and `planning/plans/2026-05-31-bmad-to-superpowers-transition-plan.md` are committed. -- `.review-tmp/` is gone. -- `CLAUDE.md` no longer references `docs/prd.md`, `docs/architecture.md`, `docs/epics.md`, `docs/stories/`, or the product briefs at their original paths. -- The cutover commit is a single commit on `chore/bmad-to-superpowers-transition`, merged to `main` via one PR. -- `just test` and `just lint-ci` remain green; no source/CI changes. diff --git a/planning/changes/2026-05-31.02-shipped-work-review/design.md b/planning/changes/2026-05-31.02-shipped-work-review.md similarity index 100% rename from planning/changes/2026-05-31.02-shipped-work-review/design.md rename to planning/changes/2026-05-31.02-shipped-work-review.md diff --git a/planning/changes/2026-05-31.03-middleware-protocol-and-chain/design.md b/planning/changes/2026-05-31.03-middleware-protocol-and-chain.md similarity index 100% rename from planning/changes/2026-05-31.03-middleware-protocol-and-chain/design.md rename to planning/changes/2026-05-31.03-middleware-protocol-and-chain.md diff --git a/planning/changes/2026-05-31.03-middleware-protocol-and-chain/plan.md b/planning/changes/2026-05-31.03-middleware-protocol-and-chain/plan.md deleted file mode 100644 index 6330762..0000000 --- a/planning/changes/2026-05-31.03-middleware-protocol-and-chain/plan.md +++ /dev/null @@ -1,830 +0,0 @@ -# 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:** `planning/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`, `planning/specs/2026-05-31-middleware-protocol-and-chain-design.md`, `planning/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 `planning/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 planning/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: `planning/specs/2026-05-31-middleware-protocol-and-chain-design.md`, `planning/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. diff --git a/planning/changes/2026-05-31.04-phase-shortcut-decorators/design.md b/planning/changes/2026-05-31.04-phase-shortcut-decorators.md similarity index 100% rename from planning/changes/2026-05-31.04-phase-shortcut-decorators/design.md rename to planning/changes/2026-05-31.04-phase-shortcut-decorators.md diff --git a/planning/changes/2026-05-31.04-phase-shortcut-decorators/plan.md b/planning/changes/2026-05-31.04-phase-shortcut-decorators/plan.md deleted file mode 100644 index 5a0d92e..0000000 --- a/planning/changes/2026-05-31.04-phase-shortcut-decorators/plan.md +++ /dev/null @@ -1,739 +0,0 @@ -# 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:** `planning/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/dev/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/dev/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/dev/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/dev/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/dev/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/dev/engineering.md`, `planning/specs/2026-05-31-phase-shortcut-decorators-design.md`, `planning/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 `planning/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 planning/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/dev/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: `planning/specs/2026-05-31-phase-shortcut-decorators-design.md`, `planning/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/dev/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/planning/changes/2026-05-31.05-request-immutability-helpers/design.md b/planning/changes/2026-05-31.05-request-immutability-helpers.md similarity index 100% rename from planning/changes/2026-05-31.05-request-immutability-helpers/design.md rename to planning/changes/2026-05-31.05-request-immutability-helpers.md diff --git a/planning/changes/2026-05-31.05-request-immutability-helpers/plan.md b/planning/changes/2026-05-31.05-request-immutability-helpers/plan.md deleted file mode 100644 index 6eb95a3..0000000 --- a/planning/changes/2026-05-31.05-request-immutability-helpers/plan.md +++ /dev/null @@ -1,543 +0,0 @@ -# Request / Response immutability helper expansion 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-3: add 5 new `with_*` helpers to `Request` (`with_headers`, `with_cookie`, `with_cookies`, `with_extension`, `with_extensions`) and 2 to `Response` (`with_headers`, `with_status`). - -**Architecture:** All seven helpers are one-line `dataclasses.replace(...)` calls following the existing Story-1-2 pattern (`with_header`, `with_url`, `with_body`, `with_query`). Plural helpers merge: `{**existing, **incoming}`. Singular helpers (`with_cookie`, `with_extension`) take `(name, value)` and add/replace one entry. No validation, no case normalization. - -**Tech Stack:** Python 3.11 floor; `dataclasses.replace` on frozen+slots dataclasses. No new dependencies. - -**Branch:** `story/2-3-request-immutability-helpers` (already created; spec commit `5bcf9a4` is on it). - -**Spec:** `planning/specs/2026-05-31-request-immutability-helpers-design.md`. - ---- - -## File Structure - -**Modified files:** -- `src/httpware/request.py` — append 5 helper methods (~20 lines added). -- `src/httpware/response.py` — append 2 helper methods + add `Self` and `dataclasses` imports (~10 lines added). -- `tests/test_request.py` — append 10 new tests. -- `tests/test_response.py` — append 4 new tests. -- `CHANGELOG.md` — append Story 2.3 bullet under `[Unreleased]` / `### Added`. - -**Files not touched:** everything else. Purely additive. - ---- - -## Task 1: `Request.with_headers` (merge headers) - -TDD cycle for the plural-merge helper on Request. Four tests cover add, override, preserve, and empty-input cases. - -**Files:** -- Modify: `src/httpware/request.py` (append method) -- Modify: `tests/test_request.py` (append 4 tests) - -- [ ] **Step 1: Add the failing tests** - -Append to `tests/test_request.py`: - -```python -def test_with_headers_merges_new_headers() -> None: - r = Request(method="GET", url="/") - new = r.with_headers({"X-Trace": "abc", "X-Other": "1"}) - assert new.headers == {"X-Trace": "abc", "X-Other": "1"} - assert r.headers == {} - - -def test_with_headers_overrides_existing_key() -> None: - r = Request(method="GET", url="/", headers={"X-Trace": "old"}) - new = r.with_headers({"X-Trace": "new"}) - assert new.headers == {"X-Trace": "new"} - assert r.headers == {"X-Trace": "old"} - - -def test_with_headers_preserves_other_keys() -> None: - r = Request(method="GET", url="/", headers={"Keep": "1", "Replace": "old"}) - new = r.with_headers({"Replace": "new", "Add": "2"}) - assert new.headers == {"Keep": "1", "Replace": "new", "Add": "2"} - - -def test_with_headers_empty_mapping_returns_distinct_copy() -> None: - r = Request(method="GET", url="/", headers={"A": "1"}) - new = r.with_headers({}) - assert new == r - assert new is not r -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_request.py -k "with_headers" -v` -Expected: 4 errors with `AttributeError: 'Request' object has no attribute 'with_headers'`. - -- [ ] **Step 3: Implement `with_headers`** - -Append to `src/httpware/request.py`, immediately after the existing `with_query` method (i.e., as the last method of the `Request` class): - -```python - def with_headers(self, headers: Mapping[str, str]) -> Self: - """Return a copy with the given headers merged in (incoming keys override existing).""" - return dataclasses.replace(self, headers={**self.headers, **headers}) -``` - -(Note: four-space indentation since this is a class method.) - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/test_request.py -k "with_headers" -v` -Expected: 4 passed. - -- [ ] **Step 5: Lint and ty** - -Run: `uv run ruff check src/httpware/request.py tests/test_request.py` -Run: `uv run ty check src/httpware/request.py` -Expected: both clean. - -- [ ] **Step 6: Commit** - -```bash -git add src/httpware/request.py tests/test_request.py -git commit -m "$(cat <<'EOF' -feat(story-2.3): Request.with_headers merge helper - -Adds Request.with_headers(headers: Mapping[str, str]) -> Self that -merges the incoming mapping into the existing headers; incoming keys -override existing. Four tests cover add, override, preserve-others, -and empty-input semantics. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: `Request.with_cookie` and `Request.with_cookies` - -The cookies pair mirrors `with_header` / `with_headers`: singular adds/replaces one entry, plural merges. - -**Files:** -- Modify: `src/httpware/request.py` (append two methods) -- Modify: `tests/test_request.py` (append 3 tests) - -- [ ] **Step 1: Add the failing tests** - -Append to `tests/test_request.py`: - -```python -def test_with_cookie_adds_single_cookie() -> None: - r = Request(method="GET", url="/") - new = r.with_cookie("session", "abc") - assert new.cookies == {"session": "abc"} - assert r.cookies == {} - - -def test_with_cookie_replaces_existing_cookie() -> None: - r = Request(method="GET", url="/", cookies={"session": "old"}) - new = r.with_cookie("session", "new") - assert new.cookies == {"session": "new"} - assert r.cookies == {"session": "old"} - - -def test_with_cookies_merges_new_cookies() -> None: - r = Request(method="GET", url="/", cookies={"keep": "1", "replace": "old"}) - new = r.with_cookies({"replace": "new", "add": "2"}) - assert new.cookies == {"keep": "1", "replace": "new", "add": "2"} - assert r.cookies == {"keep": "1", "replace": "old"} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_request.py -k "with_cookie" -v` -Expected: 3 errors with `AttributeError: 'Request' object has no attribute 'with_cookie'` (and `with_cookies`). - -- [ ] **Step 3: Implement both methods** - -Append to `src/httpware/request.py`, immediately after `with_headers`: - -```python - def with_cookie(self, name: str, value: str) -> Self: - """Return a copy with the given cookie added or replaced.""" - return dataclasses.replace(self, cookies={**self.cookies, name: value}) - - def with_cookies(self, cookies: Mapping[str, str]) -> Self: - """Return a copy with the given cookies merged in (incoming keys override existing).""" - return dataclasses.replace(self, cookies={**self.cookies, **cookies}) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/test_request.py -k "with_cookie" -v` -Expected: 3 passed. - -- [ ] **Step 5: Lint and ty** - -Run: `uv run ruff check src/httpware/request.py tests/test_request.py` -Run: `uv run ty check src/httpware/request.py` -Expected: both clean. - -- [ ] **Step 6: Commit** - -```bash -git add src/httpware/request.py tests/test_request.py -git commit -m "$(cat <<'EOF' -feat(story-2.3): Request.with_cookie and with_cookies helpers - -Adds Request.with_cookie(name, value) -> Self and -Request.with_cookies(cookies: Mapping) -> Self. Singular adds/replaces -one cookie; plural merges a mapping with incoming keys overriding. -Three tests cover the add, replace, and merge cases. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: `Request.with_extension` and `Request.with_extensions` - -The extensions pair mirrors `with_cookie` / `with_cookies` but values are `Any` (extensions are opaque user payloads passed to the transport). - -**Files:** -- Modify: `src/httpware/request.py` (append two methods) -- Modify: `tests/test_request.py` (append 3 tests) - -- [ ] **Step 1: Add the failing tests** - -Append to `tests/test_request.py`: - -```python -def test_with_extension_adds_single_entry() -> None: - r = Request(method="GET", url="/") - new = r.with_extension("timeout", 5.0) - assert new.extensions == {"timeout": 5.0} - assert r.extensions == {} - - -def test_with_extensions_merges_new_entries() -> None: - r = Request(method="GET", url="/", extensions={"keep": 1, "replace": "old"}) - new = r.with_extensions({"replace": "new", "add": [1, 2]}) - assert new.extensions == {"keep": 1, "replace": "new", "add": [1, 2]} - assert r.extensions == {"keep": 1, "replace": "old"} - - -def test_with_extension_accepts_any_value_type() -> None: - class _Marker: - pass - - marker = _Marker() - r = Request(method="GET", url="/") - new = r.with_extension("marker", marker) - assert new.extensions == {"marker": marker} - assert new.extensions["marker"] is marker -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_request.py -k "with_extension" -v` -Expected: 3 errors with `AttributeError: 'Request' object has no attribute 'with_extension'` (and `with_extensions`). - -- [ ] **Step 3: Implement both methods** - -Append to `src/httpware/request.py`, immediately after `with_cookies`: - -```python - def with_extension(self, name: str, value: Any) -> Self: # noqa: ANN401 - """Return a copy with the given extension entry added or replaced.""" - return dataclasses.replace(self, extensions={**self.extensions, name: value}) - - def with_extensions(self, extensions: Mapping[str, Any]) -> Self: - """Return a copy with the given extensions merged in (incoming keys override existing).""" - return dataclasses.replace(self, extensions={**self.extensions, **extensions}) -``` - -The `# noqa: ANN401` on `with_extension`'s `value: Any` is intentional — extensions are opaque user payloads. Matches the existing `# noqa: ANN401` pattern on `Response.json()`. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/test_request.py -k "with_extension" -v` -Expected: 3 passed. - -- [ ] **Step 5: Full Request test pass + lint** - -Run: `uv run pytest tests/test_request.py -v` -Expected: All previously-passing tests plus 10 new ones (4 from Task 1 + 3 from Task 2 + 3 from Task 3) pass. - -Run: `uv run ruff check src/httpware/request.py tests/test_request.py` -Run: `uv run ty check src/httpware/request.py` -Expected: both clean. - -- [ ] **Step 6: Commit** - -```bash -git add src/httpware/request.py tests/test_request.py -git commit -m "$(cat <<'EOF' -feat(story-2.3): Request.with_extension and with_extensions helpers - -Adds Request.with_extension(name, value: Any) -> Self and -Request.with_extensions(extensions: Mapping[str, Any]) -> Self. -Extensions hold opaque user payloads (transport hints, debug -attachments) — the Any value type is intentional and noqa'd. Three -tests cover add-single, merge-plural, and Any-value-type behavior. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: `Response.with_headers` and `Response.with_status` - -Response gets the same merge-headers helper as Request, plus `with_status` for status code replacement. - -**Files:** -- Modify: `src/httpware/response.py` (add imports + two methods) -- Modify: `tests/test_response.py` (append 4 tests) - -- [ ] **Step 1: Add the failing tests** - -Append to `tests/test_response.py`: - -```python -def test_response_with_headers_merges_new_headers() -> None: - resp = Response(status=200, headers={"keep": "1"}, content=b"", url="/", elapsed=0.0) - new = resp.with_headers({"x-trace": "abc"}) - assert new.headers == {"keep": "1", "x-trace": "abc"} - assert resp.headers == {"keep": "1"} - - -def test_response_with_headers_overrides_existing_key() -> None: - resp = Response(status=200, headers={"x-trace": "old"}, content=b"", url="/", elapsed=0.0) - new = resp.with_headers({"x-trace": "new"}) - assert new.headers == {"x-trace": "new"} - assert resp.headers == {"x-trace": "old"} - - -def test_response_with_status_replaces_status() -> None: - resp = Response(status=200, headers={"a": "1"}, content=b"body", url="/x", elapsed=0.5) - new = resp.with_status(503) - assert new.status == 503 - assert new.headers == {"a": "1"} - assert new.content == b"body" - assert new.url == "/x" - assert new.elapsed == 0.5 - assert resp.status == 200 - - -def test_response_with_status_accepts_arbitrary_int() -> None: - resp = Response(status=200, headers={}, content=b"", url="/", elapsed=0.0) - # No validation by design — value objects don't enforce protocol semantics. - new = resp.with_status(99) - assert new.status == 99 -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_response.py -k "with_" -v` -Expected: 4 errors with `AttributeError: 'Response' object has no attribute 'with_headers'` (and `with_status`). - -- [ ] **Step 3: Add imports to `src/httpware/response.py`** - -Edit the top of `src/httpware/response.py`. The current imports are: - -```python -import json -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any -``` - -Change them to: - -```python -import dataclasses -import json -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any, Self -``` - -(`import dataclasses` is added so that `dataclasses.replace(...)` works inside the new methods. `Self` is added to `typing` for the return type.) - -- [ ] **Step 4: Implement both methods on `Response`** - -In `src/httpware/response.py`, append to the `Response` class (after the existing `json` method, before the `StreamResponse` class): - -```python - def with_headers(self, headers: Mapping[str, str]) -> Self: - """Return a copy with the given headers merged in (incoming keys override existing).""" - return dataclasses.replace(self, headers={**self.headers, **headers}) - - def with_status(self, status: int) -> Self: - """Return a copy with the given status code.""" - return dataclasses.replace(self, status=status) -``` - -(Four-space indentation for class methods.) - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `uv run pytest tests/test_response.py -v` -Expected: All previously-passing tests plus 4 new ones pass. - -- [ ] **Step 6: Lint and ty** - -Run: `uv run ruff check src/httpware/response.py tests/test_response.py` -Run: `uv run ty check src/httpware/response.py` -Expected: both clean. - -- [ ] **Step 7: Commit** - -```bash -git add src/httpware/response.py tests/test_response.py -git commit -m "$(cat <<'EOF' -feat(story-2.3): Response.with_headers and with_status helpers - -Adds Response.with_headers(headers: Mapping[str, str]) -> Self and -Response.with_status(status: int) -> Self for ergonomic Response -rewriting from middleware. Both use the existing dataclasses.replace -pattern. with_status applies no validation by design — value objects -don't enforce protocol semantics. - -Adds `import dataclasses` and `Self` to response.py's typing imports. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 5: CHANGELOG bullet - -Record the Story 2.3 surface under `[Unreleased]` / `### Added`. - -**Files:** -- Modify: `CHANGELOG.md` - -- [ ] **Step 1: Append the CHANGELOG bullet** - -Edit `CHANGELOG.md`. The `## [Unreleased]` / `### Added` section currently ends with the Story 2.2 bullet about the phase-shortcut decorators. Append a new bullet immediately after the Story 2.2 bullet (still before the `[Unreleased]: ...` reference link at the bottom): - -```markdown -- Request and Response immutability helper expansion: `Request.with_headers`, `with_cookie`, `with_cookies`, `with_extension`, `with_extensions`; `Response.with_headers`, `with_status`. Plural helpers merge mappings (incoming keys override existing); singular helpers add or replace a single entry. No validation, no header-key normalization — matches the existing `with_header` semantics from Story 1.2 (Story 2.3). -``` - -- [ ] **Step 2: Commit** - -```bash -git add CHANGELOG.md -git commit -m "$(cat <<'EOF' -docs(story-2.3): CHANGELOG entry for immutability helper expansion - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 6: Verify, push, PR, merge - -End-to-end sanity check on the branch, push, open PR, wait for CI, merge. - -- [ ] **Step 1: Run the full test suite with coverage** - -Run: `just test` -Expected: 198 passed (184 baseline post-2-2 + 14 new), 1 deselected (perf), 100% line coverage including the seven new helpers. - -If coverage is below 100% on `request.py` or `response.py`, identify the uncovered line. The new helpers are all one-line bodies that are exercised by their dedicated tests — uncovered lines indicate a missing test. - -- [ ] **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: only the untracked plan file `planning/plans/2026-05-31-request-immutability-helpers-plan.md`. - -- [ ] **Step 4: Review the branch diff** - -Run: `git log --oneline main..HEAD` -Expected: six or seven commits — the spec commit (`docs(story-2.3): design...`), Task 1, Task 2, Task 3, Task 4, Task 5. - -Run: `git diff --stat main..HEAD` -Expected: changes to `CHANGELOG.md`, `planning/specs/2026-05-31-request-immutability-helpers-design.md`, `src/httpware/request.py`, `src/httpware/response.py`, `tests/test_request.py`, `tests/test_response.py`. No other files touched. - -- [ ] **Step 5: Stage and commit the plan file** - -```bash -git add planning/plans/2026-05-31-request-immutability-helpers-plan.md -git commit -m "docs(story-2.3): implementation plan for immutability helpers - -Co-Authored-By: Claude Opus 4.7 (1M context) " -``` - -- [ ] **Step 6: Push the branch** - -Run: `git push -u origin story/2-3-request-immutability-helpers` -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.3): Request/Response immutability helper expansion" --body "$(cat <<'EOF' -## Summary - -- Adds 5 helpers to \`Request\`: \`with_headers\` (merge), \`with_cookie\` / \`with_cookies\` (singular add/replace + plural merge), \`with_extension\` / \`with_extensions\` (same pattern, value type \`Any\`). -- Adds 2 helpers to \`Response\`: \`with_headers\` (merge) and \`with_status\` (replace). -- Convention: singular \`with_X(name, value)\` adds/replaces one entry; plural \`with_Xs(items)\` merges with incoming keys overriding. -- Existing helpers untouched, including \`with_query\`'s REPLACE semantics — the asymmetry vs \`with_headers\` MERGE is justified by usage patterns, HTTP semantics, and the singular-helper escape hatch (full rationale in the spec). -- 14 new tests (10 on Request, 4 on Response); 100% line coverage on new helpers; \`just test\` shows 198 passed. - -Out of scope (subsequent stories): auth coercion (2-4), AsyncClient wiring (2-5), \`StreamResponse.with_*\` (Story 4-1), case-insensitive header keys (existing deferred-work entry). - -Spec + plan: \`planning/specs/2026-05-31-request-immutability-helpers-design.md\`, \`planning/plans/2026-05-31-request-immutability-helpers-plan.md\`. - -## Test plan - -- [x] \`just test\` — 198 passed, 1 deselected, 100% line coverage. -- [x] \`just lint-ci\` clean. -- [x] \`tests/test_no_httpx2_leakage.py\` still passes. -- [ ] 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. - -If `pytest (3.14)` fails on the `codecov/codecov-action@v4.0.1` step (transient EPIPE has been observed twice in this repo), re-run with `gh run rerun --failed`. - -- [ ] **Step 9: Merge** - -Once CI is green: - -Run: `gh pr merge --merge --delete-branch` -Run: `git checkout main && git pull --ff-only && git log --oneline -3` - -Story 2-3 is complete. Story 2-4 (auth coercion as middleware) is the next normal-flow item. - ---- - -## Definition of done - -- `src/httpware/request.py` has 5 new methods: `with_headers`, `with_cookie`, `with_cookies`, `with_extension`, `with_extensions`. Existing methods untouched. -- `src/httpware/response.py` has 2 new methods: `with_headers`, `with_status`. Imports updated to include `dataclasses` and `Self`. -- `tests/test_request.py` contains 10 new tests; all pass. -- `tests/test_response.py` contains 4 new tests; all pass. -- `CHANGELOG.md` has a Story 2.3 bullet under `[Unreleased]` / `### Added`. -- `just test` shows 198 passed, 1 deselected, 100% line coverage. -- `just lint-ci` clean. -- `tests/test_no_httpx2_leakage.py` still passes. -- Both spec and plan committed on `story/2-3-request-immutability-helpers` and land via a single PR. diff --git a/planning/changes/2026-05-31.06-msgspec-decoder-via-extras/design.md b/planning/changes/2026-05-31.06-msgspec-decoder-via-extras.md similarity index 100% rename from planning/changes/2026-05-31.06-msgspec-decoder-via-extras/design.md rename to planning/changes/2026-05-31.06-msgspec-decoder-via-extras.md diff --git a/planning/changes/2026-05-31.06-msgspec-decoder-via-extras/plan.md b/planning/changes/2026-05-31.06-msgspec-decoder-via-extras/plan.md deleted file mode 100644 index b8542a1..0000000 --- a/planning/changes/2026-05-31.06-msgspec-decoder-via-extras/plan.md +++ /dev/null @@ -1,471 +0,0 @@ -# msgspec decoder via extras 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 1-6: a `MsgspecDecoder` adapter at `src/httpware/decoders/msgspec.py` backed by `msgspec.json.decode`, gated behind the `[msgspec]` extra via a new `find_spec`-based `import_checker` module borrowed from lite-bootstrap. - -**Architecture:** Two new source modules (an `import_checker` and the decoder), two new test files (decoder behavior + subprocess-based import isolation), and one CHANGELOG entry. No package-root re-export — the decoder honors seam #5 by requiring the explicit `from httpware.decoders.msgspec import MsgspecDecoder` path. - -**Tech Stack:** Python 3.11 floor; `msgspec>=0.18` via the `[msgspec]` extra already declared in `pyproject.toml`; `importlib.util.find_spec` for extra detection. - -**Branch:** `story/1-6-msgspec-decoder-via-extras` (already created; spec commit `b12a989` is on it). - -**Spec:** `planning/specs/2026-05-31-msgspec-decoder-via-extras-design.md`. - ---- - -## File Structure - -**New files:** -- `src/httpware/_internal/import_checker.py` — `find_spec`-based detection flags. Initial content: `is_msgspec_installed`. -- `src/httpware/decoders/msgspec.py` — `MsgspecDecoder` class plus `MISSING_DEPENDENCY_MESSAGE` constant. -- `tests/test_decoders_msgspec.py` — 8 behavioral tests for the decoder. -- `tests/test_optional_extras_isolation.py` — subprocess-based test that `import httpware` does not load `msgspec`. - -**Modified files:** -- `CHANGELOG.md` — append Story 1.6 bullet under `[Unreleased]` / `### Added`. - -**Files NOT touched:** -- `pyproject.toml` — `msgspec = ["msgspec>=0.18"]` is already declared from Story 1-1. -- `src/httpware/__init__.py` — no package-root re-export (seam contract). -- `src/httpware/decoders/__init__.py` — `ResponseDecoder` Protocol stays as-is. - ---- - -## Task 1: `_internal/import_checker.py` - -Create the find_spec-based detection module. One line of state, no behavior to TDD — but include a quick sanity test that the flag is True in the test environment (where `msgspec` is installed). - -**Files:** -- Create: `src/httpware/_internal/import_checker.py` - -- [ ] **Step 1: Create the module** - -Create `src/httpware/_internal/import_checker.py`: - -```python -"""Detect optional extras without importing them. Used by adapter modules to gate hard imports.""" - -from importlib.util import find_spec - - -is_msgspec_installed = find_spec("msgspec") is not None -``` - -No `__all__` (project convention — see memory `user-no-all-in-submodules`). - -- [ ] **Step 2: Sanity-check the flag in a Python REPL** - -Run: `uv run python -c "from httpware._internal import import_checker; print(import_checker.is_msgspec_installed)"` -Expected: `True` (msgspec is installed in the dev environment via `--all-extras`). - -- [ ] **Step 3: Lint and ty** - -Run: `uv run ruff check src/httpware/_internal/import_checker.py` -Run: `uv run ty check src/httpware/_internal/import_checker.py` -Expected: both clean. - -- [ ] **Step 4: Commit** - -```bash -git add src/httpware/_internal/import_checker.py -git commit -m "$(cat <<'EOF' -feat(story-1.6): _internal/import_checker.py for find_spec-based extra detection - -Adds is_msgspec_installed flag computed once at module import time via -importlib.util.find_spec. No actual import of msgspec happens — only -the importlib check. Future opt-in extras (otel in Story 5-4, etc.) -extend this module with their own flags. - -Pattern adapted from modern-python/lite-bootstrap. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: `MsgspecDecoder` + 8 decoder tests - -TDD the decoder. Start with the protocol-satisfaction test, then the happy-path decode, then add error and construction-failure tests. - -**Files:** -- Create: `src/httpware/decoders/msgspec.py` -- Create: `tests/test_decoders_msgspec.py` - -- [ ] **Step 1: Add the first failing test (protocol satisfaction)** - -Create `tests/test_decoders_msgspec.py`: - -```python -"""Unit tests for httpware.decoders.msgspec.MsgspecDecoder.""" - -import msgspec -import pytest -from pydantic import BaseModel - -from httpware._internal import import_checker -from httpware.decoders import ResponseDecoder -from httpware.decoders.msgspec import MISSING_DEPENDENCY_MESSAGE, MsgspecDecoder - - -class _Item(msgspec.Struct): - name: str - qty: int - - -class _ItemModel(BaseModel): - name: str - qty: int - - -def test_decoder_satisfies_response_decoder_protocol() -> None: - assert isinstance(MsgspecDecoder(), ResponseDecoder) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/test_decoders_msgspec.py::test_decoder_satisfies_response_decoder_protocol -v` -Expected: `ModuleNotFoundError: No module named 'httpware.decoders.msgspec'`. - -- [ ] **Step 3: Implement the decoder module** - -Create `src/httpware/decoders/msgspec.py`: - -```python -"""MsgspecDecoder — opt-in ResponseDecoder backed by msgspec.json.decode.""" - -from typing import TypeVar - -from httpware._internal import import_checker - - -if import_checker.is_msgspec_installed: - import msgspec - - -MISSING_DEPENDENCY_MESSAGE = ( - "MsgspecDecoder requires the 'msgspec' extra. " - "Install with: pip install httpware[msgspec]" -) - -T = TypeVar("T") - - -class MsgspecDecoder: - """Decode raw response bytes via `msgspec.json.decode(content, type=model)`. - - Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing - this module without the extra works (the `msgspec` import is guarded by a - `find_spec` check), but instantiating the decoder raises `ImportError` with - the install hint. - """ - - def __init__(self) -> None: - if not import_checker.is_msgspec_installed: - raise ImportError(MISSING_DEPENDENCY_MESSAGE) - - def decode(self, content: bytes, model: type[T]) -> T: - """Validate `content` as JSON against `model` in a single parse pass.""" - return msgspec.json.decode(content, type=model) -``` - -No `__all__`. - -If `ty check` rejects the `msgspec.json.decode(...)` line because `msgspec` is imported inside a runtime `if` block, add `# ty: ignore[unresolved-reference]` to the `return` line with a brief comment pointing at the `import_checker` guard. Verify via Step 6 first. - -- [ ] **Step 4: Run the protocol test to verify it passes** - -Run: `uv run pytest tests/test_decoders_msgspec.py::test_decoder_satisfies_response_decoder_protocol -v` -Expected: PASS. - -- [ ] **Step 5: Add the remaining 7 tests** - -Append to `tests/test_decoders_msgspec.py`: - -```python -def test_decode_into_msgspec_struct() -> None: - result = MsgspecDecoder().decode(b'{"name":"x","qty":1}', _Item) - assert result == _Item(name="x", qty=1) - - -def test_decode_into_pydantic_model() -> None: - result = MsgspecDecoder().decode(b'{"name":"y","qty":2}', _ItemModel) - assert result == _ItemModel(name="y", qty=2) - - -def test_decode_into_builtin_type() -> None: - result = MsgspecDecoder().decode(b"42", int) - assert result == 42 # noqa: PLR2004 - - -def test_decode_into_list_of_struct() -> None: - result = MsgspecDecoder().decode(b'[{"name":"a","qty":1}]', list[_Item]) - assert result == [_Item(name="a", qty=1)] - - -def test_decode_validation_error_propagates() -> None: - with pytest.raises(msgspec.ValidationError): - MsgspecDecoder().decode(b'{"name":"x","qty":"not-an-int"}', _Item) - - -def test_decode_json_parse_error_propagates() -> None: - with pytest.raises(msgspec.DecodeError): - MsgspecDecoder().decode(b"{", _Item) - - -def test_construction_raises_without_extra_via_monkeypatch( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(import_checker, "is_msgspec_installed", False) - with pytest.raises(ImportError, match="MsgspecDecoder requires the 'msgspec' extra"): - MsgspecDecoder() -``` - -- [ ] **Step 6: Run all 8 tests to verify they pass** - -Run: `uv run pytest tests/test_decoders_msgspec.py -v` -Expected: 8 passed. - -- [ ] **Step 7: Lint and ty** - -Run: `uv run ruff check src/httpware/decoders/msgspec.py tests/test_decoders_msgspec.py` -Run: `uv run ty check src/httpware/decoders/msgspec.py` -Expected: both clean. - -If ruff flags `PLR2004` (magic number) on the `42` literal beyond what `# noqa: PLR2004` already covers, add suppressions per the existing test pattern. - -- [ ] **Step 8: Commit** - -```bash -git add src/httpware/decoders/msgspec.py tests/test_decoders_msgspec.py -git commit -m "$(cat <<'EOF' -feat(story-1.6): MsgspecDecoder adapter behind [msgspec] extra - -Adds src/httpware/decoders/msgspec.py with: -- MISSING_DEPENDENCY_MESSAGE constant (module-level, not class attribute) -- MsgspecDecoder class: __init__ raises ImportError with install hint - if msgspec isn't installed; decode() calls msgspec.json.decode( - content, type=model) in a single C-level parse pass. - -The msgspec import is gated by import_checker.is_msgspec_installed, -so the module imports cleanly without the extra — only construction -fails. No __all__ (project convention). No caching (msgspec.json.decode -is a free function with no per-model adapter overhead). - -Eight tests cover: protocol satisfaction, decode-into-Struct, -decode-into-pydantic-model, decode-into-builtin, decode-into-list, -ValidationError propagation, DecodeError propagation, and the -construction-failure path via monkeypatch. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: Subprocess-based import-isolation test - -Add the subprocess test that verifies `import httpware` does NOT transitively load `msgspec`. This is the test for AC4 from the archived spec ("importing httpware without msgspec installed does not import msgspec"). Because `msgspec` IS installed in the test environment, the check must run in a fresh subprocess to see a clean `sys.modules`. - -**Files:** -- Create: `tests/test_optional_extras_isolation.py` - -- [ ] **Step 1: Create the test file** - -Create `tests/test_optional_extras_isolation.py`: - -```python -"""Verify that `import httpware` does not transitively load opt-in extras.""" - -import subprocess -import sys - - -def test_importing_httpware_does_not_import_msgspec() -> None: - """Fresh subprocess: msgspec must NOT appear in sys.modules after `import httpware`. - - msgspec IS installed in the test environment (via `--all-extras`), so this - test runs in a subprocess with a clean interpreter to verify that nothing - in the httpware import chain pulls msgspec in. - """ - result = subprocess.run( # noqa: S603 - [ - sys.executable, - "-c", - "import httpware; import sys; " - "sys.exit(0 if 'msgspec' not in sys.modules else 1)", - ], - check=False, - capture_output=True, - ) - assert result.returncode == 0, ( - "msgspec was loaded transitively by `import httpware`; " - f"stdout={result.stdout!r} stderr={result.stderr!r}" - ) -``` - -The `# noqa: S603` suppresses ruff's "subprocess without shell=False explicit" warning — the call uses a list of args, not shell=True, so it's safe; the rule is overly cautious for this case. If S603 isn't flagged, drop the noqa. - -- [ ] **Step 2: Run the test** - -Run: `uv run pytest tests/test_optional_extras_isolation.py -v` -Expected: 1 passed. - -If it fails, the subprocess output identifies what's pulling msgspec in. Likely culprit: a re-export from `src/httpware/__init__.py`. The spec explicitly forbids this. - -- [ ] **Step 3: Lint** - -Run: `uv run ruff check tests/test_optional_extras_isolation.py` -Expected: clean. - -- [ ] **Step 4: Commit** - -```bash -git add tests/test_optional_extras_isolation.py -git commit -m "$(cat <<'EOF' -test(story-1.6): subprocess-based import-isolation guard for opt-in extras - -Verifies that `import httpware` does not transitively load msgspec. msgspec -is installed in the test env (via --all-extras), so the check runs in a -fresh subprocess with a clean sys.modules. Future stories (5-4 otel) extend -this file with their own subprocess tests for each opt-in extra. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: CHANGELOG bullet - -**Files:** -- Modify: `CHANGELOG.md` - -- [ ] **Step 1: Append the bullet** - -Edit `CHANGELOG.md`. The `## [Unreleased]` / `### Added` section currently ends with the Story 2.3 bullet. Append a new bullet immediately after Story 2.3 (still before the `[Unreleased]: ...` reference link line): - -```markdown -- `MsgspecDecoder` opt-in `ResponseDecoder` adapter behind the `[msgspec]` extra; `msgspec.json.decode(content, type=model)` in a single C-level parse pass. Accepts `msgspec.Struct`, pydantic `BaseModel`, and builtin types as `model`. `msgspec.ValidationError` and `msgspec.DecodeError` propagate unchanged. Module import is safe without the extra (gated by `httpware._internal.import_checker.is_msgspec_installed`); only `MsgspecDecoder()` construction raises `ImportError` with an install hint when the extra is missing. `import httpware` does NOT eagerly load `msgspec` — `MsgspecDecoder` is reachable only via `from httpware.decoders.msgspec import MsgspecDecoder` (Story 1.6). -``` - -- [ ] **Step 2: Commit** - -```bash -git add CHANGELOG.md -git commit -m "$(cat <<'EOF' -docs(story-1.6): CHANGELOG entry for MsgspecDecoder via extras - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 5: Verify, push, PR, merge - -End-to-end sanity check, push, open PR, wait for CI, merge. - -- [ ] **Step 1: Run the full test suite with coverage** - -Run: `just test` -Expected: 207 passed (198 baseline post-2.3 + 8 decoder tests + 1 isolation test), 1 deselected (perf), 100% line coverage including the new `import_checker.py` and `decoders/msgspec.py`. - -If coverage is below 100% on the new modules, identify the uncovered branch. The construction-failure path is exercised by the monkeypatch test; the happy path by the decode tests. - -- [ ] **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: only the untracked plan file `planning/plans/2026-05-31-msgspec-decoder-via-extras-plan.md`. - -- [ ] **Step 4: Review the branch diff** - -Run: `git log --oneline main..HEAD` -Expected: five or six commits — spec (`docs(story-1.6): design...`), Task 1, Task 2, Task 3, Task 4. - -Run: `git diff --stat main..HEAD` -Expected: changes to `CHANGELOG.md`, plus the four new files: `planning/specs/2026-05-31-msgspec-decoder-via-extras-design.md`, `src/httpware/_internal/import_checker.py`, `src/httpware/decoders/msgspec.py`, `tests/test_decoders_msgspec.py`, `tests/test_optional_extras_isolation.py`. No other source files touched. - -- [ ] **Step 5: Stage and commit the plan file** - -```bash -git add planning/plans/2026-05-31-msgspec-decoder-via-extras-plan.md -git commit -m "docs(story-1.6): implementation plan for MsgspecDecoder via extras - -Co-Authored-By: Claude Opus 4.7 (1M context) " -``` - -- [ ] **Step 6: Push the branch** - -Run: `git push -u origin story/1-6-msgspec-decoder-via-extras` -Expected: push succeeds; GitHub prints a "Create a pull request for ..." URL. - -- [ ] **Step 7: Open the PR** - -```bash -gh pr create --title "feat(story-1.6): MsgspecDecoder via the [msgspec] extra" --body "$(cat <<'EOF' -## Summary - -- Adds `src/httpware/decoders/msgspec.py` with `MsgspecDecoder`, the second `ResponseDecoder` adapter. Backed by `msgspec.json.decode(content, type=model)` — single C-level parse pass, no adapter caching needed (msgspec doesn't have pydantic's `TypeAdapter` overhead). -- Adds `src/httpware/_internal/import_checker.py` with `is_msgspec_installed` (`find_spec`-based detection — does NOT import msgspec). Pattern adapted from `modern-python/lite-bootstrap`. Future opt-in extras (otel in Story 5-4, etc.) extend this module. -- Module import is safe without the extra — only `MsgspecDecoder()` construction raises `ImportError` with the install hint. Enables capability-probe code. -- No package-root re-export. Honors seam #5 ("never import an extra at package top-level"). Consumers use `from httpware.decoders.msgspec import MsgspecDecoder`. -- 8 behavioral tests + 1 subprocess-based import-isolation test (in new `tests/test_optional_extras_isolation.py`, which future opt-in extras will extend). -- 207 passing total; 100% coverage on the new modules. - -Out of scope (subsequent stories): `AsyncClient` wiring (Story 1-7), `RecordedTransport` (Story 1-8), follow-up cleanup of legacy `__all__` exports in existing submodules. - -Spec + plan: `planning/specs/2026-05-31-msgspec-decoder-via-extras-design.md`, `planning/plans/2026-05-31-msgspec-decoder-via-extras-plan.md`. - -## Test plan - -- [x] `just test` — 207 passed, 1 deselected, 100% line coverage including the new modules. -- [x] `just lint-ci` clean. -- [x] `tests/test_no_httpx2_leakage.py` still passes. -- [x] `tests/test_optional_extras_isolation.py::test_importing_httpware_does_not_import_msgspec` passes — subprocess verifies `import httpware` does not load `msgspec`. -- [ ] 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 (`lint`, `pytest (3.11)`, `pytest (3.12)`, `pytest (3.13)`, `pytest (3.14)`) green. - -If `pytest (3.14)` fails on `codecov/codecov-action@v4.0.1` with EPIPE (transient pattern seen on this repo), re-run with `gh run rerun --failed`. - -- [ ] **Step 9: Merge** - -Once CI is green: - -Run: `gh pr merge --merge --delete-branch` -Run: `git checkout main && git pull --ff-only && git log --oneline -3` - -Story 1-6 is complete. Story 1-7 (`AsyncClient` with HTTP methods, `response_model`, `with_options`, lifecycle) is the next normal-flow item in Epic 1. - ---- - -## Definition of done - -- `src/httpware/_internal/import_checker.py` exists with `is_msgspec_installed`. -- `src/httpware/decoders/msgspec.py` exists with `MISSING_DEPENDENCY_MESSAGE` constant and `MsgspecDecoder` class. No `__all__`. -- `tests/test_decoders_msgspec.py` contains 8 passing tests. -- `tests/test_optional_extras_isolation.py` contains the subprocess-based import-isolation test; passes. -- `CHANGELOG.md` has a Story 1.6 bullet under `[Unreleased]` / `### Added`. -- `just test` shows 207 passed, 1 deselected, 100% line coverage. -- `just lint-ci` clean. -- `tests/test_no_httpx2_leakage.py` still passes. -- Story 1-6 lands as a single PR off `main` via the branch `story/1-6-msgspec-decoder-via-extras`. diff --git a/planning/changes/2026-05-31.07-asyncclient/design.md b/planning/changes/2026-05-31.07-asyncclient.md similarity index 100% rename from planning/changes/2026-05-31.07-asyncclient/design.md rename to planning/changes/2026-05-31.07-asyncclient.md diff --git a/planning/changes/2026-05-31.07-asyncclient/plan.md b/planning/changes/2026-05-31.07-asyncclient/plan.md deleted file mode 100644 index c10d715..0000000 --- a/planning/changes/2026-05-31.07-asyncclient/plan.md +++ /dev/null @@ -1,1894 +0,0 @@ -# AsyncClient 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 1-7: a `AsyncClient` class at `src/httpware/client.py` with 8 HTTP method shortcuts, typed `response_model` overloads, lifecycle management, and `with_options` returning lifecycle-view clients. - -**Architecture:** Single new module `src/httpware/client.py` (~350 lines, heavy with type signatures). Extends the existing `ClientConfig` with `decoder` and `middleware` fields. Composes the middleware chain at construction via `compose()` (Story 2-1) and stores the resulting `Next` callable. HTTP methods are 2-line shims that call a shared `_send` helper. Lifecycle uses a private `_owns_transport` flag to distinguish the original client (closes the transport on `__aexit__`) from `with_options` views (no-op on close). - -**Tech Stack:** Python 3.11 floor. Existing deps only (`pydantic`, `httpx2` via transport, stdlib `json`). `typing.overload` for response_model typing. - -**Branch:** `story/1-7-asyncclient` (already created; spec commit `bebb1dd` is on it). - -**Spec:** `planning/specs/2026-05-31-asyncclient-design.md`. - ---- - -## File Structure - -**New files:** -- `src/httpware/client.py` — `AsyncClient` class with construction, HTTP methods, lifecycle, `with_options`. -- `tests/test_client_construction.py` — defaults, `from_url`, timeout normalization, param validation. -- `tests/test_client_methods.py` — 8 HTTP methods build correct Requests; default merging; URL resolution; body params. -- `tests/test_client_response_model.py` — decoder invocation by `response_model`. -- `tests/test_client_typing.py` — `ty`-checked file verifying overload return types. -- `tests/test_client_lifecycle.py` — `__aenter__`/`__aexit__`, view no-op, double-close. -- `tests/test_client_middleware_wiring.py` — middleware execution + re-composition via `with_options`. - -**Modified files:** -- `src/httpware/config.py` — extend `ClientConfig` with `decoder` and `middleware` fields. -- `src/httpware/__init__.py` — export `AsyncClient`, add to `__all__`. -- `CHANGELOG.md` — Story 1.7 bullet. - -**Files NOT touched:** `request.py`, `response.py`, `errors.py`, `decoders/*`, `middleware/*`, `_internal/*`, `transports/*`. - ---- - -## Task 1: Extend `ClientConfig` with `decoder` and `middleware` fields - -Backwards-compatible addition. Existing `tests/test_config.py` keeps passing because both new fields have defaults. - -**Files:** -- Modify: `src/httpware/config.py` -- Modify: `tests/test_config.py` (append two assertions to the existing defaults test) - -- [ ] **Step 1: Add the failing assertions** - -Edit `tests/test_config.py`. Find `test_client_config_defaults` and append two assertions: - -```python -def test_client_config_defaults() -> None: - cfg = ClientConfig() - assert cfg.base_url is None - assert cfg.default_headers == {} - assert cfg.default_query == {} - assert cfg.timeout == Timeout() - assert cfg.limits == Limits() - # NEW for Story 1.7: - from httpware.decoders.pydantic import PydanticDecoder # noqa: PLC0415 — local to keep import ordering tidy - assert isinstance(cfg.decoder, PydanticDecoder) - assert cfg.middleware == () -``` - -(Note: the `# noqa: PLC0415` here is a temporary stand-in if ruff flags the in-function import; if not flagged, drop the noqa. The preferred fix is to add `from httpware.decoders.pydantic import PydanticDecoder` to the top-level imports — do that instead and remove the in-function import.) - -Re-run: `uv run pytest tests/test_config.py::test_client_config_defaults -v` -Expected: `AttributeError: 'ClientConfig' object has no attribute 'decoder'`. - -- [ ] **Step 2: Extend `ClientConfig`** - -Edit `src/httpware/config.py`. Current state: - -```python -"""Immutable configuration value types: Limits, Timeout, ClientConfig.""" - -from collections.abc import Mapping -from dataclasses import dataclass, field - - -@dataclass(frozen=True, slots=True) -class Timeout: - """Per-phase request timeout configuration (seconds).""" - - connect: float = 5.0 - read: float = 30.0 - write: float = 30.0 - pool: float = 5.0 - - -@dataclass(frozen=True, slots=True) -class Limits: - """Connection-pool limits.""" - - max_connections: int = 100 - max_keepalive_connections: int = 20 - keepalive_expiry: float = 5.0 - - -@dataclass(frozen=True, slots=True) -class ClientConfig: - """Immutable client configuration bag.""" - - base_url: str | None = None - default_headers: Mapping[str, str] = field(default_factory=dict) - default_query: Mapping[str, str] = field(default_factory=dict) - timeout: Timeout = field(default_factory=Timeout) - limits: Limits = field(default_factory=Limits) -``` - -Add two imports and two fields. Final file: - -```python -"""Immutable configuration value types: Limits, Timeout, ClientConfig.""" - -from collections.abc import Mapping -from dataclasses import dataclass, field - -from httpware.decoders import ResponseDecoder -from httpware.decoders.pydantic import PydanticDecoder -from httpware.middleware import Middleware - - -@dataclass(frozen=True, slots=True) -class Timeout: - """Per-phase request timeout configuration (seconds).""" - - connect: float = 5.0 - read: float = 30.0 - write: float = 30.0 - pool: float = 5.0 - - -@dataclass(frozen=True, slots=True) -class Limits: - """Connection-pool limits.""" - - max_connections: int = 100 - max_keepalive_connections: int = 20 - keepalive_expiry: float = 5.0 - - -@dataclass(frozen=True, slots=True) -class ClientConfig: - """Immutable client configuration bag.""" - - base_url: str | None = None - default_headers: Mapping[str, str] = field(default_factory=dict) - default_query: Mapping[str, str] = field(default_factory=dict) - timeout: Timeout = field(default_factory=Timeout) - limits: Limits = field(default_factory=Limits) - decoder: ResponseDecoder = field(default_factory=PydanticDecoder) - middleware: tuple[Middleware, ...] = () -``` - -Now update the test file to move the `PydanticDecoder` import to the top: - -```python -"""Unit tests for httpware.config types.""" - -from dataclasses import FrozenInstanceError - -import pytest - -from httpware import ClientConfig, Limits, Timeout -from httpware.decoders.pydantic import PydanticDecoder -``` - -And drop the in-function import + noqa from `test_client_config_defaults`: - -```python -def test_client_config_defaults() -> None: - cfg = ClientConfig() - assert cfg.base_url is None - assert cfg.default_headers == {} - assert cfg.default_query == {} - assert cfg.timeout == Timeout() - assert cfg.limits == Limits() - assert isinstance(cfg.decoder, PydanticDecoder) - assert cfg.middleware == () -``` - -- [ ] **Step 3: Run all config tests to verify they pass** - -Run: `uv run pytest tests/test_config.py -v` -Expected: all pass (existing 6 tests + the extended defaults test). - -- [ ] **Step 4: Lint and ty** - -Run: `uv run ruff check src/httpware/config.py tests/test_config.py` -Run: `uv run ty check src/httpware/config.py` -Expected: both clean. - -- [ ] **Step 5: Commit** - -```bash -git add src/httpware/config.py tests/test_config.py -git commit -m "$(cat <<'EOF' -feat(story-1.7): extend ClientConfig with decoder and middleware fields - -Adds two new fields to ClientConfig: -- decoder: ResponseDecoder (default: PydanticDecoder()) -- middleware: tuple[Middleware, ...] (default: ()) - -Both fields have defaults so existing construction paths are unchanged. -The PydanticDecoder default factory introduces a constructor-time -dependency from config.py on decoders/pydantic.py — acceptable since -pydantic is a hard dep. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: `AsyncClient` construction and defaults - -Build the smallest `AsyncClient` that satisfies construction defaults and `from_url`. No HTTP methods yet — those land in Task 3. - -**Files:** -- Create: `src/httpware/client.py` -- Create: `tests/test_client_construction.py` - -- [ ] **Step 1: Add the failing tests** - -Create `tests/test_client_construction.py`: - -```python -"""Unit tests for httpware.client.AsyncClient construction.""" - -import pytest - -from httpware import AsyncClient, Limits, Timeout -from httpware.decoders.pydantic import PydanticDecoder -from httpware.middleware import Middleware -from httpware.request import Request -from httpware.response import Response -from httpware.transports.httpx2 import Httpx2Transport - - -class _FakeTransport: - """Minimal Transport for construction tests; never actually called.""" - - async def __call__(self, request: Request) -> Response: # pragma: no cover - not used - raise NotImplementedError - - def stream( # pragma: no cover - not used - self, request: Request - ): - raise NotImplementedError - - async def aclose(self) -> None: # pragma: no cover - not used - return None - - -def test_init_defaults_provide_transport_and_decoder() -> None: - client = AsyncClient() - assert isinstance(client._transport, Httpx2Transport) - assert isinstance(client._config.decoder, PydanticDecoder) - assert client._config.middleware == () - - -def test_init_accepts_explicit_transport() -> None: - transport = _FakeTransport() - client = AsyncClient(transport=transport) - assert client._transport is transport - - -def test_init_accepts_explicit_decoder() -> None: - decoder = PydanticDecoder() - client = AsyncClient(decoder=decoder) - assert client._config.decoder is decoder - - -def test_init_accepts_middleware_sequence() -> None: - class _M: - async def __call__(self, request: Request, next): # noqa: A002 - return await next(request) - - middleware: list[Middleware] = [_M()] - client = AsyncClient(middleware=middleware) - assert client._config.middleware == tuple(middleware) - - -def test_init_normalizes_float_timeout() -> None: - client = AsyncClient(timeout=2.5) - assert client._config.timeout == Timeout(connect=2.5, read=2.5, write=2.5, pool=2.5) - - -def test_init_keeps_timeout_instance() -> None: - t = Timeout(connect=1.0, read=60.0, write=10.0, pool=2.0) - client = AsyncClient(timeout=t) - assert client._config.timeout is t - - -def test_init_normalizes_none_timeout() -> None: - client = AsyncClient(timeout=None) - assert client._config.timeout == Timeout() - - -def test_init_default_limits() -> None: - client = AsyncClient() - assert client._config.limits == Limits() - - -def test_from_url_classmethod_sets_base_url() -> None: - client = AsyncClient.from_url("https://api.example.com/v1") - assert client._config.base_url == "https://api.example.com/v1" - - -def test_init_owns_transport_by_default() -> None: - client = AsyncClient() - assert client._owns_transport is True - - -def test_construction_does_not_create_httpx2_client() -> None: - """Construction is side-effect-free; the httpx2.AsyncClient is lazily created on first request.""" - client = AsyncClient() - # Httpx2Transport stores `_client` lazily; until first call, _client is None. - assert client._transport._client is None # type: ignore[attr-defined] -``` - -The last test reaches into private state (`_client`) on `Httpx2Transport`. Use the `# ty: ignore[unresolved-attribute]` style if `ty` complains; the comment above matches the existing test patterns elsewhere. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_client_construction.py -v` -Expected: `ImportError: cannot import name 'AsyncClient' from 'httpware'`. - -- [ ] **Step 3: Implement the minimal `AsyncClient`** - -Create `src/httpware/client.py`: - -```python -"""AsyncClient — the v0.1.0 public surface of httpware.""" - -import dataclasses -import json as _json -from collections.abc import Mapping, Sequence -from typing import Any, TypeVar, overload - -from httpware._internal.chain import compose -from httpware.config import ClientConfig, Limits, Timeout -from httpware.decoders import ResponseDecoder -from httpware.decoders.pydantic import PydanticDecoder -from httpware.middleware import Middleware, Next -from httpware.request import Request -from httpware.response import Response -from httpware.transports import Transport -from httpware.transports.httpx2 import Httpx2Transport - - -T = TypeVar("T") - -_UNSET: Any = object() - - -def _normalize_timeout(value: Timeout | float | None) -> Timeout: - if value is None: - return Timeout() - if isinstance(value, Timeout): - return value - return Timeout(connect=value, read=value, write=value, pool=value) - - -def _build_body( - json_value: Any | None, content: bytes | None -) -> tuple[bytes | None, str | None]: - if json_value is not None and content is not None: - raise TypeError("pass either `json` or `content`, not both") - if json_value is not None: - return _json.dumps(json_value).encode("utf-8"), "application/json" - return content, None - - -class AsyncClient: - """Async HTTP client with typed response decoding and middleware composition.""" - - _config: ClientConfig - _transport: Transport - _dispatch: Next - _owns_transport: bool - - def __init__( - self, - *, - base_url: str | None = None, - default_headers: Mapping[str, str] | None = None, - default_query: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - limits: Limits | None = None, - transport: Transport | None = None, - decoder: ResponseDecoder | None = None, - middleware: Sequence[Middleware] | None = None, - ) -> None: - normalized_timeout = _normalize_timeout(timeout) - resolved_limits = limits or Limits() - resolved_transport: Transport = transport or Httpx2Transport( - limits=resolved_limits, timeout=normalized_timeout - ) - resolved_decoder = decoder or PydanticDecoder() - resolved_middleware = tuple(middleware) if middleware is not None else () - - self._config = ClientConfig( - base_url=base_url, - default_headers=dict(default_headers or {}), - default_query=dict(default_query or {}), - timeout=normalized_timeout, - limits=resolved_limits, - decoder=resolved_decoder, - middleware=resolved_middleware, - ) - self._transport = resolved_transport - self._dispatch = compose(resolved_middleware, resolved_transport) - self._owns_transport = True - - @classmethod - def from_url(cls, base_url: str, **kwargs: Any) -> "AsyncClient": - """Construct an AsyncClient with a base URL prefix.""" - return cls(base_url=base_url, **kwargs) -``` - -- [ ] **Step 4: Add `AsyncClient` to the package root exports** - -Edit `src/httpware/__init__.py`. Find the existing `from httpware.transports.httpx2 import Httpx2Transport` line (or insert in alphabetic position). Add the import: - -```python -from httpware.client import AsyncClient -``` - -In `__all__`, add `"AsyncClient"` to the list. The list is sorted by `RUF022` (ASCII order); the correct position is at the very start (`"A"` < `"S"` in ASCII, so before `"STATUS_TO_EXCEPTION"`). If unsure, add the entry anywhere and run `uv run ruff check --fix src/httpware/__init__.py` to let ruff sort it. - -- [ ] **Step 5: Run construction tests** - -Run: `uv run pytest tests/test_client_construction.py -v` -Expected: 11 passed. - -- [ ] **Step 6: Lint and ty** - -Run: `uv run ruff check src/httpware/client.py src/httpware/__init__.py tests/test_client_construction.py` -Run: `uv run ty check src/httpware/client.py src/httpware/__init__.py` -Expected: both clean. - -- [ ] **Step 7: Commit** - -```bash -git add src/httpware/client.py src/httpware/__init__.py tests/test_client_construction.py -git commit -m "$(cat <<'EOF' -feat(story-1.7): AsyncClient construction + from_url + defaults - -Adds src/httpware/client.py with the AsyncClient skeleton: -- keyword-only __init__ resolving defaults for transport (Httpx2Transport), - decoder (PydanticDecoder), middleware (()), timeout (Timeout()), and - limits (Limits()) -- _normalize_timeout helper for float→Timeout coercion -- _build_body helper for the upcoming HTTP method shortcuts -- _UNSET sentinel for the upcoming with_options method -- from_url classmethod factory -- middleware chain composed via compose() at construction; result stored - in self._dispatch -- _owns_transport flag set to True (views from with_options will set False) - -No HTTP methods yet (Task 3). Construction is side-effect-free — -Httpx2Transport's lazy init means no httpx2.AsyncClient() is created -until the first request. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: HTTP method shortcuts + URL resolution + request building - -The biggest task. Implement `_resolve_url`, `_build_request`, `_send`, and all 8 HTTP methods with their `@overload` declarations. Test via a `_RecordingTransport` that captures the produced `Request`. - -**Files:** -- Modify: `src/httpware/client.py` (append helpers + 8 methods) -- Create: `tests/test_client_methods.py` - -- [ ] **Step 1: Add the first failing test (GET happy path)** - -Create `tests/test_client_methods.py`: - -```python -"""Unit tests for AsyncClient HTTP method shortcuts.""" - -from contextlib import AbstractAsyncContextManager -from typing import Any - -import pytest - -from httpware import AsyncClient -from httpware.request import Request -from httpware.response import Response, StreamResponse - - -class _RecordingTransport: - """Captures the last-seen Request and returns a canned Response.""" - - def __init__(self) -> None: - self.last_request: Request | None = None - self.canned = Response( - status=200, - headers={"x-from": "transport"}, - content=b"body", - url="https://example.test/", - elapsed=0.0, - ) - - async def __call__(self, request: Request) -> Response: - self.last_request = request - return self.canned - - 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 - - -async def test_get_builds_request_with_method_and_url() -> None: - transport = _RecordingTransport() - client = AsyncClient(transport=transport) - - await client.get("https://api.example.com/users") - - assert transport.last_request is not None - assert transport.last_request.method == "GET" - assert transport.last_request.url == "https://api.example.com/users" - assert transport.last_request.body is None -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/test_client_methods.py::test_get_builds_request_with_method_and_url -v` -Expected: `AttributeError: 'AsyncClient' object has no attribute 'get'`. - -- [ ] **Step 3: Implement `_resolve_url`, `_build_request`, `_send`, and `get`** - -Append to `src/httpware/client.py` (inside the `AsyncClient` class): - -```python - def _resolve_url(self, path: str) -> str: - if path.startswith(("http://", "https://")): - return path - base = self._config.base_url - if base is None: - return path - return f"{base.rstrip('/')}/{path.lstrip('/')}" - - def _build_request( - self, - method: str, - path: str, - *, - headers: Mapping[str, str] | None, - params: Mapping[str, str] | None, - cookies: Mapping[str, str] | None, - timeout: Timeout | float | None, - body: bytes | None, - content_type: str | None, - ) -> Request: - merged_headers: dict[str, str] = {**self._config.default_headers, **(headers or {})} - if content_type is not None and "content-type" not in {k.lower() for k in merged_headers}: - merged_headers["content-type"] = content_type - merged_params: dict[str, str] = {**self._config.default_query, **(params or {})} - extensions: dict[str, Any] = {} - if timeout is not None: - extensions["timeout"] = _normalize_timeout(timeout) - return Request( - method=method, - url=self._resolve_url(path), - headers=merged_headers, - params=merged_params, - cookies=dict(cookies or {}), - body=body, - extensions=extensions, - ) - - async def _send( - self, - method: str, - path: str, - *, - headers: Mapping[str, str] | None, - params: Mapping[str, str] | None, - cookies: Mapping[str, str] | None, - timeout: Timeout | float | None, - body: bytes | None, - content_type: str | None, - response_model: type[T] | None, - ) -> Response | T: - request = self._build_request( - method, - path, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - body=body, - content_type=content_type, - ) - response = await self._dispatch(request) - if response_model is None: - return response - return self._config.decoder.decode(response.content, response_model) - - @overload - async def get( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - response_model: None = None, - ) -> Response: ... - - @overload - async def get( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - response_model: type[T], - ) -> T: ... - - async def get( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - response_model: type[T] | None = None, - ) -> Response | T: - return await self._send( - "GET", - path, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - body=None, - content_type=None, - response_model=response_model, - ) -``` - -- [ ] **Step 4: Run the GET test to verify it passes** - -Run: `uv run pytest tests/test_client_methods.py::test_get_builds_request_with_method_and_url -v` -Expected: PASS. - -- [ ] **Step 5: Add tests for URL resolution and default merging** - -Append to `tests/test_client_methods.py`: - -```python -async def test_relative_path_joins_with_base_url() -> None: - transport = _RecordingTransport() - client = AsyncClient(base_url="https://api.example.com/v1", transport=transport) - await client.get("/users") - assert transport.last_request is not None - assert transport.last_request.url == "https://api.example.com/v1/users" - - -async def test_relative_path_without_leading_slash_joins_same_way() -> None: - transport = _RecordingTransport() - client = AsyncClient(base_url="https://api.example.com/v1", transport=transport) - await client.get("users") - assert transport.last_request is not None - assert transport.last_request.url == "https://api.example.com/v1/users" - - -async def test_absolute_url_bypasses_base_url() -> None: - transport = _RecordingTransport() - client = AsyncClient(base_url="https://api.example.com/v1", transport=transport) - await client.get("https://other.com/foo") - assert transport.last_request is not None - assert transport.last_request.url == "https://other.com/foo" - - -async def test_default_headers_merged_with_per_call_headers() -> None: - transport = _RecordingTransport() - client = AsyncClient( - default_headers={"x-keep": "1", "x-override": "default"}, - transport=transport, - ) - await client.get("/", headers={"x-override": "per-call", "x-add": "2"}) - assert transport.last_request is not None - assert transport.last_request.headers == { - "x-keep": "1", - "x-override": "per-call", - "x-add": "2", - } - - -async def test_default_query_merged_with_per_call_params() -> None: - transport = _RecordingTransport() - client = AsyncClient(default_query={"k": "default"}, transport=transport) - await client.get("/", params={"k": "per-call", "extra": "1"}) - assert transport.last_request is not None - assert transport.last_request.params == {"k": "per-call", "extra": "1"} -``` - -Run: `uv run pytest tests/test_client_methods.py -v` -Expected: all 6 tests pass. - -- [ ] **Step 6: Add tests for `post` body params** - -Append: - -```python -async def test_post_with_json_serializes_and_sets_content_type() -> None: - transport = _RecordingTransport() - client = AsyncClient(transport=transport) - await client.post("/users", json={"name": "alice"}) - assert transport.last_request is not None - assert transport.last_request.method == "POST" - assert transport.last_request.body == b'{"name": "alice"}' - assert transport.last_request.headers["content-type"] == "application/json" - - -async def test_post_with_content_preserves_bytes_unchanged() -> None: - transport = _RecordingTransport() - client = AsyncClient(transport=transport) - await client.post("/users", content=b"raw bytes") - assert transport.last_request is not None - assert transport.last_request.body == b"raw bytes" - assert "content-type" not in transport.last_request.headers - - -async def test_post_json_and_content_raises_typeerror() -> None: - transport = _RecordingTransport() - client = AsyncClient(transport=transport) - with pytest.raises(TypeError, match="`json` or `content`"): - await client.post("/users", json={"a": 1}, content=b"raw") - - -async def test_post_per_call_content_type_skips_auto_injection() -> None: - transport = _RecordingTransport() - client = AsyncClient(transport=transport) - await client.post( - "/users", - json={"a": 1}, - headers={"Content-Type": "application/vnd.custom+json"}, - ) - assert transport.last_request is not None - # The user-supplied Content-Type wins; the auto-injection is skipped because the case-insensitive - # check finds an existing entry. - assert transport.last_request.headers["Content-Type"] == "application/vnd.custom+json" -``` - -Run: `uv run pytest tests/test_client_methods.py -v` -Expected: still some fail — `post` not implemented yet. - -- [ ] **Step 7: Implement `post`** - -Append to `src/httpware/client.py` (inside the `AsyncClient` class): - -```python - @overload - async def post( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: Any | None = None, - content: bytes | None = None, - response_model: None = None, - ) -> Response: ... - - @overload - async def post( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: Any | None = None, - content: bytes | None = None, - response_model: type[T], - ) -> T: ... - - async def post( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: Any | None = None, - content: bytes | None = None, - response_model: type[T] | None = None, - ) -> Response | T: - body, content_type = _build_body(json, content) - return await self._send( - "POST", - path, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - body=body, - content_type=content_type, - response_model=response_model, - ) -``` - -Run: `uv run pytest tests/test_client_methods.py -v` -Expected: 10 passed. - -- [ ] **Step 8: Implement the remaining 6 methods** - -Append to `src/httpware/client.py`: - -```python - @overload - async def put( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: Any | None = None, - content: bytes | None = None, - response_model: None = None, - ) -> Response: ... - - @overload - async def put( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: Any | None = None, - content: bytes | None = None, - response_model: type[T], - ) -> T: ... - - async def put( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: Any | None = None, - content: bytes | None = None, - response_model: type[T] | None = None, - ) -> Response | T: - body, content_type = _build_body(json, content) - return await self._send( - "PUT", - path, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - body=body, - content_type=content_type, - response_model=response_model, - ) - - @overload - async def patch( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: Any | None = None, - content: bytes | None = None, - response_model: None = None, - ) -> Response: ... - - @overload - async def patch( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: Any | None = None, - content: bytes | None = None, - response_model: type[T], - ) -> T: ... - - async def patch( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: Any | None = None, - content: bytes | None = None, - response_model: type[T] | None = None, - ) -> Response | T: - body, content_type = _build_body(json, content) - return await self._send( - "PATCH", - path, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - body=body, - content_type=content_type, - response_model=response_model, - ) - - @overload - async def delete( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - response_model: None = None, - ) -> Response: ... - - @overload - async def delete( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - response_model: type[T], - ) -> T: ... - - async def delete( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - response_model: type[T] | None = None, - ) -> Response | T: - return await self._send( - "DELETE", - path, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - body=None, - content_type=None, - response_model=response_model, - ) - - @overload - async def head( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - response_model: None = None, - ) -> Response: ... - - @overload - async def head( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - response_model: type[T], - ) -> T: ... - - async def head( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - response_model: type[T] | None = None, - ) -> Response | T: - return await self._send( - "HEAD", - path, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - body=None, - content_type=None, - response_model=response_model, - ) - - @overload - async def options( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - response_model: None = None, - ) -> Response: ... - - @overload - async def options( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - response_model: type[T], - ) -> T: ... - - async def options( - self, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - response_model: type[T] | None = None, - ) -> Response | T: - return await self._send( - "OPTIONS", - path, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - body=None, - content_type=None, - response_model=response_model, - ) - - @overload - async def request( - self, - method: str, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: Any | None = None, - content: bytes | None = None, - response_model: None = None, - ) -> Response: ... - - @overload - async def request( - self, - method: str, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: Any | None = None, - content: bytes | None = None, - response_model: type[T], - ) -> T: ... - - async def request( - self, - method: str, - path: str, - *, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - json: Any | None = None, - content: bytes | None = None, - response_model: type[T] | None = None, - ) -> Response | T: - body, content_type = _build_body(json, content) - return await self._send( - method, - path, - headers=headers, - params=params, - cookies=cookies, - timeout=timeout, - body=body, - content_type=content_type, - response_model=response_model, - ) -``` - -- [ ] **Step 9: Add tests for the remaining methods (one per method, verifying the wire method string)** - -Append to `tests/test_client_methods.py`: - -```python -@pytest.mark.parametrize( - ("client_method_name", "expected_wire_method"), - [ - ("get", "GET"), - ("post", "POST"), - ("put", "PUT"), - ("patch", "PATCH"), - ("delete", "DELETE"), - ("head", "HEAD"), - ("options", "OPTIONS"), - ], -) -async def test_each_method_emits_correct_wire_method( - client_method_name: str, expected_wire_method: str -) -> None: - transport = _RecordingTransport() - client = AsyncClient(transport=transport) - method = getattr(client, client_method_name) - await method("/foo") - assert transport.last_request is not None - assert transport.last_request.method == expected_wire_method - - -async def test_request_method_uses_first_positional_method_arg() -> None: - transport = _RecordingTransport() - client = AsyncClient(transport=transport) - await client.request("CUSTOM", "/foo") - assert transport.last_request is not None - assert transport.last_request.method == "CUSTOM" -``` - -Run: `uv run pytest tests/test_client_methods.py -v` -Expected: 18 passed (10 + 7 parametrized + 1 `request`). - -- [ ] **Step 10: Lint and ty** - -Run: `uv run ruff check src/httpware/client.py tests/test_client_methods.py` -Run: `uv run ty check src/httpware/client.py` -Expected: both clean. - -If `ty` flags any overload as ambiguous, the most common fix is to ensure each overload's `response_model` parameter has a distinct annotation (`None` literal vs `type[T]`). The pattern used here matches httpx's own stubs. - -- [ ] **Step 11: Commit** - -```bash -git add src/httpware/client.py tests/test_client_methods.py -git commit -m "$(cat <<'EOF' -feat(story-1.7): HTTP method shortcuts on AsyncClient (8 methods) - -Adds the eight public HTTP method shortcuts plus the helpers they share: -- _resolve_url for httpx-style base_url prefix join -- _build_request for default+per-call header/param merging -- _send for the dispatch + optional decode wiring -- 8 methods × 2 @overload declarations each (None vs type[T] for the - response_model parameter) — total 16 overload stubs -- get/head/options/delete take no body params; post/put/patch add json - and content (mutually exclusive — TypeError if both) -- request takes a leading positional method parameter - -18 tests cover: URL resolution (relative, absolute, no base_url), default -merging (headers, params), body resolution (json→serialized, -content→passthrough, both→TypeError), per-call Content-Type override, -and one parametrized test per method confirming the wire-method string. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: `response_model` decoding - -Verify the decoder is invoked when `response_model` is provided and bypassed when it's `None`. - -**Files:** -- Create: `tests/test_client_response_model.py` - -- [ ] **Step 1: Add the tests** - -Create `tests/test_client_response_model.py`: - -```python -"""Unit tests for AsyncClient response_model integration with ResponseDecoder.""" - -from contextlib import AbstractAsyncContextManager -from typing import Any, TypeVar - -from pydantic import BaseModel - -from httpware import AsyncClient -from httpware.request import Request -from httpware.response import Response, StreamResponse - - -T = TypeVar("T") - - -class _RecordingTransport: - def __init__(self, content: bytes) -> None: - self._content = content - - async def __call__(self, request: Request) -> Response: - return Response( - status=200, - headers={}, - content=self._content, - url=request.url, - elapsed=0.0, - ) - - def stream( # pragma: no cover - self, request: Request - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: # pragma: no cover - return None - - -class _Item(BaseModel): - name: str - qty: int - - -async def test_response_model_none_returns_raw_response() -> None: - transport = _RecordingTransport(content=b'{"name":"x","qty":1}') - client = AsyncClient(transport=transport) - result = await client.get("/foo") - assert isinstance(result, Response) - assert result.content == b'{"name":"x","qty":1}' - - -async def test_response_model_invokes_decoder() -> None: - transport = _RecordingTransport(content=b'{"name":"x","qty":1}') - client = AsyncClient(transport=transport) - result = await client.get("/foo", response_model=_Item) - assert isinstance(result, _Item) - assert result == _Item(name="x", qty=1) - - -async def test_response_model_uses_supplied_decoder() -> None: - transport = _RecordingTransport(content=b'{"name":"x","qty":1}') - seen: list[tuple[bytes, type[Any]]] = [] - - class _SpyDecoder: - def decode(self, content: bytes, model: type[T]) -> T: - seen.append((content, model)) - return model(name="spy", qty=999) # type: ignore[call-arg] - - client = AsyncClient(transport=transport, decoder=_SpyDecoder()) - result = await client.get("/foo", response_model=_Item) - assert seen == [(b'{"name":"x","qty":1}', _Item)] - assert isinstance(result, _Item) - assert result.name == "spy" -``` - -- [ ] **Step 2: Run tests** - -Run: `uv run pytest tests/test_client_response_model.py -v` -Expected: 3 passed. (The plumbing was already implemented in Task 3's `_send` helper; these tests just verify the contract.) - -- [ ] **Step 3: Lint** - -Run: `uv run ruff check tests/test_client_response_model.py` -Expected: clean. - -If ruff flags the `# type: ignore[call-arg]` on `model(name=..., qty=...)`, replace with `# ty: ignore[unknown-argument]` per the project's convention. - -- [ ] **Step 4: Commit** - -```bash -git add tests/test_client_response_model.py -git commit -m "$(cat <<'EOF' -test(story-1.7): response_model decoder invocation contract - -Three tests verify the decoder wiring established in Task 3: -- response_model=None returns the raw Response (no decoder call) -- response_model=Foo invokes the configured decoder and returns Foo -- a user-supplied decoder= overrides the default PydanticDecoder - -No production code changes; the plumbing was implemented as part of the -HTTP method shortcuts. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 5: Typed overload validation via `ty` - -Add a `ty`-checked file that fails compilation if the overload return types are wrong. - -**Files:** -- Create: `tests/test_client_typing.py` - -- [ ] **Step 1: Create the typed test file** - -Create `tests/test_client_typing.py`: - -```python -"""Type-checked verification that AsyncClient.{get,post,...} overloads narrow correctly. - -This file is checked by `ty` as part of `just lint-ci`. If the @overload -declarations are wrong, the typed assignments below fail to type-check. - -The runtime test below just ensures the module imports cleanly so coverage -notices the file. -""" - -from pydantic import BaseModel - -from httpware import AsyncClient, Response - - -class _Item(BaseModel): - name: str - - -async def _check_overload_types(client: AsyncClient) -> None: - # No response_model → Response - resp: Response = await client.get("/foo") - assert resp is not None - - # response_model=type[T] → T - item: _Item = await client.get("/foo", response_model=_Item) - assert item is not None - - # POST: same pattern - resp_post: Response = await client.post("/foo", json={"a": 1}) - item_post: _Item = await client.post("/foo", json={"a": 1}, response_model=_Item) - assert resp_post is not None - assert item_post is not None - - # request(method, path, ...) shape - resp_req: Response = await client.request("PURGE", "/foo") - item_req: _Item = await client.request("PURGE", "/foo", response_model=_Item) - assert resp_req is not None - assert item_req is not None - - -def test_typing_module_imports_cleanly() -> None: - """Runtime stub so coverage notices this file is reachable; ty does the real work.""" - assert AsyncClient is not None -``` - -- [ ] **Step 2: Run the typing module via ty** - -Run: `uv run ty check tests/test_client_typing.py` -Expected: clean. If any assignment fails type-check, the corresponding overload is wrong. - -- [ ] **Step 3: Run the runtime test** - -Run: `uv run pytest tests/test_client_typing.py -v` -Expected: 1 passed. - -- [ ] **Step 4: Lint** - -Run: `uv run ruff check tests/test_client_typing.py` -Expected: clean. - -- [ ] **Step 5: Commit** - -```bash -git add tests/test_client_typing.py -git commit -m "$(cat <<'EOF' -test(story-1.7): ty-validated overload type narrowing for HTTP methods - -Adds tests/test_client_typing.py with typed assignments that exercise -each overload variant on get, post, and request. Wrong @overload -declarations would cause ty to reject the assignments. Runs as part of -`just lint-ci`'s ty check pass. - -Includes a one-line runtime test so coverage sees the file is reachable. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 6: Lifecycle (`__aenter__` and `__aexit__`) - -Add the async context manager support plus tests. - -**Files:** -- Modify: `src/httpware/client.py` (append two methods) -- Create: `tests/test_client_lifecycle.py` - -- [ ] **Step 1: Add the failing tests** - -Create `tests/test_client_lifecycle.py`: - -```python -"""Unit tests for AsyncClient lifecycle (__aenter__, __aexit__).""" - -from contextlib import AbstractAsyncContextManager - -from httpware import AsyncClient -from httpware.request import Request -from httpware.response import Response, StreamResponse - - -class _TrackingTransport: - """Counts aclose() invocations.""" - - def __init__(self) -> None: - self.aclose_calls = 0 - - async def __call__(self, request: Request) -> Response: # pragma: no cover - not used - raise NotImplementedError - - def stream( # pragma: no cover - not used - self, request: Request - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: - self.aclose_calls += 1 - - -async def test_aenter_returns_self() -> None: - transport = _TrackingTransport() - client = AsyncClient(transport=transport) - async with client as entered: - assert entered is client - - -async def test_async_with_calls_aclose_on_exit() -> None: - transport = _TrackingTransport() - client = AsyncClient(transport=transport) - async with client: - pass - assert transport.aclose_calls == 1 - - -async def test_double_close_is_safe() -> None: - transport = _TrackingTransport() - client = AsyncClient(transport=transport) - async with client: - pass - async with client: - pass - assert transport.aclose_calls == 2 # noqa: PLR2004 - - -async def test_view_async_with_does_not_close_transport() -> None: - transport = _TrackingTransport() - client = AsyncClient(transport=transport) - view = client.with_options(timeout=10) - async with view: - pass - assert transport.aclose_calls == 0 -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_client_lifecycle.py -v` -Expected: `AttributeError: 'AsyncClient' object has no attribute '__aenter__'` (or `with_options` for the last one). - -- [ ] **Step 3: Implement `__aenter__` and `__aexit__`** - -Append to `src/httpware/client.py` (inside the `AsyncClient` class): - -```python - async def __aenter__(self) -> "AsyncClient": - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: object | None, - ) -> None: - if self._owns_transport: - await self._transport.aclose() -``` - -- [ ] **Step 4: Run the first three tests (the `with_options` test stays failing)** - -Run: `uv run pytest tests/test_client_lifecycle.py -k "not view" -v` -Expected: 3 passed (`test_aenter_returns_self`, `test_async_with_calls_aclose_on_exit`, `test_double_close_is_safe`). - -The `view` test stays red until Task 7. - -- [ ] **Step 5: Lint and ty** - -Run: `uv run ruff check src/httpware/client.py tests/test_client_lifecycle.py` -Run: `uv run ty check src/httpware/client.py` -Expected: both clean. - -- [ ] **Step 6: Commit** - -```bash -git add src/httpware/client.py tests/test_client_lifecycle.py -git commit -m "$(cat <<'EOF' -feat(story-1.7): async context manager lifecycle for AsyncClient - -Adds __aenter__ (returns self) and __aexit__ (calls transport.aclose() -if self._owns_transport). Three tests pass: aenter returns self, the -context manager closes the transport on exit, and double-close is safe -(Httpx2Transport.aclose is idempotent). - -The view test (test_view_async_with_does_not_close_transport) stays -failing until Task 7 implements with_options. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 7: `with_options` and views - -Implement `with_options` and the `_from_view` helper that constructs a view (non-owning) client. - -**Files:** -- Modify: `src/httpware/client.py` (append two methods) -- Create: `tests/test_client_middleware_wiring.py` - -- [ ] **Step 1: Add the failing middleware-wiring tests** - -Create `tests/test_client_middleware_wiring.py`: - -```python -"""Unit tests for AsyncClient middleware wiring through compose() and with_options.""" - -from contextlib import AbstractAsyncContextManager - -from httpware import AsyncClient -from httpware.middleware import Middleware, Next -from httpware.request import Request -from httpware.response import Response, StreamResponse - - -class _RecordingTransport: - def __init__(self) -> None: - self.calls = 0 - - async def __call__(self, request: Request) -> Response: - self.calls += 1 - return Response( - status=200, - headers={}, - content=b"", - url=request.url, - elapsed=0.0, - ) - - def stream( # pragma: no cover - self, request: Request - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: # pragma: no cover - return None - - -def _make_recording_middleware(label: str, log: list[str]) -> Middleware: - class _M: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - log.append(label) - return await next(request) - - return _M() - - -async def test_middleware_runs_per_request() -> None: - transport = _RecordingTransport() - log: list[str] = [] - client = AsyncClient( - transport=transport, - middleware=[_make_recording_middleware("A", log)], - ) - await client.get("/foo") - assert log == ["A"] - assert transport.calls == 1 - - -async def test_with_options_recomposes_middleware() -> None: - transport = _RecordingTransport() - parent_log: list[str] = [] - view_log: list[str] = [] - client = AsyncClient( - transport=transport, - middleware=[_make_recording_middleware("parent", parent_log)], - ) - view = client.with_options( - middleware=[_make_recording_middleware("view", view_log)], - ) - await view.get("/foo") - assert view_log == ["view"] - assert parent_log == [] # parent's middleware does NOT run for view calls - - -async def test_with_options_inherits_middleware_when_unset() -> None: - transport = _RecordingTransport() - log: list[str] = [] - client = AsyncClient( - transport=transport, - middleware=[_make_recording_middleware("inherited", log)], - ) - view = client.with_options(timeout=10) - await view.get("/foo") - assert log == ["inherited"] - - -async def test_view_shares_transport_with_parent() -> None: - transport = _RecordingTransport() - client = AsyncClient(transport=transport) - view = client.with_options(timeout=10) - assert view._transport is client._transport - - -async def test_view_does_not_own_transport() -> None: - client = AsyncClient() - view = client.with_options(timeout=10) - assert view._owns_transport is False -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_client_middleware_wiring.py -v` -Expected: middleware-only test passes (Task 3 already wired `compose()`); `with_options` tests fail with `AttributeError`. - -- [ ] **Step 3: Implement `with_options` and `_from_view`** - -Append to `src/httpware/client.py` (inside the `AsyncClient` class): - -```python - def with_options( - self, - *, - base_url: str | None = _UNSET, - default_headers: Mapping[str, str] | None = _UNSET, - default_query: Mapping[str, str] | None = _UNSET, - timeout: Timeout | float | None = _UNSET, - decoder: ResponseDecoder | None = _UNSET, - middleware: Sequence[Middleware] | None = _UNSET, - ) -> "AsyncClient": - """Return a new AsyncClient sharing the same transport with overridden config. - - The returned client is a "view": it does NOT own the transport lifecycle. - Closing it via `async with` is a no-op. The original client should be the - one inside the outermost `async with` block. - - `limits` and `transport` are NOT overridable here — both bind to the - transport, which is shared. Construct a fresh AsyncClient for those. - """ - changes: dict[str, Any] = {} - if base_url is not _UNSET: - changes["base_url"] = base_url - if default_headers is not _UNSET: - changes["default_headers"] = dict(default_headers or {}) - if default_query is not _UNSET: - changes["default_query"] = dict(default_query or {}) - if timeout is not _UNSET: - changes["timeout"] = _normalize_timeout(timeout) - if decoder is not _UNSET: - changes["decoder"] = decoder or PydanticDecoder() - if middleware is not _UNSET: - changes["middleware"] = tuple(middleware) if middleware is not None else () - - new_config = dataclasses.replace(self._config, **changes) - return AsyncClient._from_view(new_config, self._transport) - - @classmethod - def _from_view(cls, config: ClientConfig, transport: Transport) -> "AsyncClient": - """Construct a view sharing an existing transport. Bypasses __init__.""" - client = cls.__new__(cls) - client._config = config - client._transport = transport - client._dispatch = compose(config.middleware, transport) - client._owns_transport = False - return client -``` - -- [ ] **Step 4: Run all middleware-wiring and lifecycle tests** - -Run: `uv run pytest tests/test_client_middleware_wiring.py tests/test_client_lifecycle.py -v` -Expected: 5 middleware-wiring tests + 4 lifecycle tests = 9 passed. - -- [ ] **Step 5: Lint and ty** - -Run: `uv run ruff check src/httpware/client.py tests/test_client_middleware_wiring.py` -Run: `uv run ty check src/httpware/client.py` -Expected: both clean. - -- [ ] **Step 6: Commit** - -```bash -git add src/httpware/client.py tests/test_client_middleware_wiring.py -git commit -m "$(cat <<'EOF' -feat(story-1.7): with_options + view-client lifecycle for AsyncClient - -Adds with_options(...) returning a new AsyncClient sharing the same -transport with selected config overrides. Uses an _UNSET sentinel so -None is a valid override value distinct from "not specified". - -Adds _from_view classmethod that bypasses __init__ to construct a view -client (sets _owns_transport=False so __aexit__ is a no-op). The view's -middleware chain is re-composed against the shared transport. - -with_options allowlist: base_url, default_headers, default_query, timeout, -decoder, middleware. limits and transport are intentionally NOT overridable -(both bind to the transport, which is shared). - -Five new wiring tests cover: middleware execution per request, view re- -composes with new middleware, view inherits parent middleware when unset, -view shares the transport reference, view _owns_transport is False. The -previously-failing lifecycle test (view __aexit__ no-op) now passes. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 8: CHANGELOG bullet - -**Files:** -- Modify: `CHANGELOG.md` - -- [ ] **Step 1: Append the bullet** - -Edit `CHANGELOG.md`. The `## [Unreleased]` / `### Added` section currently ends with the Story 1.6 bullet. Append a new bullet immediately after it (still before the `[Unreleased]: ...` reference link at the bottom): - -```markdown -- `AsyncClient` — the v0.1.0 public surface. Construct with keyword-only `base_url`, `default_headers`, `default_query`, `timeout` (accepts `Timeout` instance, float seconds, or `None`), `limits`, `transport` (defaults to `Httpx2Transport`), `decoder` (defaults to `PydanticDecoder`), and `middleware` (`Sequence[Middleware]`, composed via `httpware._internal.chain.compose` at construction). Eight HTTP method shortcuts (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `request`) with `@overload`-based `response_model` typing — passing `response_model=type[T]` returns `T`, otherwise `Response`. Per-call overrides for `headers`, `params`, `cookies`, `timeout`; body params `json` (auto-encoded with `Content-Type: application/json`) and `content` (raw bytes; mutually exclusive). `base_url` joins with the path using an httpx-style prefix; absolute URLs (`http(s)://`) bypass. `from_url(base_url, **kwargs)` classmethod factory. Async context-manager lifecycle: the original client owns the transport and closes it on `__aexit__`; views returned by `with_options(**overrides)` share the transport and are no-ops on close. `with_options` accepts a keyword allowlist (`base_url`, `default_headers`, `default_query`, `timeout`, `decoder`, `middleware`); `limits` and `transport` are not overridable. Out of scope and deferred: `auth=` (Story 2.4), `data=`/`files=` body params, transport reference-counting, streaming (Epic 4), observability (Epic 5) (Story 1.7). -``` - -- [ ] **Step 2: Commit** - -```bash -git add CHANGELOG.md -git commit -m "$(cat <<'EOF' -docs(story-1.7): CHANGELOG entry for AsyncClient - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 9: Verify, push, PR, merge - -End-to-end sanity check, push, open PR, wait for CI, merge. - -- [ ] **Step 1: Run the full test suite with coverage** - -Run: `just test` -Expected: ~246 passed (208 baseline post-1.6 + ~38 new), 1 deselected (perf), 100% line coverage including `src/httpware/client.py` and the extended `ClientConfig`. - -If coverage is below 100% on `client.py`, identify the uncovered branch. Common culprits: an overload body (which should not be executed — `@overload` stubs are excluded by coverage's standard pragmas), the `_UNSET` defaults inside `with_options` (covered by the `inherits when unset` test), or `_from_view` (covered by the view-shares-transport test). - -- [ ] **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: only the untracked plan file `planning/plans/2026-05-31-asyncclient-plan.md`. - -- [ ] **Step 4: Review the branch diff** - -Run: `git log --oneline main..HEAD` -Expected: nine or ten commits — spec, Task 1, Task 2, Task 3, Task 4, Task 5, Task 6, Task 7, Task 8. - -Run: `git diff --stat main..HEAD` -Expected: changes to `CHANGELOG.md`, `src/httpware/__init__.py`, `src/httpware/config.py`, `src/httpware/client.py` (new), `tests/test_config.py`, plus six new test files under `tests/test_client_*.py`, plus the spec and plan docs. - -- [ ] **Step 5: Stage and commit the plan file** - -```bash -git add planning/plans/2026-05-31-asyncclient-plan.md -git commit -m "docs(story-1.7): implementation plan for AsyncClient - -Co-Authored-By: Claude Opus 4.7 (1M context) " -``` - -- [ ] **Step 6: Push the branch** - -Run: `git push -u origin story/1-7-asyncclient` -Expected: push succeeds; GitHub prints a "Create a pull request for ..." URL. - -- [ ] **Step 7: Open the PR** - -```bash -gh pr create --title "feat(story-1.7): AsyncClient — the v0.1.0 public surface" --body "$(cat <<'EOF' -## Summary - -- Adds `src/httpware/client.py` with `AsyncClient`, the central public class. Eight HTTP method shortcuts (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `request`) with typed `response_model` overloads validated by `ty`. Body params: `json` (auto-encoded) and `content` (raw bytes); mutually exclusive. Per-call overrides: `headers`, `params`, `cookies`, `timeout`. -- httpx-style prefix join for `base_url` + path; absolute URLs bypass. -- Middleware composition via `compose()` at construction (Story 2-1). The composed chain is stored as `self._dispatch`. -- Lifecycle: original AsyncClient owns the transport and closes it on `__aexit__`. Views from `with_options(...)` share the transport and are no-ops on close. Simpler than the archived Decision-9 ref-counting model; ref-counting can be added later without breaking the public API. -- `from_url(base_url, **kwargs)` classmethod factory. -- `ClientConfig` extended with `decoder` and `middleware` fields (backwards-compatible — both have defaults). -- ~38 new tests across six test files; 100% line coverage on `client.py`. - -**Out of scope and deferred:** `auth=` (Story 2-4), `data=`/`files=` body params, transport reference-counting, streaming (Epic 4), observability (Epic 5). - -Spec + plan: `planning/specs/2026-05-31-asyncclient-design.md`, `planning/plans/2026-05-31-asyncclient-plan.md`. - -## Test plan - -- [x] `just test` — ~246 passed, 1 deselected, 100% line coverage on the new and extended source files. -- [x] `just lint-ci` clean (`eof-fixer`, `ruff format --check`, `ruff check --no-fix`, `ty check`). -- [x] `tests/test_no_httpx2_leakage.py` still passes — no `httpx2` import in `client.py`. -- [x] `tests/test_optional_extras_isolation.py` still passes. -- [x] `tests/test_client_typing.py` — `ty` validates the typed overload narrowing across `get`, `post`, and `request`. -- [ ] 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 ` -Expected: all five jobs green (`lint`, `pytest (3.11)`, `pytest (3.12)`, `pytest (3.13)`, `pytest (3.14)`). - -If `pytest (3.14)` fails on `codecov/codecov-action@v4.0.1` with EPIPE (transient pattern seen on this repo), re-run with `gh run rerun --failed`. - -- [ ] **Step 9: Merge** - -Once CI is green: - -Run: `gh pr merge --merge --delete-branch` -Run: `git checkout main && git pull --ff-only && git log --oneline -3` - -Story 1-7 is complete. Story 1-8 (`RecordedTransport` for testing) closes out Epic 1. - ---- - -## Definition of done - -- `src/httpware/config.py` extends `ClientConfig` with `decoder` (default `PydanticDecoder()`) and `middleware` (default `()`) fields. -- `src/httpware/client.py` exists with `AsyncClient`, `_normalize_timeout`, `_build_body`, `_UNSET`, and `_from_view`. -- `src/httpware/__init__.py` exports `AsyncClient` at the package root and adds it to `__all__` in alphabetic position. -- All six test files exist with the test list from the spec; ~38 tests; all pass. -- `tests/test_client_typing.py` includes the `ty`-checked overload-validation file with at least one runtime test. -- `just test` shows the expected increment and 100% line coverage on `client.py` and the new `ClientConfig` fields. -- `just lint-ci` clean. -- `tests/test_no_httpx2_leakage.py` still passes — no `httpx2` import in `client.py`. -- `tests/test_optional_extras_isolation.py` still passes. -- CHANGELOG bullet under `[Unreleased]` / `### Added` describes the public surface plus the out-of-scope items. -- Story 1-7 lands as a single PR off `main` via the branch `story/1-7-asyncclient`. diff --git a/planning/changes/2026-05-31.08-recordedtransport/design.md b/planning/changes/2026-05-31.08-recordedtransport.md similarity index 100% rename from planning/changes/2026-05-31.08-recordedtransport/design.md rename to planning/changes/2026-05-31.08-recordedtransport.md diff --git a/planning/changes/2026-05-31.08-recordedtransport/plan.md b/planning/changes/2026-05-31.08-recordedtransport/plan.md deleted file mode 100644 index 3c76339..0000000 --- a/planning/changes/2026-05-31.08-recordedtransport/plan.md +++ /dev/null @@ -1,1043 +0,0 @@ -# RecordedTransport 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 1-8: a built-in `RecordedTransport` test double at `src/httpware/transports/recorded.py`, then consolidate the five in-tree stubs (`_FakeTransport`, `_OkTransport`, `_FailingTransport`, two `_RecordingTransport` variants, `_TrackingTransport`) by replacing them with `RecordedTransport`. Closes Epic 1. - -**Architecture:** Single new module (~50 lines) implementing the `Transport` protocol with a route table, observed-request list, and `aclose_calls` counter. Routes keyed by `(method.upper(), url)` → `Response | BaseException`. Configurable `default` for the no-match case. After the class lands, six existing test files swap their file-local stubs for `RecordedTransport` construction in a single mechanical commit. - -**Tech Stack:** Python 3.11 floor; stdlib only (`collections.abc.Mapping`, `contextlib.AbstractAsyncContextManager`). No new dependencies. - -**Branch:** `story/1-8-recordedtransport` (already created; spec commit `60d2e0c` is on it). - -**Spec:** `planning/specs/2026-05-31-recordedtransport-design.md`. - ---- - -## File Structure - -**New files:** -- `src/httpware/transports/recorded.py` — `RecordedTransport` class. -- `tests/test_transports_recorded.py` — 15 behavioral tests for the new class. - -**Modified files:** -- `src/httpware/__init__.py` — export `RecordedTransport`, add to `__all__`. -- `CHANGELOG.md` — Story 1.8 bullet. -- `tests/test_middleware.py` — replace `_OkTransport` and `_FailingTransport` with `RecordedTransport`. -- `tests/test_client_construction.py` — replace `_FakeTransport` with `RecordedTransport()`. -- `tests/test_client_methods.py` — replace local `_RecordingTransport` with `RecordedTransport`. -- `tests/test_client_response_model.py` — replace local `_RecordingTransport` with `RecordedTransport`. -- `tests/test_client_lifecycle.py` — replace `_TrackingTransport` with `RecordedTransport`. -- `tests/test_client_middleware_wiring.py` — replace local `_RecordingTransport` with `RecordedTransport`. - -**Files NOT touched:** -- `pyproject.toml`, `Justfile`, CI workflows. -- `src/httpware/transports/__init__.py` (the `Transport` Protocol stays as-is). -- `src/httpware/transports/httpx2.py`. - ---- - -## Task 1: `RecordedTransport` module with core route-matching tests - -TDD cycle for the core behavior: route matching, default handling, exception propagation. Implementation lands once tests are in place. - -**Files:** -- Create: `src/httpware/transports/recorded.py` -- Create: `tests/test_transports_recorded.py` - -- [ ] **Step 1: Add the first failing test (route match returns response)** - -Create `tests/test_transports_recorded.py`: - -```python -"""Unit tests for httpware.transports.recorded.RecordedTransport.""" - -import pytest - -from httpware.request import Request -from httpware.response import Response -from httpware.transports import Transport -from httpware.transports.recorded import RecordedTransport - - -def _response(content: bytes = b"ok") -> Response: - return Response(status=200, headers={}, content=content, url="/", elapsed=0.0) - - -def _request(method: str = "GET", url: str = "/foo") -> Request: - return Request(method=method, url=url) - - -async def test_route_match_returns_response() -> None: - canned = _response(b"matched") - transport = RecordedTransport(routes={("GET", "/foo"): canned}) - - result = await transport(_request()) - - assert result is canned -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/test_transports_recorded.py::test_route_match_returns_response -v` -Expected: `ModuleNotFoundError: No module named 'httpware.transports.recorded'`. - -- [ ] **Step 3: Implement `RecordedTransport`** - -Create `src/httpware/transports/recorded.py`: - -```python -"""RecordedTransport — built-in Transport test double.""" - -from collections.abc import Mapping -from contextlib import AbstractAsyncContextManager - -from httpware.request import Request -from httpware.response import Response, StreamResponse - - -class RecordedTransport: - """Built-in Transport test double. - - Construct with a route table mapping (method, url) → Response | BaseException. - `await transport(request)` looks up `(request.method.upper(), request.url)`; on - match returns the Response or raises the Exception. On no-match, uses the - `default` (Response, BaseException, or RuntimeError("No route for METHOD URL") - when None). - - Every call appends the Request to `transport.requests`. Tests can assert on - `transport.last_request`, iterate `transport.requests`, or count - `transport.aclose_calls` for lifecycle assertions. - - Routes fire indefinitely — the same (method, url) yields the same canned - Response on every match. To express "different replies on repeat calls", - swap the route between calls via `add_route(...)` or construct a new - transport per call. - - `stream()` raises NotImplementedError; streaming lands in Epic 4 (Story 4-1). - """ - - def __init__( - self, - routes: Mapping[tuple[str, str], Response | BaseException] | None = None, - *, - default: Response | BaseException | None = None, - ) -> None: - self._routes: dict[tuple[str, str], Response | BaseException] = ( - {(m.upper(), u): v for (m, u), v in routes.items()} - if routes is not None - else {} - ) - self._default = default - self.requests: list[Request] = [] - self.aclose_calls = 0 - - @property - def last_request(self) -> Request | None: - """The most recently observed Request, or None if no calls have been made.""" - return self.requests[-1] if self.requests else None - - def add_route( - self, - method: str, - url: str, - response_or_exception: Response | BaseException, - ) -> None: - """Add or replace a route entry.""" - self._routes[(method.upper(), url)] = response_or_exception - - async def __call__(self, request: Request) -> Response: - self.requests.append(request) - key = (request.method.upper(), request.url) - result: Response | BaseException | None - result = self._routes[key] if key in self._routes else self._default - if isinstance(result, BaseException): - raise result - if result is None: - msg = f"No route for {request.method} {request.url}" - raise RuntimeError(msg) - return result - - def stream( - self, - request: Request, - ) -> AbstractAsyncContextManager[StreamResponse]: - """Streaming not implemented in v0 — landing in Epic 4 (Story 4-1).""" - msg = "RecordedTransport.stream() is not implemented; streaming lands in Epic 4" - raise NotImplementedError(msg) - - async def aclose(self) -> None: - self.aclose_calls += 1 -``` - -No `__all__` (project convention). - -- [ ] **Step 4: Run the first test to verify it passes** - -Run: `uv run pytest tests/test_transports_recorded.py::test_route_match_returns_response -v` -Expected: PASS. - -- [ ] **Step 5: Add the remaining 4 core tests (exception routes, defaults)** - -Append to `tests/test_transports_recorded.py`: - -```python -async def test_route_match_raises_exception() -> None: - class _BoomError(Exception): - pass - - transport = RecordedTransport(routes={("GET", "/fail"): _BoomError("boom")}) - - with pytest.raises(_BoomError, match="boom"): - await transport(_request(url="/fail")) - - -async def test_no_match_with_no_default_raises_runtime_error() -> None: - transport = RecordedTransport() - - with pytest.raises(RuntimeError, match=r"No route for GET /missing"): - await transport(_request(url="/missing")) - - -async def test_no_match_with_response_default_returns_default() -> None: - fallback = _response(b"fallback") - transport = RecordedTransport(default=fallback) - - result = await transport(_request(url="/anything")) - - assert result is fallback - - -async def test_no_match_with_exception_default_raises_default() -> None: - transport = RecordedTransport(default=RuntimeError("default boom")) - - with pytest.raises(RuntimeError, match="default boom"): - await transport(_request(url="/anything")) -``` - -- [ ] **Step 6: Run tests to verify all 5 pass** - -Run: `uv run pytest tests/test_transports_recorded.py -v` -Expected: 5 passed. - -- [ ] **Step 7: Lint and ty** - -Run: `uv run ruff check src/httpware/transports/recorded.py tests/test_transports_recorded.py` -Run: `uv run ty check src/httpware/transports/recorded.py` -Expected: both clean. - -- [ ] **Step 8: Commit** - -```bash -git add src/httpware/transports/recorded.py tests/test_transports_recorded.py -git commit -m "$(cat <<'EOF' -feat(story-1.8): RecordedTransport test double with core route matching - -Adds src/httpware/transports/recorded.py with RecordedTransport: -- routes: Mapping[(method, url), Response | BaseException] with method - uppercased on insert -- default: Response | BaseException | None — None raises RuntimeError per - archive AC; otherwise the default is returned or raised -- requests: list[Request] populated on every __call__ -- last_request property reading requests[-1] -- aclose_calls counter -- add_route(method, url, response_or_exception) for incremental setup -- stream() raises NotImplementedError (lands in Epic 4) - -Five tests cover: route match returns Response, route raises Exception, -no-match raises RuntimeError, default Response returned on no-match, -default Exception raised on no-match. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: Method normalization, observability, lifecycle, stream, protocol satisfaction tests - -Ten more tests covering case normalization, request recording, `aclose`, `stream`, protocol satisfaction, `add_route`, and multi-call semantics. No production code changes expected. - -**Files:** -- Modify: `tests/test_transports_recorded.py` (append 10 tests) - -- [ ] **Step 1: Add the method-normalization tests** - -Append to `tests/test_transports_recorded.py`: - -```python -async def test_method_normalized_to_uppercase_in_routes() -> None: - canned = _response() - transport = RecordedTransport(routes={("get", "/foo"): canned}) - - result = await transport(_request(method="GET")) - - assert result is canned - - -async def test_method_normalized_to_uppercase_on_request() -> None: - canned = _response() - transport = RecordedTransport(routes={("GET", "/foo"): canned}) - - result = await transport(_request(method="get")) - - assert result is canned -``` - -- [ ] **Step 2: Add the requests-recording tests** - -Append: - -```python -async def test_requests_list_records_every_call() -> None: - transport = RecordedTransport(default=_response()) - - req1 = _request(url="/a") - req2 = _request(url="/b") - req3 = _request(url="/c") - await transport(req1) - await transport(req2) - await transport(req3) - - assert transport.requests == [req1, req2, req3] - - -async def test_last_request_returns_most_recent() -> None: - transport = RecordedTransport(default=_response()) - - assert transport.last_request is None - - req1 = _request(url="/a") - await transport(req1) - assert transport.last_request is req1 - - req2 = _request(url="/b") - await transport(req2) - assert transport.last_request is req2 -``` - -- [ ] **Step 3: Add the aclose tests** - -Append: - -```python -async def test_aclose_increments_counter() -> None: - transport = RecordedTransport() - - assert transport.aclose_calls == 0 - - await transport.aclose() - await transport.aclose() - await transport.aclose() - - assert transport.aclose_calls == 3 # noqa: PLR2004 - - -async def test_aclose_is_idempotent_and_doesnt_block_calls() -> None: - transport = RecordedTransport(default=_response()) - - await transport.aclose() - result = await transport(_request()) - - assert result is not None - assert transport.aclose_calls == 1 -``` - -- [ ] **Step 4: Add the stream and protocol-satisfaction tests** - -Append: - -```python -def test_stream_raises_not_implemented_error() -> None: - transport = RecordedTransport() - - with pytest.raises(NotImplementedError, match="streaming lands in Epic 4"): - transport.stream(_request()) - - -def test_satisfies_transport_protocol() -> None: - assert isinstance(RecordedTransport(), Transport) -``` - -- [ ] **Step 5: Add the add_route and multi-call tests** - -Append: - -```python -async def test_add_route_appends_or_replaces_entry() -> None: - transport = RecordedTransport() - - original = _response(b"first") - transport.add_route("GET", "/foo", original) - assert (await transport(_request())) is original - - replacement = _response(b"second") - transport.add_route("GET", "/foo", replacement) - assert (await transport(_request())) is replacement - - -async def test_routes_fire_indefinitely_on_repeat_calls() -> None: - canned = _response(b"canned") - transport = RecordedTransport(routes={("GET", "/foo"): canned}) - - r1 = await transport(_request()) - r2 = await transport(_request()) - r3 = await transport(_request()) - - assert r1 is canned - assert r2 is canned - assert r3 is canned -``` - -- [ ] **Step 6: Run all 15 tests** - -Run: `uv run pytest tests/test_transports_recorded.py -v` -Expected: 15 passed. - -- [ ] **Step 7: Lint** - -Run: `uv run ruff check tests/test_transports_recorded.py` -Expected: clean. - -- [ ] **Step 8: Verify 100% coverage on the new module** - -Run: `uv run pytest tests/test_transports_recorded.py --cov=src/httpware/transports/recorded --cov-report=term-missing` -Expected: 100% coverage on `recorded.py`. - -If any line is missed, identify which test should exercise it. The whole class body (~50 lines) is covered by the test suite as-written. - -- [ ] **Step 9: Commit** - -```bash -git add tests/test_transports_recorded.py -git commit -m "$(cat <<'EOF' -test(story-1.8): method norm, observability, lifecycle, protocol — full suite - -Ten additional tests bring RecordedTransport to 15 total: -- method normalization in both directions (route key vs request) -- requests list captures every call in order; last_request property -- aclose counter; idempotent close that doesn't block subsequent calls -- stream() raises NotImplementedError pointing to Epic 4 -- isinstance(RecordedTransport(), Transport) — protocol satisfaction -- add_route adds and replaces entries -- routes fire indefinitely on repeat matching calls - -100% line coverage on src/httpware/transports/recorded.py. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: Public exports and CHANGELOG - -Wire `RecordedTransport` into the package root and add the Story 1.8 bullet. - -**Files:** -- Modify: `src/httpware/__init__.py` -- Modify: `CHANGELOG.md` -- Modify: `tests/test_transports_recorded.py` (add a re-export test) - -- [ ] **Step 1: Add the failing re-export test** - -Append to `tests/test_transports_recorded.py`: - -```python -def test_recorded_transport_reexported_at_package_root() -> None: - """`from httpware import RecordedTransport` works in addition to the subpackage path.""" - import httpware - - assert httpware.RecordedTransport is RecordedTransport - assert "RecordedTransport" in httpware.__all__ -``` - -Move the `import httpware` to the top of the file alongside the other imports (memory: in-function imports are a code smell). If the test file doesn't yet import `httpware`, add it at the top. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/test_transports_recorded.py::test_recorded_transport_reexported_at_package_root -v` -Expected: `AttributeError: module 'httpware' has no attribute 'RecordedTransport'`. - -- [ ] **Step 3: Update `src/httpware/__init__.py`** - -Edit `src/httpware/__init__.py`. Find the existing `from httpware.transports.httpx2 import Httpx2Transport` line and add the import for `RecordedTransport` immediately after (or wherever ruff prefers alphabetically): - -```python -from httpware.transports.recorded import RecordedTransport -``` - -In `__all__`, add `"RecordedTransport"` to the list. Ruff `RUF022` will sort. The correct ASCII-order position is between `"RateLimitedError"` and `"Request"`. If unsure, add anywhere and run `uv run ruff check --fix src/httpware/__init__.py`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run pytest tests/test_transports_recorded.py::test_recorded_transport_reexported_at_package_root -v` -Expected: PASS. - -- [ ] **Step 5: Append CHANGELOG bullet** - -Edit `CHANGELOG.md`. The `## [Unreleased]` / `### Added` section currently ends with the Story 1.7 bullet about `AsyncClient`. Append a new bullet immediately after it (before the `[Unreleased]: ...` reference link at the bottom): - -```markdown -- `RecordedTransport` built-in `Transport` test double at `httpware.transports.recorded` (also re-exported as `httpware.RecordedTransport`). Construct with `routes: Mapping[(method, url), Response | BaseException]` and a configurable `default` for the no-match case (`None` → `RuntimeError("No route for METHOD URL")` per archive AC; `Response` → returned; `BaseException` → raised). Method names are uppercased on insert and lookup. Routes fire indefinitely on repeat matches. Exposes `transport.requests: list[Request]`, `transport.last_request` (property), and `transport.aclose_calls: int` for assertion patterns. `add_route(method, url, response_or_exception)` allows incremental setup. `stream()` raises `NotImplementedError` — streaming lands in Epic 4 (Story 4-1). Replaces the five in-tree test stubs (`_FakeTransport`, `_OkTransport`, `_FailingTransport`, two `_RecordingTransport` variants, `_TrackingTransport`) accumulated through Stories 2-1 and 1-7 (Story 1.8). -``` - -- [ ] **Step 6: Lint and ty** - -Run: `uv run ruff check src/httpware/__init__.py tests/test_transports_recorded.py` -Run: `uv run ty check src/httpware/__init__.py` -Expected: both clean. - -- [ ] **Step 7: Commit** - -```bash -git add src/httpware/__init__.py tests/test_transports_recorded.py CHANGELOG.md -git commit -m "$(cat <<'EOF' -feat(story-1.8): re-export RecordedTransport at httpware package root - -Adds RecordedTransport to httpware/__init__.py imports and __all__ so -consumers can `from httpware import RecordedTransport` in addition to -the subpackage path. CHANGELOG records the Story 1.8 surface and notes -the in-tree stub consolidation (Task 4). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: Replace the five in-tree test stubs with `RecordedTransport` - -Mechanical refactor across six test files. Each stub class drops out; `RecordedTransport(...)` construction takes its place. The replacements are bundled into one commit (per the spec's "stub-replacement commit must not regress any existing test"). - -**Files:** -- Modify: `tests/test_middleware.py` (drop `_OkTransport`, `_FailingTransport`) -- Modify: `tests/test_client_construction.py` (drop `_FakeTransport`) -- Modify: `tests/test_client_methods.py` (drop local `_RecordingTransport`) -- Modify: `tests/test_client_response_model.py` (drop local `_RecordingTransport`) -- Modify: `tests/test_client_lifecycle.py` (drop `_TrackingTransport`) -- Modify: `tests/test_client_middleware_wiring.py` (drop local `_RecordingTransport`; tests use `len(transport.requests)` instead of `.calls`) - -For each file, the workflow is the same: - -1. Add the import: `from httpware import RecordedTransport`. -2. Delete the stub class definition (and any unused `from contextlib import AbstractAsyncContextManager` or `from httpware.response import StreamResponse` imports left behind). -3. Replace each stub construction with `RecordedTransport(...)`. -4. Adapt any access to fields the stub had but `RecordedTransport` exposes differently (e.g., `.calls` → `len(transport.requests)`). -5. Run that file's tests. -6. Once all six files pass, commit. - -The detailed per-file replacement strategy follows. - -### 4.1 `tests/test_client_construction.py` - -The `_FakeTransport` is constructed in `test_init_accepts_explicit_transport` and stored on the client; it's never called. - -- [ ] **Step 1: Delete the `_FakeTransport` class definition** - -Find this block at the top of `tests/test_client_construction.py`: - -```python -class _FakeTransport: - """Minimal Transport for construction tests; never actually called.""" - - async def __call__(self, request: Request) -> Response: # pragma: no cover - not used - raise NotImplementedError - - def stream( # pragma: no cover - not used - self, request: Request - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: # pragma: no cover - not used - return None -``` - -Delete it. - -- [ ] **Step 2: Replace `_FakeTransport()` usage** - -In `test_init_accepts_explicit_transport`, change `transport = _FakeTransport()` to: - -```python -transport = RecordedTransport() -``` - -- [ ] **Step 3: Add the import** - -At the top of the file's imports (alphabetically): - -```python -from httpware import AsyncClient, Limits, RecordedTransport, Timeout -``` - -(Modify the existing `from httpware import AsyncClient, Limits, Timeout` line to include `RecordedTransport`.) - -- [ ] **Step 4: Drop unused imports** - -If `Response`, `StreamResponse`, `AbstractAsyncContextManager` were imported only for `_FakeTransport`, drop them. Verify by inspecting other tests in the file. - -- [ ] **Step 5: Run this file's tests** - -Run: `uv run pytest tests/test_client_construction.py -v` -Expected: all pass (same count as before). - -### 4.2 `tests/test_client_lifecycle.py` - -The `_TrackingTransport` exposes `aclose_calls`; `RecordedTransport` has the same attribute name. - -- [ ] **Step 1: Delete the `_TrackingTransport` class definition** - -Find and delete: - -```python -class _TrackingTransport: - """Counts aclose() invocations.""" - - def __init__(self) -> None: - self.aclose_calls = 0 - - async def __call__(self, request: Request) -> Response: # pragma: no cover - not used - raise NotImplementedError - - def stream( # pragma: no cover - not used - self, request: Request - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: - self.aclose_calls += 1 -``` - -- [ ] **Step 2: Replace `_TrackingTransport()` with `RecordedTransport()`** - -In each test, change `transport = _TrackingTransport()` to: - -```python -transport = RecordedTransport() -``` - -The tests already access `transport.aclose_calls`; that attribute is present on `RecordedTransport`. - -- [ ] **Step 3: Add the import** - -```python -from httpware import AsyncClient, RecordedTransport -``` - -- [ ] **Step 4: Drop unused imports** - -Remove `Request`, `Response`, `StreamResponse`, `AbstractAsyncContextManager` if they were imported only for the stub. - -- [ ] **Step 5: Run this file's tests** - -Run: `uv run pytest tests/test_client_lifecycle.py -v` -Expected: all pass. - -### 4.3 `tests/test_client_methods.py` - -The `_RecordingTransport` here has `last_request` and a canned 200 response. `RecordedTransport(default=...)` provides both. - -- [ ] **Step 1: Delete the local `_RecordingTransport` class** - -Find and delete: - -```python -class _RecordingTransport: - """Captures the last-seen Request and returns a canned Response.""" - - def __init__(self) -> None: - self.last_request: Request | None = None - self.canned = Response( - status=200, - headers={"x-from": "transport"}, - content=b"body", - url="https://example.test/", - elapsed=0.0, - ) - - async def __call__(self, request: Request) -> Response: - self.last_request = request - return self.canned - - 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 -``` - -- [ ] **Step 2: Replace constructions** - -For each `transport = _RecordingTransport()`, replace with: - -```python -transport = RecordedTransport( - default=Response( - status=200, - headers={"x-from": "transport"}, - content=b"body", - url="https://example.test/", - elapsed=0.0, - ) -) -``` - -Tests already access `transport.last_request`; the property is present on `RecordedTransport`. - -- [ ] **Step 3: Add the import** - -```python -from httpware import AsyncClient, RecordedTransport -``` - -- [ ] **Step 4: Drop unused imports** - -Remove `StreamResponse`, `AbstractAsyncContextManager` if no longer needed. - -- [ ] **Step 5: Run this file's tests** - -Run: `uv run pytest tests/test_client_methods.py -v` -Expected: all pass. - -### 4.4 `tests/test_client_response_model.py` - -The `_RecordingTransport` here takes a `content` argument and returns a canned 200 with that content. - -- [ ] **Step 1: Delete the local `_RecordingTransport` class** - -Find and delete the class definition at the top of the file. - -- [ ] **Step 2: Replace constructions** - -For each `transport = _RecordingTransport(content=b'...')`, replace with: - -```python -transport = RecordedTransport( - default=Response( - status=200, - headers={}, - content=b'...', - url="/", - elapsed=0.0, - ) -) -``` - -(Use the same content bytes the test was originally constructing.) - -- [ ] **Step 3: Add the import** - -```python -from httpware import AsyncClient, RecordedTransport, Response -``` - -- [ ] **Step 4: Drop unused imports** - -Remove `StreamResponse`, `AbstractAsyncContextManager`, and the `Request` import if no longer used. - -- [ ] **Step 5: Run this file's tests** - -Run: `uv run pytest tests/test_client_response_model.py -v` -Expected: 3 passed. - -### 4.5 `tests/test_client_middleware_wiring.py` - -The `_RecordingTransport` here counts `.calls`. Replace with `RecordedTransport(default=...)`; tests that used `transport.calls == N` switch to `len(transport.requests) == N`. - -- [ ] **Step 1: Delete the local `_RecordingTransport` class** - -Find and delete the class. - -- [ ] **Step 2: Replace constructions** - -For each `transport = _RecordingTransport()`, replace with: - -```python -transport = RecordedTransport( - default=Response( - status=200, - headers={}, - content=b"", - url="/", - elapsed=0.0, - ) -) -``` - -Note: the original stub returned a Response with `url=request.url`. `RecordedTransport(default=Response(url="/"))` returns a fixed URL. Most tests don't read `response.url`; if any do, adjust them to read `transport.last_request.url` instead. - -- [ ] **Step 3: Switch `transport.calls` to `len(transport.requests)`** - -Search for `transport.calls` in the file. Each occurrence: - -```python -# Before: -assert transport.calls == 1 - -# After: -assert len(transport.requests) == 1 -``` - -- [ ] **Step 4: Add the import** - -```python -from httpware import AsyncClient, RecordedTransport, Response -``` - -- [ ] **Step 5: Drop unused imports** - -Remove `StreamResponse`, `AbstractAsyncContextManager`, `Request` if unused. - -- [ ] **Step 6: Run this file's tests** - -Run: `uv run pytest tests/test_client_middleware_wiring.py -v` -Expected: all pass. - -### 4.6 `tests/test_middleware.py` - -The file has TWO stubs: `_OkTransport` (returns fixed 200) and `_FailingTransport` (raises chosen exception). Replace both. - -- [ ] **Step 1: Delete `_OkTransport` and `_FailingTransport`** - -Find and delete: - -```python -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 - ... - ) -> AbstractAsyncContextManager[StreamResponse]: - raise NotImplementedError - - async def aclose(self) -> None: # pragma: no cover - not exercised in 2-1 - return None -``` - -And `_FailingTransport`: - -```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: # 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 -``` - -- [ ] **Step 2: Replace `_OkTransport()` constructions** - -Each `_OkTransport()` becomes: - -```python -RecordedTransport( - default=Response( - status=200, - headers={"x-from": "transport"}, - content=b"transport", - url="/", - elapsed=0.0, - ) -) -``` - -Note: the original stub returned `url=request.url`. If any test reads `response.url` from the dispatched response and expects it to match the request URL, switch the assertion to read `transport.last_request.url`. Most tests in `test_middleware.py` check headers / status / content rather than url. - -- [ ] **Step 3: Replace `_FailingTransport(exc)` constructions** - -Each `_FailingTransport(some_error)` becomes: - -```python -RecordedTransport(default=some_error) -``` - -- [ ] **Step 4: Add the import** - -```python -from httpware import RecordedTransport -``` - -(plus the existing `Middleware`, `Next`, etc. imports.) - -- [ ] **Step 5: Drop unused imports** - -Remove `StreamResponse` and `AbstractAsyncContextManager` if no longer needed after the stub removal. - -- [ ] **Step 6: Run this file's tests** - -Run: `uv run pytest tests/test_middleware.py -v` -Expected: all 24 tests pass. - -### 4.7 Final commit for Task 4 - -- [ ] **Step 1: Run the full test suite** - -Run: `just test` -Expected: same count as the post-Task-3 baseline plus the 16 RecordedTransport tests (15 + 1 reexport), now ~272 passed, 1 deselected. 100% coverage maintained. - -- [ ] **Step 2: Lint** - -Run: `just lint-ci` -Expected: clean. - -- [ ] **Step 3: Commit** - -```bash -git add tests/test_middleware.py tests/test_client_construction.py tests/test_client_methods.py tests/test_client_response_model.py tests/test_client_lifecycle.py tests/test_client_middleware_wiring.py -git commit -m "$(cat <<'EOF' -test(story-1.8): consolidate five in-tree transport stubs into RecordedTransport - -Replaces the file-local _FakeTransport (test_client_construction.py), -_OkTransport and _FailingTransport (test_middleware.py), two distinct -_RecordingTransport variants (test_client_methods.py, -test_client_response_model.py, test_client_middleware_wiring.py), and -_TrackingTransport (test_client_lifecycle.py) with one shared -RecordedTransport class. - -Each replacement is mechanical: -- _FakeTransport() → RecordedTransport() -- _OkTransport() → RecordedTransport(default=Response(...)) -- _FailingTransport(exc) → RecordedTransport(default=exc) -- _RecordingTransport (last_request flavor) → RecordedTransport(default=Response(...)) -- _RecordingTransport (calls counter) → RecordedTransport(default=Response(...)) - with .calls → len(transport.requests) -- _TrackingTransport → RecordedTransport() (aclose_calls attribute preserved) - -No test behavior changes. Total test count unchanged from this commit's -edits; only the new 16 RecordedTransport tests from Tasks 1-3 increase the -overall count. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 5: Verify, push, PR, merge - -End-to-end sanity check, push, open PR, wait for CI, merge. - -- [ ] **Step 1: Run the full test suite with coverage** - -Run: `just test` -Expected: ~272 passed (256 baseline post-1.7 + 15 RecordedTransport behavioral tests + 1 reexport test), 1 deselected (perf), 100% line coverage including `src/httpware/transports/recorded.py`. - -- [ ] **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: only the untracked plan file `planning/plans/2026-05-31-recordedtransport-plan.md`. - -- [ ] **Step 4: Review the branch diff** - -Run: `git log --oneline main..HEAD` -Expected: five commits — spec, Task 1, Task 2, Task 3, Task 4. - -Run: `git diff --stat main..HEAD` -Expected: new files `src/httpware/transports/recorded.py`, `tests/test_transports_recorded.py`, the spec, the plan; modifications to `CHANGELOG.md`, `src/httpware/__init__.py`, and the six test files; no source files outside this scope. - -- [ ] **Step 5: Stage and commit the plan file** - -```bash -git add planning/plans/2026-05-31-recordedtransport-plan.md -git commit -m "docs(story-1.8): implementation plan for RecordedTransport - -Co-Authored-By: Claude Opus 4.7 (1M context) " -``` - -- [ ] **Step 6: Push the branch** - -Run: `git push -u origin story/1-8-recordedtransport` -Expected: push succeeds; GitHub prints a "Create a pull request for ..." URL. - -- [ ] **Step 7: Open the PR** - -```bash -gh pr create --title "feat(story-1.8): RecordedTransport — built-in Transport test double" --body "$(cat <<'EOF' -## Summary - -- Adds `src/httpware/transports/recorded.py` with `RecordedTransport`, a built-in `Transport` test double. Route table maps `(method, url)` to `Response | BaseException`; configurable `default` for the no-match case (`None` → `RuntimeError` per archive AC; `Response` → returned; `BaseException` → raised). Method names are uppercased on insert and lookup. Routes fire indefinitely on repeat matches. -- Exposes `transport.requests: list[Request]`, `transport.last_request` (property), and `transport.aclose_calls: int` for assertion patterns. `add_route(method, url, response_or_exception)` for incremental setup. `stream()` raises `NotImplementedError` — streaming lands in Epic 4 (Story 4-1). -- Re-exported as `httpware.RecordedTransport`. -- **Consolidation:** the five in-tree test stubs accumulated through Stories 1-7 and 2-1 (`_FakeTransport`, `_OkTransport`, `_FailingTransport`, two `_RecordingTransport` variants, `_TrackingTransport`) are replaced with one canonical class. Mechanical refactor; no test behavior changes. -- 16 new tests (15 behavioral + 1 reexport) in `tests/test_transports_recorded.py`; 100% line coverage on the new module. - -This closes Epic 1. - -Out of scope (subsequent stories): URL pattern matching / globs, cassette files loaded from JSON, streaming responses (Epic 4). - -Spec + plan: `planning/specs/2026-05-31-recordedtransport-design.md`, `planning/plans/2026-05-31-recordedtransport-plan.md`. - -## Test plan - -- [x] `just test` — ~272 passed, 1 deselected, 100% line coverage on the new module. -- [x] `just lint-ci` clean. -- [x] `tests/test_no_httpx2_leakage.py` still passes. -- [x] `tests/test_optional_extras_isolation.py` still passes. -- [x] All six existing test files (`test_middleware.py`, `test_client_construction.py`, `test_client_methods.py`, `test_client_response_model.py`, `test_client_lifecycle.py`, `test_client_middleware_wiring.py`) pass after the stub-replacement commit. -- [ ] 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 ` -Expected: all five jobs green. - -If `pytest (3.14)` fails on the `codecov/codecov-action@v4.0.1` step with EPIPE (transient pattern observed earlier on this repo), re-run with `gh run rerun --failed`. - -- [ ] **Step 9: Merge** - -Once CI is green: - -Run: `gh pr merge --merge --delete-branch` -Run: `git checkout main && git pull --ff-only && git log --oneline -3` - -Story 1-8 is complete. **Epic 1 is complete.** Story 2-4 (auth coercion as middleware) becomes the next normal-flow item in Epic 2. - ---- - -## Definition of done - -- `src/httpware/transports/recorded.py` exists with the `RecordedTransport` class. No `__all__`. -- `tests/test_transports_recorded.py` contains 16 tests (15 behavioral + 1 reexport); all pass; 100% line coverage on `recorded.py`. -- `src/httpware/__init__.py` exports `RecordedTransport` at the package root and adds it to `__all__`. -- All five existing in-tree stub classes (`_FakeTransport`, `_OkTransport`, `_FailingTransport`, the two `_RecordingTransport` variants, `_TrackingTransport`) are removed from their respective test files; those tests now use `RecordedTransport(...)` construction. Total test count unchanged from before the stub-replacement commit (only Tasks 1-3 add new tests). -- `just test` shows ~272 passed, 1 deselected, 100% line coverage. -- `just lint-ci` clean. -- `tests/test_no_httpx2_leakage.py` and `tests/test_optional_extras_isolation.py` still pass. -- CHANGELOG bullet under `[Unreleased]` / `### Added` describes the public surface plus the stub-consolidation outcome. -- Story 1-8 lands as a single PR off `main` via the branch `story/1-8-recordedtransport`. Epic 1 is complete after this merge. diff --git a/planning/changes/2026-05-31.09-release-0.1.0-prep/design.md b/planning/changes/2026-05-31.09-release-0.1.0-prep.md similarity index 100% rename from planning/changes/2026-05-31.09-release-0.1.0-prep/design.md rename to planning/changes/2026-05-31.09-release-0.1.0-prep.md diff --git a/planning/changes/2026-05-31.09-release-0.1.0-prep/plan.md b/planning/changes/2026-05-31.09-release-0.1.0-prep/plan.md deleted file mode 100644 index c64745e..0000000 --- a/planning/changes/2026-05-31.09-release-0.1.0-prep/plan.md +++ /dev/null @@ -1,497 +0,0 @@ -# Release 0.1.0 prep 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:** Land a single PR (`chore/release-0.1.0-prep`) that deletes `CHANGELOG.md`, adds `.github/workflows/publish.yml`, and trims `README.md` to ship only what's actually in 0.1.0. After merge, a manual maintainer-driven sequence verifies PyPI name ownership and the `PYPI_TOKEN` secret, then creates the GitHub Release that triggers the publish workflow. - -**Architecture:** Documentation and CI-config-only change. No production code touched; no test changes. The repo at `main` (currently `204d463`) is the release artifact; this PR just removes the misleading CHANGELOG.md, adds the publish workflow, and trims the README. - -**Tech Stack:** GitHub Actions, `uv publish`, PyPI. No new runtime deps. - -**Branch:** `chore/release-0.1.0-prep` (already created; spec commit `eecd041` is on it). - -**Spec:** `planning/specs/2026-05-31-release-0.1.0-prep-design.md`. - ---- - -## File Structure - -**Deleted files:** -- `CHANGELOG.md` — release notes live on GitHub Releases going forward (modern-di convention). - -**New files:** -- `.github/workflows/publish.yml` — triggered by `release: published`, runs `just publish`. - -**Modified files:** -- `README.md` — strict trim to what ships in 0.1.0; status banner names the unshipped categories. -- `CONTRIBUTING.md` — remove the bullet that says "Update `CHANGELOG.md`". - -**Files NOT touched:** -- `pyproject.toml` — `version = "0"` stays; publish recipe overrides via `uv version $GITHUB_REF_NAME`. -- `Justfile` — `publish` recipe is already correct. -- `.github/workflows/ci.yml` — test/lint matrix unchanged. -- `src/httpware/**`, `tests/**` — no code or test changes. -- `LICENSE`, `SECURITY.md`, `CLAUDE.md` — unchanged (CLAUDE.md verified to have no CHANGELOG references). -- `docs/dev/engineering.md` — internal roadmap stays; "Released" markers are a follow-up. -- `docs/archive/**` — bmad archive untouched. - ---- - -## Task 1: Delete `CHANGELOG.md` and scrub `CONTRIBUTING.md` - -Remove the changelog file and the one CONTRIBUTING.md bullet that referenced it. - -**Files:** -- Delete: `CHANGELOG.md` -- Modify: `CONTRIBUTING.md` - -- [ ] **Step 1: Verify the current state** - -Run: `git status --short && git branch --show-current` -Expected: branch is `chore/release-0.1.0-prep`. Working tree has the untracked plan file `planning/plans/2026-05-31-release-0.1.0-prep-plan.md` (this file) and nothing else. - -Run: `/usr/bin/grep -in "changelog" CONTRIBUTING.md CLAUDE.md README.md` -Expected output: -``` -CONTRIBUTING.md::5. **Update `CHANGELOG.md`** in the `Unreleased` section. -``` -(Plus possibly a reference in `README.md` line ~67 mentioning the changelog link. The README will be fully rewritten in Task 3.) - -If `CLAUDE.md` shows any matches, stop and flag — the spec assumed it had none. - -- [ ] **Step 2: Delete `CHANGELOG.md`** - -Run: `git rm CHANGELOG.md` -Expected: `rm 'CHANGELOG.md'`. The file is staged for deletion. - -- [ ] **Step 3: Remove the CHANGELOG bullet from `CONTRIBUTING.md`** - -Read `CONTRIBUTING.md` to find the line `5. **Update `CHANGELOG.md`** in the `Unreleased` section.` It is part of a numbered list. Two cases: - -- **Case A:** the list is `1. … 2. … 3. … 4. … 5. 6. …`. Deleting bullet 5 means renumbering subsequent items so the list stays contiguous (1, 2, 3, 4, 5 instead of 1, 2, 3, 4, 6). -- **Case B:** bullet 5 is the last item. Delete the line; no renumbering needed. - -Inspect the file first (`uv run head -50 CONTRIBUTING.md` or just read it), then apply the edit. If Case A, also adjust subsequent numbers in the same edit so the list reads consecutively. - -- [ ] **Step 4: Verify no CHANGELOG references remain anywhere except docs/archive/** - -Run: `/usr/bin/grep -rIn "changelog" --include="*.md" --include="*.toml" --include="*.yml" --include="*.yaml" . --exclude-dir=docs/archive --exclude-dir=.venv --exclude-dir=.git --exclude-dir=docs/superpowers` -Expected: no matches (or only matches inside `planning/specs/2026-05-31-release-0.1.0-prep-design.md` describing this very deletion, which is fine). - -If `README.md` still has a CHANGELOG reference, leave it — Task 3 rewrites the README entirely. - -- [ ] **Step 5: Commit** - -```bash -git add CONTRIBUTING.md -git commit -m "$(cat <<'EOF' -chore(release): delete CHANGELOG.md; remove contributing-guide reference - -modern-di in the same org doesn't ship a CHANGELOG.md — release notes -live on GitHub Releases (one entry per tag). This commit removes the -file and the one CONTRIBUTING.md bullet that asked contributors to -update an `Unreleased` section. - -README.md still has a stale CHANGELOG link; the README is fully -rewritten in a follow-up commit on this branch. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: Add `.github/workflows/publish.yml` - -Mirror modern-di's publish workflow byte-for-byte. - -**Files:** -- Create: `.github/workflows/publish.yml` - -- [ ] **Step 1: Create the workflow file** - -Create `.github/workflows/publish.yml`: - -```yaml -name: Publish Package - -on: - release: - types: - - published - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: extractions/setup-just@v2 - - uses: astral-sh/setup-uv@v3 - - run: just publish - env: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} -``` - -This mirrors `/Users/kevinsmith/src/pypi/modern-di/.github/workflows/publish.yml`. Triggered when a GitHub Release is published. Runs `just publish` which does `rm -rf dist && uv version $GITHUB_REF_NAME && uv build && uv publish --token $PYPI_TOKEN`. `$GITHUB_REF_NAME` is the tag name (e.g., `0.1.0`). - -- [ ] **Step 2: Lint-check the YAML** - -Run: `uv run python -c "import yaml; yaml.safe_load(open('.github/workflows/publish.yml'))"` -Expected: silent success (no output, no exception). - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/publish.yml -git commit -m "$(cat <<'EOF' -ci(release): add publish.yml workflow for PyPI release on tag - -Mirrors the modern-di publish workflow byte-for-byte. Triggered when a -GitHub Release is published; runs the existing `just publish` recipe -which extracts the version from $GITHUB_REF_NAME, builds, and uploads -to PyPI with the PYPI_TOKEN repo secret. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: Rewrite `README.md` - -Replace the current README with the trimmed version from the spec. Removes the bullets for unshipped features (Retry/RetryBudget/Bulkhead/Timeout/Observability/OTel) and adds a status banner that names what's not yet shipped. - -**Files:** -- Modify: `README.md` (full rewrite) - -- [ ] **Step 1: Replace the contents of `README.md`** - -Open `README.md` and replace the entire file with this content: - -```markdown -# httpware - -[![Test](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml) -[![PyPI version](https://badge.fury.io/py/httpware.svg)](https://pypi.org/project/httpware/) -[![Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -**Async HTTP client framework for Python.** - -`httpware` is a typed, async HTTP client library built on `httpx2` with a protocol-based seam so the transport is swappable. Middleware composes via an onion model. Pydantic and msgspec response decoding ship out of the box. `RecordedTransport` replaces respx for transport-level tests. - -> **Status:** Pre-1.0 (0.1.0 alpha). Public API is subject to change between minor releases until v1.0. Resilience middleware (retry / timeout / bulkhead), streaming, and observability are not yet shipped — track progress on GitHub. - -## Install - -​```bash -pip install httpware -​``` - -Optional extras: - -​```bash -pip install httpware[msgspec] # MsgspecDecoder -​``` - -(`otel`, `niquests`, and `all` extras are declared but their integrations have not shipped yet.) - -## Quickstart - -​```python -from httpware import AsyncClient -from pydantic import BaseModel - - -class User(BaseModel): - id: int - name: str - - -async def main() -> None: - async with AsyncClient(base_url="https://api.example.com") as client: - user = await client.get("/users/1", response_model=User) - print(user.name) -​``` - -## What ships in 0.1.0 - -- **`AsyncClient`** — eight HTTP method shortcuts (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `request`) with typed `response_model` overloads; per-call overrides for `headers`, `params`, `cookies`, `timeout`, `json`, `content`; httpx-style `base_url` join; `with_options(...)` returns a view sharing the same transport. -- **Transport-agnostic seam.** `httpx2` is confined to `httpware.transports.httpx2.Httpx2Transport`. Implement the `Transport` protocol to swap backends. -- **Middleware foundation.** `Middleware` protocol, `Next` type alias, recursive-closure `compose()` chain composition, and phase decorators (`@before_request`, `@after_response`, `@on_error`). -- **Pluggable response decoding.** `PydanticDecoder` (default) with cached `TypeAdapter`; `MsgspecDecoder` via `httpware[msgspec]`. -- **`RecordedTransport`** — built-in test double with a route table, observed-request list, and `aclose_calls` counter. -- **Status-keyed exception hierarchy** — `StatusError`, 4xx / 5xx subclasses, plain typed fields (`status: int`, `body: bytes`, `headers`, `json`, `request_method`, `request_url`). Pickleable; userinfo redacted in `__repr__`. -- **No `httpx2` exception types** leak through `httpware`. The transport seam maps them to `httpware` exceptions. - -## Part of `modern-python` - -Browse the full list of templates and libraries in [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index. - -## License - -MIT — see [LICENSE](./LICENSE). -``` - -**IMPORTANT:** when you write this to `README.md`, the three triple-backtick fences (around `bash`, `bash`, `python`) above show as zero-width-joiner-prefixed because this plan file is itself markdown. Replace each `​​```` with a real triple-backtick fence (three actual `` ` `` characters) before saving. Verify after the write that the markdown renders correctly. - -- [ ] **Step 2: Verify the README content** - -Run: `/usr/bin/grep -in "retry\|bulkhead\|RetryBudget\|observabil\|otel\|opentelemetry" README.md` -Expected: at most one match — the line in the status banner that says "Resilience middleware (retry / timeout / bulkhead), streaming, and observability are not yet shipped". Any other matches indicate stale content from the old README; remove them. - -Run: `/usr/bin/grep -in "changelog" README.md` -Expected: no matches. - -Run: `wc -l README.md` -Expected: roughly 55–70 lines (the old README was ~80; the trim drops ~10). - -- [ ] **Step 3: Commit** - -```bash -git add README.md -git commit -m "$(cat <<'EOF' -docs(release): trim README to what 0.1.0 actually ships - -Removes the "Highlights" bullets for resilience middleware (Retry, -RetryBudget, Bulkhead, Timeout), the observability paragraph, and the -"first-class OpenTelemetry" line — none of those Epics have shipped. - -Adds an explicit unshipped-categories list to the status banner so users -know what's missing before they install. - -Replaces the bare CHANGELOG link with nothing (the file is gone; release -notes live on GitHub Releases). - -The "What ships in 0.1.0" section enumerates the shipped public surface: -AsyncClient, transport-agnostic seam, middleware foundation, -PydanticDecoder + MsgspecDecoder, RecordedTransport, status-keyed -exception hierarchy. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: Verify, push, PR, merge - -End-to-end sanity check before the PR opens. - -- [ ] **Step 1: Run the full test suite** - -Run: `just test` -Expected: 273 passed, 1 deselected (perf), 100% line coverage. (Unchanged from `main`; this PR touches no test code.) - -- [ ] **Step 2: Run lint** - -Run: `just lint-ci` -Expected: clean. - -- [ ] **Step 3: Confirm the working tree state** - -Run: `git status --short` -Expected: only the untracked plan file `planning/plans/2026-05-31-release-0.1.0-prep-plan.md`. - -Run: `git log --oneline main..HEAD` -Expected: four commits — spec (`docs(release): design for 0.1.0 release prep`), Task 1 (`chore(release): delete CHANGELOG.md…`), Task 2 (`ci(release): add publish.yml…`), Task 3 (`docs(release): trim README…`). - -Run: `git diff --stat main..HEAD` -Expected: `CHANGELOG.md` deleted, `CONTRIBUTING.md` modified (~1 line), `.github/workflows/publish.yml` added, `README.md` modified (~80 lines changed), plus the spec and plan files. - -- [ ] **Step 4: Stage and commit the plan file** - -```bash -git add planning/plans/2026-05-31-release-0.1.0-prep-plan.md -git commit -m "docs(release): implementation plan for 0.1.0 release prep - -Co-Authored-By: Claude Opus 4.7 (1M context) " -``` - -- [ ] **Step 5: Push the branch** - -Run: `git push -u origin chore/release-0.1.0-prep` -Expected: push succeeds. - -- [ ] **Step 6: Open the PR** - -```bash -gh pr create --title "chore: prep 0.1.0 release" --body "$(cat <<'EOF' -## Summary - -Repo-side prep for the `0.1.0` PyPI release. No production code or test changes. - -- **Deletes `CHANGELOG.md`.** Release notes live on GitHub Releases (one entry per tag), matching the modern-di convention in the same org. -- **Adds `.github/workflows/publish.yml`.** Mirrors modern-di's publish workflow byte-for-byte. Triggered by `release: published`; runs the existing `just publish` recipe which sets the version from `$GITHUB_REF_NAME`, builds, and uploads to PyPI with the `PYPI_TOKEN` secret. -- **Trims `README.md`.** Drops the "Highlights" bullets for unshipped features (Retry/RetryBudget/Bulkhead/Timeout/Observability/OpenTelemetry). Status banner explicitly names the unshipped categories so users know what's missing. Replaces the bare CHANGELOG link with the deleted-file convention. -- **Removes the CHANGELOG bullet from `CONTRIBUTING.md`.** - -`pyproject.toml`, `Justfile`, `ci.yml`, and the source tree are untouched. - -After this PR merges, the maintainer: -1. Verifies the PyPI name `httpware` is owned by `modern-python` (or free). -2. Verifies `PYPI_TOKEN` is present in repo secrets. -3. Creates a GitHub Release tagged `0.1.0`, which triggers `publish.yml` → `just publish` → PyPI. - -Spec + plan: `planning/specs/2026-05-31-release-0.1.0-prep-design.md`, `planning/plans/2026-05-31-release-0.1.0-prep-plan.md`. - -## Test plan - -- [x] No production code / test changes; `just test` shows 273 passed, 1 deselected, 100% line coverage (unchanged from `main`). -- [x] `just lint-ci` clean. -- [x] `tests/test_no_httpx2_leakage.py` still passes. -- [x] `grep -i changelog` returns no matches in any project file (except the spec/plan describing the deletion). -- [x] `python -c "import yaml; yaml.safe_load(open('.github/workflows/publish.yml'))"` — workflow file parses. -- [ ] CI green on all matrix entries. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -- [ ] **Step 7: Wait for CI** - -Run: `gh pr checks ` (the number is printed by `gh pr create`). -Expected: all checks green. Since this PR touches no test code, all `pytest (3.x)` jobs pass identically to `main`. The `lint` job runs against the unchanged source and passes. - -- [ ] **Step 8: Merge** - -Once CI is green: - -Run: `gh pr merge --merge --delete-branch` -Run: `git checkout main && git pull --ff-only && git log --oneline -3` - -The repo is now release-ready. Proceed to Task 5 — the manual maintainer-driven release sequence. - ---- - -## Task 5: Maintainer-driven post-merge release sequence - -These steps run outside the PR workflow. The maintainer executes them after `chore/release-0.1.0-prep` merges to `main`. **An agent cannot complete this task autonomously without explicit maintainer authorization** because it (a) creates a public PyPI release that is hard to retract and (b) may require PyPI-account decisions if the name is contested. - -- [ ] **Step 1: Verify PyPI name ownership** - -Run: -```bash -curl -sS https://pypi.org/pypi/httpware/json \ - | python -c "import sys, json; d=json.load(sys.stdin); info=d.get('info', {}); print('home_page:', info.get('home_page')); print('project_urls:', info.get('project_urls')); print('author:', info.get('author')); print('latest_version:', info.get('version'))" -``` - -Decision tree: -- Output shows `home_page` containing `github.com/modern-python/httpware` OR `project_urls` includes our repo URL → name is ours. Proceed. -- Output shows a different homepage / author → STOP. The name is owned by a third party. Options: (a) rename the package (edit `pyproject.toml` `name`, `Justfile publish` recipe, README); (b) open a PyPI name-transfer request (https://pypi.org/help/#name-claim). Do NOT proceed to Step 3. -- The `curl` returns a 404 → name is free. Proceed; the first publish will reserve it. - -- [ ] **Step 2: Verify `PYPI_TOKEN` secret exists** - -Run: `gh secret list --repo modern-python/httpware | grep PYPI_TOKEN` -Expected: a line like `PYPI_TOKEN Updated YYYY-MM-DD`. - -If absent: create a PyPI API token scoped to the `httpware` project at https://pypi.org/manage/account/token/, then add it: - -```bash -gh secret set PYPI_TOKEN --repo modern-python/httpware -# (paste the token when prompted) -``` - -- [ ] **Step 3: Create the GitHub Release** - -```bash -gh release create 0.1.0 \ - --repo modern-python/httpware \ - --title "0.1.0 — initial alpha" \ - --notes-file - <<'EOF' -Initial public alpha of `httpware`. - -**Includes:** -- AsyncClient with 8 HTTP method shortcuts and typed `response_model` overloads -- Transport-agnostic seam (`httpx2` confined to `Httpx2Transport`) -- Middleware foundation: `Middleware` protocol, `compose()`, phase decorators -- Pluggable response decoders: `PydanticDecoder` (default), `MsgspecDecoder` via `[msgspec]` extra -- `RecordedTransport` test double -- Status-keyed exception hierarchy - -**Not yet shipped (next releases):** -- Resilience middleware (retry, timeout, bulkhead) — Epic 3 -- Streaming (`AsyncClient.stream`) — Epic 4 -- Observability / OpenTelemetry — Epic 5 -- `auth=` parameter on AsyncClient — Story 2-4 -- `data=` / `files=` body params - -Public API is subject to change between minor releases until v1.0. -EOF -``` - -The release publication triggers `publish.yml`. The workflow runs `just publish`: -1. `rm -rf dist` -2. `uv version 0.1.0` (sets pyproject's `version` field from `$GITHUB_REF_NAME = "0.1.0"`). -3. `uv build` → produces `dist/httpware-0.1.0-py3-none-any.whl` and `dist/httpware-0.1.0.tar.gz`. -4. `uv publish --token $PYPI_TOKEN` → uploads to PyPI. - -- [ ] **Step 4: Watch the publish workflow run** - -Run: `gh run list --repo modern-python/httpware --workflow=publish.yml --limit 3` -Expected: a `queued` or `in_progress` run for the `0.1.0` tag. - -Run: `gh run watch --repo modern-python/httpware` (the ID is in the list output). -Expected: workflow completes with `conclusion: success`. - -If the workflow fails: -- **Build failure** (`uv build`): inspect the run log via `gh run view --log-failed --repo modern-python/httpware`. Common cause: a missing build artifact or dep declaration. The tag stays; iterate via a `0.1.1` patch. -- **Publish failure** (`uv publish`): likely `PYPI_TOKEN` invalid / scope mismatch / package already exists with a conflicting version. Re-run the workflow from the Actions tab once the underlying issue is fixed. - -- [ ] **Step 5: Smoke-check the published package** - -Run in a fresh venv: -```bash -mkdir /tmp/httpware-smoke && cd /tmp/httpware-smoke -uv venv && source .venv/bin/activate -pip install httpware==0.1.0 -python -c " -import httpware -from httpware import AsyncClient, Middleware, RecordedTransport, PydanticDecoder, before_request -print('OK:', httpware.__file__) -print('AsyncClient:', AsyncClient) -print('Middleware:', Middleware) -print('RecordedTransport:', RecordedTransport) -print('PydanticDecoder:', PydanticDecoder) -print('before_request:', before_request) -" -``` - -Expected: all imports succeed; `httpware.__file__` ends with `…/site-packages/httpware/__init__.py`. - -If a name import fails: the wheel is incomplete. Investigate via `pip show -f httpware` to see what landed. Fix on `main`, tag `0.1.1`, re-release. - -- [ ] **Step 6: (Optional, recommended) Confirm install with the `[msgspec]` extra** - -```bash -cd /tmp/httpware-smoke -deactivate && rm -rf .venv && uv venv && source .venv/bin/activate -pip install "httpware[msgspec]==0.1.0" -python -c "from httpware.decoders.msgspec import MsgspecDecoder; print('OK:', MsgspecDecoder)" -``` - -Expected: `OK: `. - -- [ ] **Step 7: Announce (optional)** - -The 0.1.0 release lands on PyPI. Communicate via the org's preferred channel (GitHub Discussions, README badge auto-updates, etc.). This step is purely social; no command to run. - ---- - -## Definition of done - -- `CHANGELOG.md` is gone from the repo. -- `.github/workflows/publish.yml` exists with the modern-di-pattern content. -- `README.md` is rewritten per the spec; no references to unshipped features (Retry/RetryBudget/Bulkhead/Timeout/Observability/OTel) as if they exist; status banner names what's missing. -- `CONTRIBUTING.md` has no `CHANGELOG.md` reference. -- `just test` shows 273 passed (unchanged); `just lint-ci` clean. -- `tests/test_no_httpx2_leakage.py` and `tests/test_optional_extras_isolation.py` still pass. -- PR `chore/release-0.1.0-prep` lands on `main` via merge. -- PyPI name verified as owned by `modern-python` (or free) by the maintainer. -- `PYPI_TOKEN` secret present on the repo. -- GitHub Release `0.1.0` created; `publish.yml` runs to completion; `pip install httpware==0.1.0` works in a clean venv and the public names import successfully. diff --git a/planning/changes/2026-06-01.01-auth-coercion/design.md b/planning/changes/2026-06-01.01-auth-coercion.md similarity index 100% rename from planning/changes/2026-06-01.01-auth-coercion/design.md rename to planning/changes/2026-06-01.01-auth-coercion.md diff --git a/planning/changes/2026-06-01.01-auth-coercion/plan.md b/planning/changes/2026-06-01.01-auth-coercion/plan.md deleted file mode 100644 index d41b281..0000000 --- a/planning/changes/2026-06-01.01-auth-coercion/plan.md +++ /dev/null @@ -1,1006 +0,0 @@ -# Auth coercion as middleware 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-4: add an `auth=` parameter to `AsyncClient.__init__` and `with_options` that coerces `str | Callable[[], str | Awaitable[str]] | Middleware | None` into a `Middleware` (or `None`), with auto-injection at the inner-most user-controllable position in the chain. - -**Architecture:** New private module `src/httpware/_internal/auth.py` (~70 lines) holds the coercion logic and bearer-middleware factories built atop `@before_request` (Story 2-2). `AsyncClient` tracks the user-supplied middleware list and the raw auth value separately so `with_options(...)` can recompose without losing either. `AuthValue` is the only new public name. - -**Tech Stack:** Python 3.11 floor. `inspect.signature` for the callable-vs-Middleware dispatch. `inspect.isawaitable` for sync-vs-async token providers. No new dependencies. - -**Branch:** `story/2-4-auth-coercion` (already created; spec commit `5906d22` is on it). - -**Spec:** `planning/specs/2026-06-01-auth-coercion-design.md`. - ---- - -## File Structure - -**New files:** -- `src/httpware/_internal/auth.py` — `AuthValue` alias, `_normalize_auth`, `_bearer`, `_bearer_from_provider`, `_has_authorization`. -- `tests/test_internal_auth.py` — 10 unit tests for `_normalize_auth` in isolation. - -**Modified files:** -- `src/httpware/client.py` — `__init__` gains `auth: AuthValue = None`; new private attrs `_user_middleware` and `_auth`; auth middleware appended at the END of the composed list. `with_options` accepts `auth=` and recomposes. `_from_view` accepts `user_middleware=` and `auth=` kwargs. -- `src/httpware/__init__.py` — re-export `AuthValue`; add to `__all__`. -- `tests/test_client_construction.py` — 3 tests for the new param. -- `tests/test_client_methods.py` — 3 tests for end-to-end Authorization header injection. -- `tests/test_client_middleware_wiring.py` — 3 tests for composition ordering and `with_options(auth=...)`. - -**Files NOT touched:** -- `pyproject.toml`, `Justfile`, `.github/workflows/`. -- `src/httpware/middleware/__init__.py` (`@before_request`, `Middleware`, `Next` are reused, not modified). -- `src/httpware/config.py` (`ClientConfig.middleware` already holds `tuple[Middleware, ...]`). -- `src/httpware/transports/*`, `src/httpware/decoders/*`, `src/httpware/errors.py`, `src/httpware/request.py`, `src/httpware/response.py`. - ---- - -## Task 1: `_internal/auth.py` module with 10 unit tests - -TDD cycle for the core coercion logic. Start with one happy-path test, implement the whole module, then add the remaining 9 tests. - -**Files:** -- Create: `src/httpware/_internal/auth.py` -- Create: `tests/test_internal_auth.py` - -- [ ] **Step 1: Add the first failing test (None returns None)** - -Create `tests/test_internal_auth.py`: - -```python -"""Unit tests for httpware._internal.auth._normalize_auth.""" - -import pytest - -from httpware._internal.auth import _normalize_auth -from httpware.middleware import Middleware, Next -from httpware.request import Request -from httpware.response import Response - - -def _make_request(headers: dict[str, str] | None = None) -> Request: - return Request(method="GET", url="/foo", headers=headers or {}) - - -def _ok_response() -> Response: - return Response(status=200, headers={}, content=b"", url="/foo", elapsed=0.0) - - -async def _identity_next(request: Request) -> Response: - return _ok_response() - - -def test_none_returns_none() -> None: - assert _normalize_auth(None) is None -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/test_internal_auth.py::test_none_returns_none -v` -Expected: `ModuleNotFoundError: No module named 'httpware._internal.auth'`. - -- [ ] **Step 3: Implement `src/httpware/_internal/auth.py`** - -Create `src/httpware/_internal/auth.py`: - -```python -"""Normalize the `auth=` value of AsyncClient into a Middleware (or None).""" - -import inspect -from collections.abc import Awaitable, Callable -from typing import TypeAlias - -from httpware.middleware import Middleware, before_request -from httpware.request import Request - - -AuthValue: TypeAlias = ( - str - | Callable[[], str | Awaitable[str]] - | Middleware - | None -) - - -def _normalize_auth(value: AuthValue) -> Middleware | None: - """Coerce an `auth=` value into a Middleware. - - - `None` → returns `None` (no auth middleware injected). - - `str` → returns a middleware that sets `Authorization: Bearer ` - on every request (skipping if Authorization is already present). - - `Callable[[], str | Awaitable[str]]` (zero-arg) → returns a middleware - that calls the provider per request (awaiting if it returns an - awaitable) and sets `Authorization: Bearer ` (skip-if-present). - - `Middleware` (two-arg `__call__(request, next)`) → returned unchanged. - - Any other callable shape → raises `TypeError` naming `auth=`. - """ - if value is None: - return None - if isinstance(value, str): - return _bearer(value) - if not callable(value): - msg = ( - "`auth=` must be a string, zero-arg callable, Middleware, or None; " - f"got {type(value).__name__}" - ) - raise TypeError(msg) - n_params = len(inspect.signature(value).parameters) - if n_params == 0: - return _bearer_from_provider(value) - if n_params == 2: - return value - msg = ( - "`auth=` callable must take 0 args (token provider) or 2 args " - f"(Middleware); got {n_params}" - ) - raise TypeError(msg) - - -def _bearer(token: str) -> Middleware: - """Middleware that sets `Authorization: Bearer ` (skip-if-present).""" - - @before_request - async def _add_static_bearer(request: Request) -> Request: - if _has_authorization(request): - return request - return request.with_header("Authorization", f"Bearer {token}") - - return _add_static_bearer - - -def _bearer_from_provider( - provider: Callable[[], str | Awaitable[str]], -) -> Middleware: - """Middleware that calls `provider()` per request and sets the header.""" - - @before_request - async def _add_dynamic_bearer(request: Request) -> Request: - if _has_authorization(request): - return request - token = provider() - if inspect.isawaitable(token): - token = await token - return request.with_header("Authorization", f"Bearer {token}") - - return _add_dynamic_bearer - - -def _has_authorization(request: Request) -> bool: - """Case-insensitive check for an existing Authorization header.""" - return any(k.lower() == "authorization" for k in request.headers) -``` - -No `__all__` (project convention). - -- [ ] **Step 4: Run the first test to verify it passes** - -Run: `uv run pytest tests/test_internal_auth.py::test_none_returns_none -v` -Expected: PASS. - -- [ ] **Step 5: Add the string-bearer happy-path tests** - -Append to `tests/test_internal_auth.py`: - -```python -async def test_string_returns_bearer_middleware() -> None: - mw = _normalize_auth("token") - assert mw is not None - - seen: list[Request] = [] - - async def _capture_next(request: Request) -> Response: - seen.append(request) - return _ok_response() - - await mw(_make_request(), _capture_next) - - assert seen[0].headers["Authorization"] == "Bearer token" - - -async def test_string_bearer_skips_if_authorization_already_present() -> None: - mw = _normalize_auth("ignored") - assert mw is not None - - seen: list[Request] = [] - - async def _capture_next(request: Request) -> Response: - seen.append(request) - return _ok_response() - - await mw(_make_request(headers={"Authorization": "Basic xyz"}), _capture_next) - - assert seen[0].headers["Authorization"] == "Basic xyz" -``` - -Run: `uv run pytest tests/test_internal_auth.py -v` -Expected: 3 passed. - -- [ ] **Step 6: Add the callable-provider tests** - -Append: - -```python -async def test_sync_callable_returns_token_provider_middleware() -> None: - mw = _normalize_auth(lambda: "sync-tok") - assert mw is not None - - seen: list[Request] = [] - - async def _capture_next(request: Request) -> Response: - seen.append(request) - return _ok_response() - - await mw(_make_request(), _capture_next) - - assert seen[0].headers["Authorization"] == "Bearer sync-tok" - - -async def test_async_callable_returns_token_provider_middleware() -> None: - async def _provider() -> str: - return "async-tok" - - mw = _normalize_auth(_provider) - assert mw is not None - - seen: list[Request] = [] - - async def _capture_next(request: Request) -> Response: - seen.append(request) - return _ok_response() - - await mw(_make_request(), _capture_next) - - assert seen[0].headers["Authorization"] == "Bearer async-tok" - - -async def test_callable_token_provider_skips_if_authorization_already_present() -> None: - calls = 0 - - def _provider() -> str: - nonlocal calls - calls += 1 - return "should-not-set" - - mw = _normalize_auth(_provider) - assert mw is not None - - seen: list[Request] = [] - - async def _capture_next(request: Request) -> Response: - seen.append(request) - return _ok_response() - - await mw(_make_request(headers={"authorization": "Basic existing"}), _capture_next) - - assert seen[0].headers["authorization"] == "Basic existing" - assert calls == 0 # skip-fired; provider not invoked - - -async def test_callable_token_provider_calls_provider_per_request() -> None: - calls = 0 - - def _provider() -> str: - nonlocal calls - calls += 1 - return f"tok-{calls}" - - mw = _normalize_auth(_provider) - assert mw is not None - - async def _ok_next(request: Request) -> Response: - return _ok_response() - - await mw(_make_request(), _ok_next) - await mw(_make_request(), _ok_next) - await mw(_make_request(), _ok_next) - - assert calls == 3 # noqa: PLR2004 -``` - -Run: `uv run pytest tests/test_internal_auth.py -v` -Expected: 7 passed. - -- [ ] **Step 7: Add the Middleware-passthrough test** - -Append: - -```python -async def test_middleware_returned_unchanged() -> None: - class _PassthroughMw: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - return await next(request) - - mw = _PassthroughMw() - assert _normalize_auth(mw) is mw -``` - -Run: `uv run pytest tests/test_internal_auth.py -v` -Expected: 8 passed. - -- [ ] **Step 8: Add the TypeError tests** - -Append: - -```python -def test_one_arg_callable_raises_typeerror() -> None: - with pytest.raises(TypeError, match=r"`auth=`.*0 args.*2 args.*1"): - _normalize_auth(lambda x: "tok") # noqa: ARG005 — intentional 1-arg callable - - -def test_non_callable_non_string_non_middleware_raises_typeerror() -> None: - with pytest.raises(TypeError, match=r"`auth=`.*string.*Middleware.*int"): - _normalize_auth(42) # ty: ignore[invalid-argument-type] -``` - -Run: `uv run pytest tests/test_internal_auth.py -v` -Expected: 10 passed. - -- [ ] **Step 9: Verify 100% coverage on the new module** - -Run: `uv run pytest tests/test_internal_auth.py --cov=src/httpware/_internal/auth --cov-report=term-missing` -Expected: 100% coverage on `_internal/auth.py`. - -- [ ] **Step 10: Lint and ty** - -Run: `uv run ruff check src/httpware/_internal/auth.py tests/test_internal_auth.py` -Run: `uv run ty check src/httpware/_internal/auth.py` -Expected: both clean. - -- [ ] **Step 11: Commit** - -```bash -git add src/httpware/_internal/auth.py tests/test_internal_auth.py -git commit -m "$(cat <<'EOF' -feat(story-2.4): _internal/auth.py with _normalize_auth coercion - -Adds src/httpware/_internal/auth.py with: -- AuthValue: TypeAlias = str | Callable[[], str | Awaitable[str]] | Middleware | None -- _normalize_auth(value): coerces into Middleware | None via signature-arity - dispatch (0 → token provider, 2 → Middleware passthrough, else TypeError) -- _bearer(token): static bearer middleware via @before_request -- _bearer_from_provider(provider): dynamic bearer middleware via @before_request - with sync/async detection via inspect.isawaitable -- _has_authorization(request): case-insensitive skip-check - -Ten tests cover: None passthrough, string→bearer happy path and -skip-if-present, sync and async callable providers, callable skip-if-present, -provider called per request (no caching), Middleware identity passthrough, -1-arg callable raises TypeError, non-callable raises TypeError. - -No __all__ (project convention). 100% line coverage on the new module. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: AsyncClient `__init__` integration - -Add `auth: AuthValue = None` to the constructor. Track `_user_middleware` and `_auth` separately. Compose user middleware + auth middleware at the END before `compose()`. - -**Files:** -- Modify: `src/httpware/client.py` (extend imports + `__init__` body) -- Modify: `tests/test_client_construction.py` (append 3 tests) - -- [ ] **Step 1: Add the failing tests** - -Append to `tests/test_client_construction.py`. The existing imports already include `AsyncClient`, `Limits`, `Timeout`, `RecordedTransport`; add `Middleware` if not present and add the auth-related imports: - -```python -from httpware._internal.auth import _normalize_auth - - -def test_init_no_auth_means_no_auth_middleware() -> None: - transport = RecordedTransport() - client = AsyncClient(transport=transport) - assert client._config.middleware == () - assert client._auth is None - assert client._user_middleware == () - - -def test_init_with_string_auth_appends_bearer_middleware() -> None: - transport = RecordedTransport() - client = AsyncClient(transport=transport, auth="tok") - assert len(client._config.middleware) == 1 - assert isinstance(client._config.middleware[0], Middleware) - assert client._auth == "tok" - assert client._user_middleware == () - - -def test_init_with_user_middleware_plus_auth() -> None: - class _M: - async def __call__(self, request, next): # noqa: A002, ANN001 - return await next(request) - - m1 = _M() - m2 = _M() - transport = RecordedTransport() - client = AsyncClient(transport=transport, middleware=[m1, m2], auth="tok") - assert len(client._config.middleware) == 3 - assert client._config.middleware[0] is m1 - assert client._config.middleware[1] is m2 - # The third entry is the auth middleware; identity-test that user_middleware excludes it. - assert client._user_middleware == (m1, m2) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_client_construction.py -k "auth" -v` -Expected: `TypeError: AsyncClient() got an unexpected keyword argument 'auth'`. - -- [ ] **Step 3: Extend imports in `src/httpware/client.py`** - -At the top of `src/httpware/client.py`, find the existing import block. Add: - -```python -from httpware._internal.auth import AuthValue, _normalize_auth -``` - -(Place in alphabetic order among the `httpware._internal.*` imports; ruff will reorder if needed.) - -- [ ] **Step 4: Add `auth=` to `__init__` and amend the body** - -Modify `AsyncClient.__init__`. Add the parameter at the end of the keyword-only list: - -```python -def __init__( - self, - *, - base_url: str | None = None, - default_headers: Mapping[str, str] | None = None, - default_query: Mapping[str, str] | None = None, - timeout: Timeout | float | None = None, - limits: Limits | None = None, - transport: Transport | None = None, - decoder: ResponseDecoder | None = None, - middleware: Sequence[Middleware] | None = None, - auth: AuthValue = None, -) -> None: -``` - -Replace the body (the current body resolves middleware then composes; we now interpose the auth coercion and append). The full replaced body: - -```python - normalized_timeout = _normalize_timeout(timeout) - resolved_limits = limits or Limits() - resolved_transport: Transport = transport or Httpx2Transport( - limits=resolved_limits, timeout=normalized_timeout - ) - resolved_decoder = decoder or PydanticDecoder() - resolved_user_middleware: tuple[Middleware, ...] = ( - tuple(middleware) if middleware is not None else () - ) - resolved_auth_middleware = _normalize_auth(auth) - composed_middleware: tuple[Middleware, ...] = ( - resolved_user_middleware - if resolved_auth_middleware is None - else (*resolved_user_middleware, resolved_auth_middleware) - ) - - self._config = ClientConfig( - base_url=base_url, - default_headers=dict(default_headers or {}), - default_query=dict(default_query or {}), - timeout=normalized_timeout, - limits=resolved_limits, - decoder=resolved_decoder, - middleware=composed_middleware, - ) - self._transport = resolved_transport - self._dispatch = compose(composed_middleware, resolved_transport) - self._owns_transport = True - self._user_middleware = resolved_user_middleware - self._auth = auth -``` - -- [ ] **Step 5: Run construction tests** - -Run: `uv run pytest tests/test_client_construction.py -v` -Expected: existing 11 tests + 3 new = 14 passed. - -- [ ] **Step 6: Lint and ty** - -Run: `uv run ruff check src/httpware/client.py tests/test_client_construction.py` -Run: `uv run ty check src/httpware/client.py` -Expected: both clean. - -- [ ] **Step 7: Commit** - -```bash -git add src/httpware/client.py tests/test_client_construction.py -git commit -m "$(cat <<'EOF' -feat(story-2.4): AsyncClient.__init__ accepts auth= and tracks _user_middleware/_auth - -Adds the `auth: AuthValue = None` keyword param. Coerces via -_normalize_auth and appends the resulting middleware (if any) at the END -of the user-supplied middleware list — so it runs just before the -transport. - -Introduces two new private attrs on AsyncClient: -- _user_middleware: the user's tuple, EXCLUDING the auth middleware -- _auth: the raw AuthValue (so with_options can recompose) - -ClientConfig.middleware continues to hold the COMPOSED list (what -actually runs), keeping the existing wiring contract. - -Three new construction tests verify: no auth → empty composed; string -auth → 1-element composed; user middleware + auth → 3-element composed -with user entries at positions 0,1 and auth at position 2. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: End-to-end Authorization header tests - -Three tests in `tests/test_client_methods.py` confirming the auth middleware fires through the full AsyncClient → transport path. No production code changes expected. - -**Files:** -- Modify: `tests/test_client_methods.py` (append 3 tests) - -- [ ] **Step 1: Add the tests** - -Append to `tests/test_client_methods.py`. The existing imports already include `AsyncClient`, `RecordedTransport`, `Request`, `Response`. Add `pytest` if not present (it is). - -```python -async def test_string_auth_sends_authorization_header() -> None: - transport = RecordedTransport( - default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0) - ) - client = AsyncClient(transport=transport, auth="tok") - - await client.get("/foo") - - assert transport.last_request is not None - assert transport.last_request.headers["Authorization"] == "Bearer tok" - - -async def test_per_call_authorization_header_wins_over_auth_param() -> None: - transport = RecordedTransport( - default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0) - ) - client = AsyncClient(transport=transport, auth="default-tok") - - await client.get("/foo", headers={"Authorization": "Bearer override"}) - - assert transport.last_request is not None - assert transport.last_request.headers["Authorization"] == "Bearer override" - - -async def test_callable_auth_calls_provider_per_request() -> None: - transport = RecordedTransport( - default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0) - ) - calls = 0 - - def _provider() -> str: - nonlocal calls - calls += 1 - return f"tok-{calls}" - - client = AsyncClient(transport=transport, auth=_provider) - - await client.get("/a") - await client.get("/b") - - assert calls == 2 # noqa: PLR2004 -``` - -- [ ] **Step 2: Run tests** - -Run: `uv run pytest tests/test_client_methods.py -k "auth" -v` -Expected: 3 passed. - -- [ ] **Step 3: Lint** - -Run: `uv run ruff check tests/test_client_methods.py` -Expected: clean. - -- [ ] **Step 4: Commit** - -```bash -git add tests/test_client_methods.py -git commit -m "$(cat <<'EOF' -test(story-2.4): end-to-end Authorization header injection via auth= - -Three tests confirm the auth middleware fires through the full -AsyncClient → transport path: -- string auth attaches Authorization: Bearer on the transport's - observed request -- per-call headers={"Authorization": ...} wins over the auth= param - (skip-if-present rule) -- callable auth invokes the provider once per AsyncClient.get(...) call - -No production code changes; Task 2 wired the integration. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: `with_options(auth=...)` + `_from_view` updates - -Add `auth` to the `with_options` keyword allowlist; recompose user middleware + auth into the new `ClientConfig.middleware`. Update `_from_view` to accept `user_middleware=` and `auth=` keyword params. - -**Files:** -- Modify: `src/httpware/client.py` (extend `with_options` + `_from_view`) -- Modify: `tests/test_client_middleware_wiring.py` (append 3 tests) - -- [ ] **Step 1: Add the failing tests** - -Append to `tests/test_client_middleware_wiring.py`. The existing imports already include `AsyncClient`, `RecordedTransport`, `Request`, `Response`, `Middleware`, `Next`. - -```python -async def test_auth_runs_inside_user_middleware() -> None: - transport = RecordedTransport( - default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0) - ) - - user_seen_headers: list[Mapping[str, str]] = [] # type: ignore[name-defined] - - class _UserOuter: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - user_seen_headers.append(dict(request.headers)) - return await next(request) - - client = AsyncClient(transport=transport, middleware=[_UserOuter()], auth="tok") - await client.get("/foo") - - # User middleware saw the request BEFORE auth header was applied. - assert "Authorization" not in user_seen_headers[0] - # Transport saw the request WITH the auth header. - assert transport.last_request is not None - assert transport.last_request.headers["Authorization"] == "Bearer tok" - - -async def test_with_options_auth_replaces_auth_middleware() -> None: - transport = RecordedTransport( - default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0) - ) - client = AsyncClient(transport=transport, auth="parent") - view = client.with_options(auth="view") - - await view.get("/foo") - assert transport.last_request is not None - assert transport.last_request.headers["Authorization"] == "Bearer view" - - await client.get("/foo") - assert transport.last_request is not None - assert transport.last_request.headers["Authorization"] == "Bearer parent" - - -async def test_with_options_middleware_keeps_existing_auth() -> None: - transport = RecordedTransport( - default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0) - ) - - class _M: - async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002 - return await next(request) - - m1 = _M() - m2 = _M() - client = AsyncClient(transport=transport, auth="tok", middleware=[m1]) - view = client.with_options(middleware=[m2]) - - await view.get("/foo") - assert transport.last_request is not None - assert transport.last_request.headers["Authorization"] == "Bearer tok" -``` - -The first test references `Mapping` — add to imports if not already present at the top of the file: - -```python -from collections.abc import Mapping -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_client_middleware_wiring.py -k "auth or with_options" -v` -Expected: the `auth_runs_inside_user_middleware` test passes (uses Task 2's `__init__`); the two `with_options` tests fail with `TypeError: with_options() got an unexpected keyword argument 'auth'`. - -- [ ] **Step 3: Update `with_options` in `src/httpware/client.py`** - -Find the `with_options` method (currently at ~line 576). Add `auth: AuthValue | object = _UNSET` to the keyword-only list and amend the body. Full replaced method: - -```python -def with_options( - self, - *, - base_url: str | None = _UNSET, - default_headers: Mapping[str, str] | None = _UNSET, - default_query: Mapping[str, str] | None = _UNSET, - timeout: Timeout | float | None = _UNSET, - decoder: ResponseDecoder | None = _UNSET, - middleware: Sequence[Middleware] | None = _UNSET, - auth: AuthValue | object = _UNSET, -) -> "AsyncClient": - """Return a new AsyncClient sharing the same transport with overridden config. - - The returned client is a "view": it does NOT own the transport lifecycle. - Closing it via `async with` is a no-op. The original client should be the - one inside the outermost `async with` block. - - `limits` and `transport` are NOT overridable here — both bind to the - transport, which is shared. Construct a fresh AsyncClient for those. - """ - changes: dict[str, typing.Any] = {} - if base_url is not _UNSET: - changes["base_url"] = base_url - if default_headers is not _UNSET: - changes["default_headers"] = dict(default_headers or {}) - if default_query is not _UNSET: - changes["default_query"] = dict(default_query or {}) - if timeout is not _UNSET: - changes["timeout"] = _normalize_timeout(timeout) - if decoder is not _UNSET: - changes["decoder"] = decoder or PydanticDecoder() - - new_user_middleware = self._user_middleware - if middleware is not _UNSET: - new_user_middleware = tuple(middleware) if middleware is not None else () - - new_auth = self._auth - if auth is not _UNSET: - new_auth = auth - - new_auth_middleware = _normalize_auth(new_auth) - new_composed: tuple[Middleware, ...] = ( - new_user_middleware - if new_auth_middleware is None - else (*new_user_middleware, new_auth_middleware) - ) - changes["middleware"] = new_composed - - new_config = dataclasses.replace(self._config, **changes) - return AsyncClient._from_view( - new_config, - self._transport, - user_middleware=new_user_middleware, - auth=new_auth, - ) -``` - -- [ ] **Step 4: Update `_from_view` in `src/httpware/client.py`** - -Find `_from_view` (immediately below `with_options`). Replace it with: - -```python -@classmethod -def _from_view( - cls, - config: ClientConfig, - transport: Transport, - *, - user_middleware: tuple[Middleware, ...], - auth: AuthValue, -) -> "AsyncClient": - """Construct a view sharing an existing transport. Bypasses __init__.""" - client = cls.__new__(cls) - client._config = config - client._transport = transport - client._dispatch = compose(config.middleware, transport) - client._owns_transport = False - client._user_middleware = user_middleware - client._auth = auth - return client -``` - -- [ ] **Step 5: Run middleware-wiring tests** - -Run: `uv run pytest tests/test_client_middleware_wiring.py -v` -Expected: all tests pass (previously 13 + 3 new = 16 passed; verify the count matches what the file actually contained before; the absolute number doesn't matter — both new tests pass and no old test breaks). - -- [ ] **Step 6: Run all existing tests to confirm no regression** - -Run: `just test` -Expected: 273 (baseline post-1.8) + 10 (Task 1) + 3 (Task 2) + 3 (Task 3) + 3 (Task 4) = 292 passed at this point, 1 deselected. (Task 5 adds one more reexport test for a final total of 293.) - -- [ ] **Step 7: Lint and ty** - -Run: `uv run ruff check src/httpware/client.py tests/test_client_middleware_wiring.py` -Run: `uv run ty check src/httpware/client.py` -Expected: both clean. - -- [ ] **Step 8: Commit** - -```bash -git add src/httpware/client.py tests/test_client_middleware_wiring.py -git commit -m "$(cat <<'EOF' -feat(story-2.4): with_options(auth=) + _from_view tracks user_middleware/auth - -with_options now accepts `auth: AuthValue | object = _UNSET`. View -construction recomposes the chain from the new (or inherited) user -middleware + auth value: `with_options(middleware=...)` preserves auth; -`with_options(auth=...)` preserves user middleware; both can change -together; neither change loses the auth/middleware that wasn't passed. - -_from_view gains `user_middleware=` and `auth=` keyword params so the -view client inherits the raw inputs (not just the composed result) and -its own with_options call works correctly. - -Three new wiring tests cover: auth runs INSIDE user middleware (user -sees request before auth header; transport sees it after); with_options -replaces auth in the view while preserving the parent; with_options -replacing middleware preserves auth. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 5: Public exports + reexport test - -Re-export `AuthValue` at the package root and add to `__all__`. - -**Files:** -- Modify: `src/httpware/__init__.py` -- Modify: `tests/test_internal_auth.py` (append reexport test) - -- [ ] **Step 1: Add the failing reexport test** - -Append to `tests/test_internal_auth.py`: - -```python -def test_auth_value_reexported_at_package_root() -> None: - """`from httpware import AuthValue` works.""" - import httpware - from httpware._internal.auth import AuthValue as InternalAuthValue - - assert httpware.AuthValue is InternalAuthValue - assert "AuthValue" in httpware.__all__ -``` - -Move the `import httpware` to the top of the file (project preference: no in-function imports). Add at the top alongside the other imports. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/test_internal_auth.py::test_auth_value_reexported_at_package_root -v` -Expected: `AttributeError: module 'httpware' has no attribute 'AuthValue'`. - -- [ ] **Step 3: Update `src/httpware/__init__.py`** - -Find the existing `from httpware._internal.chain import compose` line (or wherever `_internal.*` imports live). Add: - -```python -from httpware._internal.auth import AuthValue -``` - -In `__all__`, add `"AuthValue"`. The list is sorted by ruff's `RUF022` (ASCII order); `"AuthValue"` sits between `"AsyncClient"` and `"BadRequestError"`. If unsure, add anywhere and run `uv run ruff check --fix src/httpware/__init__.py`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run pytest tests/test_internal_auth.py -v` -Expected: 11 passed (10 + 1 reexport). - -- [ ] **Step 5: Lint and ty** - -Run: `uv run ruff check src/httpware/__init__.py tests/test_internal_auth.py` -Run: `uv run ty check src/httpware/__init__.py` -Expected: both clean. - -- [ ] **Step 6: Commit** - -```bash -git add src/httpware/__init__.py tests/test_internal_auth.py -git commit -m "$(cat <<'EOF' -feat(story-2.4): re-export AuthValue at httpware package root - -Adds AuthValue to httpware/__init__.py imports and __all__ so consumers -can `from httpware import AuthValue` to type-annotate their config (e.g., -a settings class that holds an auth value). - -The bearer helpers and _normalize_auth remain internal at -httpware._internal.auth and are NOT exported. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 6: Verify, push, PR, merge - -- [ ] **Step 1: Run the full test suite** - -Run: `just test` -Expected: 293 passed, 1 deselected (perf), 100% line coverage including the new `_internal/auth.py`. - -- [ ] **Step 2: Run 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** - -Run: `git status --short` -Expected: only the untracked plan file `planning/plans/2026-06-01-auth-coercion-plan.md`. - -Run: `git log --oneline main..HEAD` -Expected: six commits — spec, Task 1, Task 2, Task 3, Task 4, Task 5. - -- [ ] **Step 4: Stage and commit the plan** - -```bash -git add planning/plans/2026-06-01-auth-coercion-plan.md -git commit -m "docs(story-2.4): implementation plan for auth coercion - -Co-Authored-By: Claude Opus 4.7 (1M context) " -``` - -- [ ] **Step 5: Push the branch** - -Run: `git push -u origin story/2-4-auth-coercion` -Expected: push succeeds. - -- [ ] **Step 6: Open the PR** - -```bash -gh pr create --title "feat(story-2.4): auth coercion as middleware" --body "$(cat <<'EOF' -## Summary - -- Adds an \`auth=\` parameter to \`AsyncClient.__init__\` and \`with_options\` accepting \`str | Callable[[], str | Awaitable[str]] | Middleware | None\`. \`AuthValue\` type alias re-exported at the package root. -- Coercion lives in \`src/httpware/_internal/auth.py\`. Dispatch via \`inspect.signature\` arity: 0 → token provider, 2 → Middleware passthrough, else \`TypeError\`. Bearer helpers built atop \`@before_request\` (free \`\` repr; token never leaks). -- Skip-if-present rule: auth middleware leaves an existing \`Authorization\` header untouched (user middleware / per-call header wins). Provider not invoked when skip fires. -- Auth middleware appended at the END of the user-supplied list — runs just before the transport. User middleware sees Request WITHOUT auth header on the way down; sees Response on the way up. -- \`with_options(auth=...)\` supported; AsyncClient tracks \`_user_middleware\` and \`_auth\` separately so \`with_options(middleware=...)\` preserves the existing auth, and vice versa. -- 20 new tests (10 in \`test_internal_auth.py\` + 3 construction + 3 methods + 3 wiring); 100% line coverage on the new source. - -**This closes Epic 2.** - -Out of scope: OAuth2 / refresh tokens, mTLS, signature schemes (HMAC, AWS Sigv4), per-call \`auth=\` override on HTTP methods. - -Spec + plan: \`planning/specs/2026-06-01-auth-coercion-design.md\`, \`planning/plans/2026-06-01-auth-coercion-plan.md\`. - -## Test plan - -- [x] \`just test\` — 293 passed, 1 deselected, 100% line coverage on the new module. -- [x] \`just lint-ci\` clean. -- [x] \`tests/test_no_httpx2_leakage.py\` still passes. -- [x] \`tests/test_optional_extras_isolation.py\` still passes. -- [ ] 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 7: Wait for CI** - -Run: `gh pr checks `. -Expected: all five jobs green. - -If `pytest (3.14)` fails on the `codecov/codecov-action@v4.0.1` step with EPIPE (the transient pattern this repo has hit before), re-run with `gh run rerun --failed`. - -- [ ] **Step 8: Merge** - -Once CI is green: - -Run: `gh pr merge --merge --delete-branch` -Run: `git checkout main && git pull --ff-only && git log --oneline -3` - -Story 2-4 is complete. **Epic 2 is complete.** The next normal-flow roadmap item is Epic 3 (resilience: retry, RetryBudget, bulkhead). - ---- - -## Definition of done - -- `src/httpware/_internal/auth.py` exists with `AuthValue`, `_normalize_auth`, `_bearer`, `_bearer_from_provider`, `_has_authorization`. No `__all__`. -- `src/httpware/client.py` `__init__` accepts `auth: AuthValue = None`; private attrs `_user_middleware` and `_auth` set. Auth middleware appended at the end of the composed list. -- `src/httpware/client.py` `with_options` accepts `auth=` and recomposes; `_from_view` accepts `user_middleware=` and `auth=` kwargs. -- `src/httpware/__init__.py` re-exports `AuthValue` and adds it to `__all__`. -- 20 new tests pass; existing 273 tests still pass. -- `just test` shows 293 passed, 1 deselected, 100% line coverage on the new source. -- `just lint-ci` clean. -- `tests/test_no_httpx2_leakage.py` and `tests/test_optional_extras_isolation.py` still pass. -- Story 2-4 lands as a single PR off `main` via the branch `story/2-4-auth-coercion`. After this merge, Epic 2 is fully complete. diff --git a/planning/changes/2026-06-02.01-docs-reorg-and-mkdocs/design.md b/planning/changes/2026-06-02.01-docs-reorg-and-mkdocs.md similarity index 100% rename from planning/changes/2026-06-02.01-docs-reorg-and-mkdocs/design.md rename to planning/changes/2026-06-02.01-docs-reorg-and-mkdocs.md diff --git a/planning/changes/2026-06-02.01-docs-reorg-and-mkdocs/plan.md b/planning/changes/2026-06-02.01-docs-reorg-and-mkdocs/plan.md deleted file mode 100644 index f430dff..0000000 --- a/planning/changes/2026-06-02.01-docs-reorg-and-mkdocs/plan.md +++ /dev/null @@ -1,779 +0,0 @@ -# Docs reorg + minimal mkdocs site 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:** Delete `docs/archive/`, move agent/contributor workflow artifacts to a tool-neutral `planning/` directory at the repo root, and stand up a minimal mkdocs site published via Read the Docs — without breaking any internal markdown link, the CI lint/test pipeline, or git history for moved files. - -**Architecture:** Pure structural reorganization. Eleven atomic-commit tasks executed in dependency order: (1) preserve the one load-bearing archive citation by inlining it into engineering.md, (2) move workflow artifacts via `git mv` so history follows, (3) move engineering docs into a `docs/dev/` subtree that the mkdocs site will publish, (4) bulk-update path references across CLAUDE.md and every existing spec/plan, (5) refactor README.md to align with the modern-python org pattern, (6) add the mkdocs/RTD config files, (7) delete the archive, (8) verify everything builds and lints. No source code touched. No CI grep invariants change. The `src/` layout is unchanged. - -**Tech Stack:** `mkdocs` + `mkdocs-material` (Read the Docs published), `uv`, `just`, `git mv` for history preservation. - ---- - -## Pre-flight - -Plan assumes you are on a clean working tree at the spec's current commit (`a2abca4` or descendant). Verify before starting: - -```bash -git status # should be clean -git rev-parse HEAD # record starting commit for sanity -``` - -The spec lives at `docs/superpowers/specs/2026-06-02-docs-reorg-and-mkdocs-design.md` — read it once if you haven't. - ---- - -### Task 1: Inline the load-bearing decoder rationale into engineering.md - -**Goal:** Before `archive/` is deleted, port the one rationale that `engineering.md` externalizes — the "two-pass decoding is rejected" reasoning at Seam 3 — into `engineering.md` itself. This must happen first so nothing is lost when archive is removed in Task 10. - -**Files:** -- Modify: `docs/engineering.md` line 40 (Seam 3 "Rule" bullet) - -**Reference (source of inlined text):** `docs/archive/architecture.md` lines 270–283 ("Decision 8 — ResponseDecoder protocol"). - -- [ ] **Step 1: Read the archive section once** - -Run: `sed -n '270,283p' docs/archive/architecture.md` - -Confirm you see the "ResponseDecoder protocol" decision: single parse pass (NFR3), `TypeAdapter.validate_json` with `lru_cache` (NFR2), `msgspec.json.decode` for the extras adapter. - -- [ ] **Step 2: Edit engineering.md line 40** - -Replace this exact line in `docs/engineering.md`: - -```markdown -- **Rule:** the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (`json.loads` then `validate_python`) is rejected — see `archive/architecture.md` Validation & Decoding for rationale. -``` - -With: - -```markdown -- **Rule:** the decoder must operate on raw bytes in a single parse pass. Two-pass decoding (`json.loads` then `validate_python`) is rejected: a single bytes-in / typed-object-out pass avoids the redundant intermediate `dict` allocation and parses faster. The Pydantic adapter implements this as `TypeAdapter(model).validate_json(content)` with `@functools.lru_cache(maxsize=None)` on `TypeAdapter` construction (the adapter object is the expensive part to build, keyed by `model`). The msgspec adapter implements it as `msgspec.json.decode(content, type=model)`. -``` - -- [ ] **Step 3: Confirm no other archive citation is load-bearing** - -Run: `grep -n "archive/" docs/engineering.md` - -Expected output (exactly three remaining lines): -``` -3:This doc is the single distilled reference for `httpware` design rationale, protocol seams, and remaining roadmap. ... -134:Twenty-seven stories remain. Topic slugs in `docs/superpowers/specs/` and `docs/superpowers/plans/` use kebab-case descriptions, not the story IDs — these IDs are kept here only as a stable mapping to the archived epic specs (`archive/epics.md`). -181:When work starts on a roadmap item, it gets a superpowers spec at `docs/superpowers/specs/YYYY-MM-DD--design.md` and a plan at `docs/superpowers/plans/YYYY-MM-DD--plan.md`. The bmad-era 40KB story specs in `archive/stories/` cover 1-1 through 1-5 and are retired going forward. -``` - -These three remaining references are *soft* — Task 2 strips them. Line 40 no longer mentions archive: verify with `grep -n "archive/architecture.md" docs/engineering.md` returning **no output**. - -- [ ] **Step 4: Commit** - -```bash -git add docs/engineering.md -git commit -m "docs: inline decoder rationale before archive deletion" -``` - ---- - -### Task 2: Strip remaining soft archive references from engineering.md - -**Goal:** Remove the three remaining `archive/` mentions (lines 3, 134, 181) and any pointer that will be broken when archive is deleted. These are informational, not load-bearing; they just get deleted/rephrased. - -**Files:** -- Modify: `docs/engineering.md` (three edits) - -- [ ] **Step 1: Edit line 3 — drop the archive pointer sentence** - -Replace this exact line in `docs/engineering.md`: - -```markdown -This doc is the single distilled reference for `httpware` design rationale, protocol seams, and remaining roadmap. It complements [`../CLAUDE.md`](../CLAUDE.md): `CLAUDE.md` holds AI-enforced invariants and operational commands; this file holds the reasoning and the structural map. Historical planning artifacts live in [`archive/`](./archive/) and are cited only for original rationale. -``` - -With: - -```markdown -This doc is the single distilled reference for `httpware` design rationale, protocol seams, and remaining roadmap. It complements [`../CLAUDE.md`](../CLAUDE.md): `CLAUDE.md` holds AI-enforced invariants and operational commands; this file holds the reasoning and the structural map. -``` - -- [ ] **Step 2: Edit line 134 — drop the archived-epics parenthetical** - -Replace this exact line in `docs/engineering.md`: - -```markdown -Twenty-seven stories remain. Topic slugs in `docs/superpowers/specs/` and `docs/superpowers/plans/` use kebab-case descriptions, not the story IDs — these IDs are kept here only as a stable mapping to the archived epic specs (`archive/epics.md`). -``` - -With: - -```markdown -Twenty-seven stories remain. Topic slugs in `docs/superpowers/specs/` and `docs/superpowers/plans/` use kebab-case descriptions, not the story IDs — these IDs are retained as a stable identifier convention from the original epic structure. -``` - -(Note: this line still references `docs/superpowers/...` — those references get rewritten in Task 7.) - -- [ ] **Step 3: Edit line 181 — drop the retired-stories sentence** - -Replace this exact line in `docs/engineering.md`: - -```markdown -When work starts on a roadmap item, it gets a superpowers spec at `docs/superpowers/specs/YYYY-MM-DD--design.md` and a plan at `docs/superpowers/plans/YYYY-MM-DD--plan.md`. The bmad-era 40KB story specs in `archive/stories/` cover 1-1 through 1-5 and are retired going forward. -``` - -With: - -```markdown -When work starts on a roadmap item, it gets a spec at `docs/superpowers/specs/YYYY-MM-DD--design.md` and a plan at `docs/superpowers/plans/YYYY-MM-DD--plan.md`. -``` - -(The `docs/superpowers/` paths here also get rewritten in Task 7.) - -- [ ] **Step 4: Verify no archive references remain** - -Run: `grep -n "archive/" docs/engineering.md` - -Expected output: **(nothing)** — all archive citations are gone from engineering.md. - -- [ ] **Step 5: Commit** - -```bash -git add docs/engineering.md -git commit -m "docs: drop soft archive references from engineering.md" -``` - ---- - -### Task 3: Move workflow artifacts to planning/ (preserves git history) - -**Goal:** Relocate the `docs/superpowers/` subtree to a tool-neutral `planning/` directory at the repository root, and move `docs/deferred-work.md` alongside. Use `git mv` so history follows. This is a pure rename — no content changes. - -**Files:** -- Move: `docs/superpowers/specs/` → `planning/specs/` -- Move: `docs/superpowers/plans/` → `planning/plans/` -- Move: `docs/deferred-work.md` → `planning/deferred-work.md` -- Delete: `docs/superpowers/` (empty after the moves) - -- [ ] **Step 1: Create the parent planning/ directory** - -```bash -mkdir -p planning -``` - -- [ ] **Step 2: Move the specs and plans subdirectories** - -```bash -git mv docs/superpowers/specs planning/specs -git mv docs/superpowers/plans planning/plans -``` - -- [ ] **Step 3: Move deferred-work.md** - -```bash -git mv docs/deferred-work.md planning/deferred-work.md -``` - -- [ ] **Step 4: Remove the now-empty docs/superpowers/ directory** - -```bash -rmdir docs/superpowers -``` - -If `rmdir` complains the directory is not empty, list the contents (`ls -la docs/superpowers/`) and resolve before proceeding. - -- [ ] **Step 5: Verify history is preserved** - -```bash -git log --follow --oneline -n 3 planning/specs/2026-06-01-auth-coercion-design.md -``` - -Expected: shows commits from before the rename. If git shows only one commit, the move did not preserve history — abort and investigate. - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "docs: rename docs/superpowers/ to planning/ (history-preserving)" -``` - ---- - -### Task 4: Move engineering.md and CONTRIBUTING.md into docs/dev/ - -**Goal:** Establish the `docs/dev/` subtree that the mkdocs site will publish under "Development". Move `docs/engineering.md` and the root `CONTRIBUTING.md` into it. - -**Files:** -- Move: `docs/engineering.md` → `docs/dev/engineering.md` -- Move: `CONTRIBUTING.md` (root) → `docs/dev/contributing.md` -- Create directory: `docs/dev/` - -- [ ] **Step 1: Create docs/dev/** - -```bash -mkdir -p docs/dev -``` - -- [ ] **Step 2: Move engineering.md** - -```bash -git mv docs/engineering.md docs/dev/engineering.md -``` - -- [ ] **Step 3: Move root CONTRIBUTING.md** - -```bash -git mv CONTRIBUTING.md docs/dev/contributing.md -``` - -- [ ] **Step 4: Verify history is preserved** - -```bash -git log --follow --oneline -n 3 docs/dev/engineering.md -``` - -Expected: shows multiple historical commits (the file has been edited several times). - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "docs: move engineering.md and CONTRIBUTING.md into docs/dev/" -``` - ---- - -### Task 5: Create thin root CONTRIBUTING.md stub - -**Goal:** Replace the moved-out root `CONTRIBUTING.md` with a tiny stub that points to the published docs URL and the in-repo source path. Preserves GitHub's "open a PR" UI integration (which surfaces a root `CONTRIBUTING.md` to PR authors). - -**Files:** -- Create: `CONTRIBUTING.md` (at repo root) - -- [ ] **Step 1: Write the stub** - -Create `CONTRIBUTING.md` with exactly this content: - -```markdown -# Contributing - -The contributing guide is published as part of the project documentation: -**https://httpware.readthedocs.io/en/latest/dev/contributing/** - -Source: [`docs/dev/contributing.md`](docs/dev/contributing.md). -``` - -- [ ] **Step 2: Commit** - -```bash -git add CONTRIBUTING.md -git commit -m "docs: add thin root CONTRIBUTING.md stub pointing to published guide" -``` - ---- - -### Task 6: Bulk-update path references across CLAUDE.md and the planning tree - -**Goal:** Every file that referenced `docs/superpowers/...`, `docs/engineering.md`, `docs/deferred-work.md`, or `docs/archive/...` is now pointing at paths that have moved (or will not exist post-Task 10). This task mechanically rewrites the safe-to-transform references and manually fixes the context-sensitive ones (archive removals). - -**Files to update (21 total):** -- `CLAUDE.md` (root) -- `docs/dev/engineering.md` -- `planning/specs/2026-05-31-*.md` (9 files) -- `planning/specs/2026-06-01-auth-coercion-design.md` -- `planning/specs/2026-06-02-docs-reorg-and-mkdocs-design.md` (self-reference — see Step 6 below) -- `planning/plans/2026-05-31-*.md` (8 files) -- `planning/plans/2026-06-01-auth-coercion-plan.md` - -Note: `README.md` does NOT contain these old paths; it's refactored separately in Task 7. - -- [ ] **Step 1: Mechanical replacements across CLAUDE.md and planning/** - -These four substitutions are safe to apply via `sed` because they always mean the same thing in every context: - -**Important:** this plan and the spec live inside `planning/` and *intentionally* contain old-path references (in `git mv` commands and narrative). They are excluded from the rewrite. - -```bash -# macOS sed uses -i '' (BSD); replace with -i (GNU) on Linux if needed. -SED_INPLACE=(-i '') - -find CLAUDE.md planning/ docs/dev/ -type f -name '*.md' \ - ! -name '2026-06-02-docs-reorg-and-mkdocs-design.md' \ - ! -name '2026-06-02-docs-reorg-and-mkdocs-plan.md' \ - -print0 | - xargs -0 sed "${SED_INPLACE[@]}" \ - -e 's|docs/superpowers/specs/|planning/specs/|g' \ - -e 's|docs/superpowers/plans/|planning/plans/|g' \ - -e 's|docs/engineering\.md|docs/dev/engineering.md|g' \ - -e 's|docs/deferred-work\.md|planning/deferred-work.md|g' -``` - -If you are on Linux: change `SED_INPLACE=(-i '')` to `SED_INPLACE=(-i)`. - -- [ ] **Step 2: Verify mechanical replacements landed** - -Run: -```bash -grep -rn "docs/superpowers/\|docs/engineering\.md\|docs/deferred-work\.md" CLAUDE.md planning/ docs/dev/ \ - | grep -v "2026-06-02-docs-reorg-and-mkdocs-design.md" \ - | grep -v "2026-06-02-docs-reorg-and-mkdocs-plan.md" -``` - -Expected output: **(nothing)**. The only files that legitimately still contain the old paths are this plan and its spec — both excluded from the find pass above and from this grep. - -If hits appear in any other file, investigate and fix manually before continuing. - -- [ ] **Step 3: Update CLAUDE.md archive references (context-sensitive — manual)** - -Edit `CLAUDE.md`. Two distinct edits. - -Edit A — delete the archive bullet from "Where to find what" section. Replace this exact line: - -```markdown -- [`docs/archive/`](docs/archive/) — historical bmad-era planning bundle (PRD, architecture, epics, product briefs, per-story specs for 1-1 through 1-5). Consult only for original rationale or specific FR/NFR citations. -``` - -With: **(delete entirely — no replacement)** - -Edit B — strip the archive trailer from the "When in doubt" bullet. Replace this exact line: - -```markdown -- Check [`docs/engineering.md`](docs/engineering.md) before adding a new module or extension point; `docs/archive/architecture.md` has the deeper historical rationale if needed. -``` - -With (note: the `docs/engineering.md` → `docs/dev/engineering.md` part was already handled by Step 1's sed, so the line currently reads `Check [\`docs/dev/engineering.md\`](docs/dev/engineering.md) before adding a new module or extension point; \`docs/archive/architecture.md\` has the deeper historical rationale if needed.` — the edit removes only the trailing semicolon-clause): - -```markdown -- Check [`docs/dev/engineering.md`](docs/dev/engineering.md) before adding a new module or extension point. -``` - -- [ ] **Step 4: Sweep for any remaining archive references** - -Run: `grep -rn "docs/archive\|archive/" CLAUDE.md planning/ docs/dev/` - -Expected hits are only inside `planning/specs/2026-06-02-docs-reorg-and-mkdocs-design.md` (the spec narrates the deletion). Anywhere else, investigate. - -- [ ] **Step 5: Sweep for any remaining references to the root CONTRIBUTING.md (which is now a stub)** - -References to `CONTRIBUTING.md` from within planning/ or docs/dev/ should point at `docs/dev/contributing.md`. Currently there are none (verify): - -Run: `grep -rn "CONTRIBUTING\.md" planning/ docs/dev/ CLAUDE.md` - -Expected: no hits (the contributing doc isn't cross-referenced from other files). If hits appear, replace each with `docs/dev/contributing.md` as appropriate to the link context. - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "docs: update path references for docs reorg" -``` - ---- - -### Task 7: Refactor README.md - -**Goal:** Slim README to align with the modern-python org pattern (project intent, install, runnable snippet, links to docs site / PyPI / license, org positioning). The current README is good content but lacks the docs-site link and has a "What ships in 0.1.0" section that duplicates engineering notes. Trim it. - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Replace README.md entirely** - -Overwrite `README.md` with this content: - -````markdown -# httpware - -[![Test](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml) -[![PyPI version](https://badge.fury.io/py/httpware.svg)](https://pypi.org/project/httpware/) -[![Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -**Async HTTP client framework for Python.** - -`httpware` is a typed, async HTTP client library with a protocol-based seam so the transport is swappable (`httpx2` ships as the default). Middleware composes via an onion model. Pydantic and msgspec response decoding ship out of the box. `RecordedTransport` replaces `respx` for transport-level tests. - -> **Status:** Pre-1.0 (0.1.0 alpha). Public API is subject to change between minor releases until v1.0. Resilience middleware (retry / timeout / bulkhead), streaming, and observability are not yet shipped. - -## Install - -```bash -pip install httpware -``` - -Optional extras: - -```bash -pip install httpware[msgspec] # MsgspecDecoder -``` - -(`otel`, `niquests`, and `all` extras are declared; integrations have not shipped yet.) - -## Quickstart - -```python -from httpware import AsyncClient -from pydantic import BaseModel - - -class User(BaseModel): - id: int - name: str - - -async def main() -> None: - async with AsyncClient(base_url="https://api.example.com") as client: - user = await client.get("/users/1", response_model=User) - print(user.name) -``` - -## 📚 [Documentation](https://httpware.readthedocs.io) - -## 📦 [PyPI](https://pypi.org/project/httpware) - -## 📝 [License](./LICENSE) - -## Part of `modern-python` - -Browse the full list of templates and libraries in [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index. -```` - -(The "What ships in 0.1.0" section is removed — that level of detail belongs on the docs site, not the README. The badges and the runnable Quickstart stay.) - -- [ ] **Step 2: Commit** - -```bash -git add README.md -git commit -m "docs: refactor README to link to published docs site" -``` - ---- - -### Task 8: Add mkdocs.yml, .readthedocs.yaml, docs/requirements.txt, docs/index.md - -**Goal:** Stand up the minimal mkdocs site. Four new files. After this task, `mkdocs build --strict` should succeed against the new structure. - -**Files:** -- Create: `mkdocs.yml` -- Create: `.readthedocs.yaml` -- Create: `docs/requirements.txt` -- Create: `docs/index.md` - -- [ ] **Step 1: Create mkdocs.yml** - -Create `mkdocs.yml` with exactly this content: - -```yaml -site_name: httpware -site_url: https://httpware.readthedocs.io/ -repo_url: https://github.com/modern-python/httpware -docs_dir: docs -edit_uri: edit/main/docs/ - -nav: - - Quick-Start: index.md - - Development: - - Engineering Notes: dev/engineering.md - - Contributing: dev/contributing.md - -theme: - name: material - features: - - content.code.copy - - content.action.edit - - navigation.footer - - navigation.sections - - navigation.top - - header.autohide - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: black - accent: pink - toggle: - icon: material/brightness-7 - name: Switch to dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: black - accent: pink - toggle: - icon: material/brightness-4 - name: Switch to system preference - -markdown_extensions: - - toc: - permalink: true - - pymdownx.highlight: - anchor_linenums: true - - pymdownx.inlinehilite - - pymdownx.superfences - - admonition - - attr_list -``` - -- [ ] **Step 2: Create .readthedocs.yaml** - -Create `.readthedocs.yaml` with exactly this content: - -```yaml -version: 2 - -build: - os: "ubuntu-22.04" - tools: - python: "3.12" - -python: - install: - - requirements: docs/requirements.txt - -mkdocs: - configuration: mkdocs.yml -``` - -- [ ] **Step 3: Create docs/requirements.txt** - -Create `docs/requirements.txt` with exactly this content: - -``` -mkdocs -mkdocs-material -``` - -- [ ] **Step 4: Create docs/index.md** - -Create `docs/index.md` with exactly this content: - -````markdown -# httpware - -A Python async HTTP client framework for building resilient service clients. `httpware` owns the abstraction layer above the underlying HTTP client (`httpx2` by default); consumers never import the transport directly. - -> **Status:** Pre-1.0 (0.1.0 alpha). Public API is subject to change between minor releases until v1.0. - -## Install - -```bash -pip install httpware -``` - -Optional extras: - -```bash -pip install httpware[msgspec] # MsgspecDecoder -``` - -## First request - -```python -import asyncio - -from httpware import AsyncClient -from pydantic import BaseModel - - -class User(BaseModel): - id: int - name: str - - -async def main() -> None: - async with AsyncClient(base_url="https://api.example.com") as client: - user = await client.get("/users/1", response_model=User) - print(user.name) - - -asyncio.run(main()) -``` - -## Where to go next - -- **[Engineering Notes](dev/engineering.md)** — design invariants, the five protocol seams, exception contract, module layout, testing patterns, optional-extras pattern. -- **[Contributing](dev/contributing.md)** — setup, conventions, workflow. - -## Part of `modern-python` - -`httpware` ships under the [`modern-python`](https://github.com/modern-python) org. See the org profile for the categorized index of related templates and libraries. -```` - -- [ ] **Step 5: Build the site locally to catch broken links now** - -Run: `uv run --with mkdocs --with mkdocs-material mkdocs build --strict` - -Expected: exits 0 with no warnings. A `site/` directory is produced (ignore it; do not commit). - -If the build fails with a "doc file is not included in the 'nav'" warning for `dev/engineering.md` or `dev/contributing.md`, the nav block in `mkdocs.yml` is wrong — recheck Step 1. - -If the build fails with a broken-link warning, follow the link in the error to find the offending file and fix its reference. Common cases: - -- A link to `engineering.md` from a sibling file under `dev/` should be a relative `engineering.md`, not `dev/engineering.md`. -- A link from `index.md` to a file under `dev/` should be `dev/engineering.md`. - -- [ ] **Step 6: Confirm site/ is gitignored** - -Run: `grep -n "^site" .gitignore` - -If `site/` is not gitignored, add it: - -```bash -echo "site/" >> .gitignore -git add .gitignore -``` - -Then remove the local build artifact: - -```bash -rm -rf site/ -``` - -- [ ] **Step 7: Commit** - -```bash -git add mkdocs.yml .readthedocs.yaml docs/requirements.txt docs/index.md -# Also stage .gitignore if you modified it in Step 6 -git add .gitignore 2>/dev/null || true -git commit -m "docs: add minimal mkdocs site published via Read the Docs" -``` - ---- - -### Task 9: Delete docs/archive/ - -**Goal:** Remove the bmad-era archive. Everything load-bearing has been ported. After this commit, `git grep -n "docs/archive"` should return zero hits (outside the spec/plan files that narrate the deletion). - -**Files:** -- Delete: `docs/archive/` (entire subtree) - -- [ ] **Step 1: Confirm contents about to be deleted** - -```bash -ls docs/archive/ -ls docs/archive/stories/ -``` - -Expected files: -- `docs/archive/`: `README.md`, `architecture.md`, `epics.md`, `prd.md`, `product-brief-httpware.md`, `product-brief-httpware-distillate.md`, `stories/` -- `docs/archive/stories/`: `1-1-project-scaffold-and-tooling.md`, `1-2-core-data-types.md`, `1-3-exception-hierarchy-with-plain-fields.md`, `1-4-transport-protocol-and-httpx2transport-adapter.md`, `1-5-responsedecoder-protocol-and-pydantic-adapter.md`, `sprint-status.yaml` - -If the inventory differs, **stop** and investigate before deleting. - -- [ ] **Step 2: Delete the directory** - -```bash -git rm -rf docs/archive -``` - -- [ ] **Step 3: Verify nothing in the published tree references archive** - -```bash -grep -rn "docs/archive\|archive/architecture\.md\|archive/epics\.md\|archive/stories" docs/ CLAUDE.md README.md -``` - -Expected: **(no output)**. Files under `docs/` no longer mention archive at all. - -- [ ] **Step 4: Final repo-wide sweep, excluding the migration spec and plan** - -```bash -grep -rn "docs/archive" . \ - --exclude-dir=.git --exclude-dir=.venv --exclude-dir=site \ - | grep -v "planning/specs/2026-06-02-docs-reorg-and-mkdocs-design.md" \ - | grep -v "planning/plans/2026-06-02-docs-reorg-and-mkdocs-plan.md" -``` - -Expected: **(no output)**. The migration's own spec and plan narrate the deletion and are the only allowed mentions. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "docs: delete bmad-era archive (rationale inlined into engineering.md)" -``` - ---- - -### Task 10: Run lint, tests, and final mkdocs strict build - -**Goal:** Confirm nothing in the source tree, the test suite, or the docs build regressed. - -- [ ] **Step 1: Lint** - -Run: `just lint-ci` - -Expected: exits 0. If ruff or ty fail, the cause is unrelated to the docs reorg (no Python files were touched) — investigate before continuing. - -- [ ] **Step 2: Tests** - -Run: `just test` - -Expected: exits 0, all tests pass, coverage unchanged. - -- [ ] **Step 3: Docs build** - -Run: `uv run --with mkdocs --with mkdocs-material mkdocs build --strict` - -Expected: exits 0 with no warnings. Remove the build artifact afterward: `rm -rf site/`. - -- [ ] **Step 4: History-preservation spot check** - -```bash -git log --follow --oneline -n 3 docs/dev/engineering.md -git log --follow --oneline -n 3 planning/specs/2026-06-01-auth-coercion-design.md -git log --follow --oneline -n 3 docs/dev/contributing.md -``` - -Expected: each shows historical commits from before the rename. If any of them shows only the rename commit, history was not preserved — `git mv` was missed somewhere and the file was re-created instead of moved. Investigate and fix. - -- [ ] **Step 5: No commit needed** - -Task 10 only runs verifications. If everything passed, the working tree is clean and you are done with the implementation. - ---- - -### Task 11: Post-merge follow-up (out of band, manual, not part of the PR) - -**Goal:** Document the manual steps required after the PR merges. These are NOT part of the implementation — they're a checklist for the human reviewer/maintainer. - -**Out-of-band steps:** - -1. **Create the Read the Docs project.** Log in at https://readthedocs.org, add `modern-python/httpware` as a new project. The webhook is set up automatically when the project is added via the GitHub integration. -2. **Verify the build.** First build kicks off when the PR merges to `main`. Confirm at the RTD project dashboard that the build succeeds. -3. **Claim the subdomain.** Default subdomain will be `httpware.readthedocs.io` if available. If taken, update `site_url` in `mkdocs.yml` and the docs link in `README.md` accordingly. -4. **Set default branch / version.** Confirm RTD is tracking the `main` branch as the default version. -5. **Add the RTD badge to the README** (optional, follow-up): - ```markdown - [![Documentation Status](https://readthedocs.org/projects/httpware/badge/?version=latest)](https://httpware.readthedocs.io/en/latest/?badge=latest) - ``` - -This task does not require any commit. - ---- - -## Verification summary - -Every task above should leave the working tree in a state where: - -- `just lint-ci` exits 0 (Tasks 1–10) -- `just test` exits 0 (Tasks 1–10) -- `git status` is clean after each task's commit -- `git log --follow` shows preserved history for every moved file -- After Task 8 onwards, `mkdocs build --strict` exits 0 with zero warnings - -The final repository layout matches the spec's "File structure" section: - -``` -/ -├─ README.md, SECURITY.md, CLAUDE.md, LICENSE, Justfile, pyproject.toml, ... -├─ CONTRIBUTING.md ← thin stub -├─ .readthedocs.yaml ← NEW -├─ mkdocs.yml ← NEW -├─ docs/ -│ ├─ index.md ← NEW -│ ├─ requirements.txt ← NEW -│ └─ dev/ -│ ├─ engineering.md -│ └─ contributing.md -├─ planning/ -│ ├─ specs/ -│ ├─ plans/ -│ └─ deferred-work.md -├─ src/httpware/ ← unchanged -└─ tests/ ← unchanged -``` diff --git a/planning/changes/2026-06-02.02-project-hygiene-tidy/design.md b/planning/changes/2026-06-02.02-project-hygiene-tidy.md similarity index 100% rename from planning/changes/2026-06-02.02-project-hygiene-tidy/design.md rename to planning/changes/2026-06-02.02-project-hygiene-tidy.md diff --git a/planning/changes/2026-06-02.02-project-hygiene-tidy/plan.md b/planning/changes/2026-06-02.02-project-hygiene-tidy/plan.md deleted file mode 100644 index 6f40a77..0000000 --- a/planning/changes/2026-06-02.02-project-hygiene-tidy/plan.md +++ /dev/null @@ -1,727 +0,0 @@ -# Project hygiene tidy 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:** Land four small hygiene fixes in one PR: a `just publish` env-var guard, a widened `uv_build` band, project-wide `http.HTTPStatus` substitution for status-code `PLR2004` noqas, and a two-line `Response.json()` correctness fix (honors declared charset). - -**Architecture:** Six atomic-commit tasks executed in dependency order. Tasks 1–2 are config-only (Justfile, pyproject.toml). Tasks 3–4 are behavior-preserving refactors (HTTPStatus substitutions in tests and one source file). Task 5 is a proper TDD cycle for `Response.json()` (failing test first, then fix). Task 6 closes the deferred-work entries this PR resolves. No CI invariants change; no public API break. - -**Tech Stack:** `uv` (build system + package manager), `just` (task runner), `pytest`, `ruff` (lint, with `RUF100` to catch unused noqas), `http.HTTPStatus` (stdlib). - ---- - -## Pre-flight - -Plan assumes a clean working tree at the spec's commit (`2026-06-02-project-hygiene-tidy-design.md` is already committed). Verify before starting: - -```bash -git status # should be clean -git log --oneline -3 # confirm the hygiene spec commit is present -``` - -The spec lives at `planning/specs/2026-06-02-project-hygiene-tidy-design.md` — read it once if you haven't. - -Establish the baseline: - -```bash -just lint-ci -just test -``` - -Both must exit 0 before any task. If either fails on `main`, stop and surface it to the user — this plan assumes a green baseline. - ---- - -### Task 1: `just publish` env-var guard - -**Goal:** Refuse to run the publish recipe when `GITHUB_REF_NAME` or `PYPI_TOKEN` is unset, so that local invocations cannot corrupt `pyproject.toml` via `uv version ""`. - -**Files:** -- Modify: `Justfile` (the `publish` recipe at lines 25-29) - -- [ ] **Step 1: Read current `publish` recipe** - -Run: `sed -n '25,29p' Justfile` - -Expected output: - -``` -publish: - rm -rf dist - uv version $GITHUB_REF_NAME - uv build - uv publish --token $PYPI_TOKEN -``` - -- [ ] **Step 2: Replace the recipe** - -Edit `Justfile`, replacing the existing `publish` recipe with: - -```just -publish: - @test -n "$GITHUB_REF_NAME" || (echo "GITHUB_REF_NAME is required; refusing to run outside CI" >&2; exit 1) - @test -n "$PYPI_TOKEN" || (echo "PYPI_TOKEN is required" >&2; exit 1) - rm -rf dist - uv version $GITHUB_REF_NAME - uv build - uv publish --token $PYPI_TOKEN -``` - -(The `@` prefix suppresses just's echoing of the guard line itself; the actual error message still reaches stderr via `echo … >&2`.) - -- [ ] **Step 3: Verify the guard rejects empty env** - -Run: `env -i PATH="$PATH" HOME="$HOME" just publish` - -Expected: exits non-zero, prints `GITHUB_REF_NAME is required; refusing to run outside CI` on stderr. - -- [ ] **Step 4: Verify `pyproject.toml` was NOT mutated** - -Run: `git status pyproject.toml` - -Expected: empty output (no changes to `pyproject.toml`). If `pyproject.toml` shows as modified, the guard failed — STOP and investigate. - -- [ ] **Step 5: Verify the recipe still parses for the happy path** - -Run: `just --show publish` - -Expected: prints the new recipe body, confirming `just` parsed it without error. - -- [ ] **Step 6: Commit** - -```bash -git add Justfile -git commit -m "$(cat <<'EOF' -build: guard just publish against missing env vars - -Refuse to run when GITHUB_REF_NAME or PYPI_TOKEN is unset, so local -invocations cannot corrupt pyproject.toml via uv version "". - -Closes deferred-work entry: "just publish lacks env-var validation". -EOF -)" -``` - ---- - -### Task 2: Widen `uv_build` band to `<1.0` - -**Goal:** Stop the every-minor bump treadmill on `uv_build`. Any 0.x release is accepted; an incompatible bump (hypothetical) surfaces as a loud build error in CI, not a silent regression. - -**Files:** -- Modify: `pyproject.toml` line 49 (`[build-system] requires`) - -- [ ] **Step 1: Read the current `[build-system]`** - -Run: `grep -A2 '^\[build-system\]' pyproject.toml` - -Expected: - -``` -[build-system] -requires = ["uv_build>=0.11,<0.12"] -build-backend = "uv_build" -``` - -- [ ] **Step 2: Widen the band** - -Edit `pyproject.toml`, changing the `requires` line from: - -```toml -requires = ["uv_build>=0.11,<0.12"] -``` - -to: - -```toml -requires = ["uv_build>=0.11,<1.0"] -``` - -- [ ] **Step 3: Refresh the lockfile** - -Run: `uv lock --upgrade` - -Expected: exits 0. May or may not change `uv.lock` depending on whether a newer `uv_build` 0.x exists; either outcome is fine. - -- [ ] **Step 4: Verify install still works** - -Run: `just install` - -Expected: exits 0. - -- [ ] **Step 5: Verify build still works** - -Run: `rm -rf dist && uv build` - -Expected: exits 0; `dist/` contains an `.whl` and a `.tar.gz`. - -Then clean up: `rm -rf dist`. - -- [ ] **Step 6: Verify tests still pass** - -Run: `just test` - -Expected: exits 0. - -- [ ] **Step 7: Commit** - -Stage `pyproject.toml` and `uv.lock` together (the lock may or may not have changed; if it did, it ships with the band widening). - -```bash -git add pyproject.toml uv.lock -git commit -m "$(cat <<'EOF' -build: widen uv_build band to <1.0 - -Accept all 0.x releases; stops the every-minor bump treadmill. An -incompatible 0.x bump (hypothetical) surfaces as a loud build error -in CI, not a silent regression. - -Closes deferred-work entry: "uv_build>=0.11,<0.12 narrow window". -EOF -)" -``` - -(If `uv.lock` was unchanged, `git add uv.lock` is a no-op and the commit only includes `pyproject.toml` — that's fine.) - ---- - -### Task 3: HTTPStatus substitution in test files - -**Goal:** Replace status-code integer literals with `http.HTTPStatus` constants across the three test files that hold them. Each substitution removes a `# noqa: PLR2004`. Eleven instances total. - -**Why one commit for three files:** All three files implement the same conceptual change (`literal → HTTPStatus.X`) with no logic change. Splitting per-file adds three commits with identical justifications. - -**Files:** -- Modify: `tests/test_transports_httpx2.py` (lines 72, 103, 135, 146) -- Modify: `tests/test_response.py` (lines 111, 116; line 123 stays — `status == 99` is intentionally invalid) -- Modify: `tests/test_middleware.py` (lines 67, 195, 268, 335) - -**HTTPStatus mapping (verified for Python 3.11+):** - -| Literal | HTTPStatus member | -|---|---| -| `200` | `HTTPStatus.OK` | -| `418` | `HTTPStatus.IM_A_TEAPOT` | -| `503` | `HTTPStatus.SERVICE_UNAVAILABLE` | -| `504` | `HTTPStatus.GATEWAY_TIMEOUT` | - -- [ ] **Step 1: Edit `tests/test_transports_httpx2.py`** - -Add at the top of the imports block (after the stdlib imports; ruff isort will land it correctly): - -```python -from http import HTTPStatus -``` - -Then replace these four lines: - -```python -# Line 72: - assert resp.status == 200 # noqa: PLR2004 -# → - assert resp.status == HTTPStatus.OK - -# Line 103: - assert resp.status == 200 # noqa: PLR2004 -# → - assert resp.status == HTTPStatus.OK - -# Line 135: - assert info.value.status == 418 # noqa: PLR2004 -# → - assert info.value.status == HTTPStatus.IM_A_TEAPOT - -# Line 146: - assert info.value.status == 504 # noqa: PLR2004 -# → - assert info.value.status == HTTPStatus.GATEWAY_TIMEOUT -``` - -- [ ] **Step 2: Edit `tests/test_response.py`** - -Add at the top: - -```python -from http import HTTPStatus -``` - -Replace these lines: - -```python -# Line 111: - assert new.status == 503 # noqa: PLR2004 -# → - assert new.status == HTTPStatus.SERVICE_UNAVAILABLE - -# Line 116: - assert resp.status == 200 # noqa: PLR2004 -# → - assert resp.status == HTTPStatus.OK -``` - -Do NOT touch line 123 (`assert new.status == 99 # noqa: PLR2004`) — that test deliberately exercises an invalid status; `99` is not an `HTTPStatus` member. - -Do NOT touch line 115 (`assert new.elapsed == 0.5 # noqa: PLR2004`) — float, not a status code. - -- [ ] **Step 3: Edit `tests/test_middleware.py`** - -Add at the top: - -```python -from http import HTTPStatus -``` - -Replace these lines: - -```python -# Line 67: - assert response.status == 200 # noqa: PLR2004 -# → - assert response.status == HTTPStatus.OK - -# Line 195: - assert response.status == 418 # noqa: PLR2004 -# → - assert response.status == HTTPStatus.IM_A_TEAPOT - -# Line 268: - assert response.status == 200 # noqa: PLR2004 -# → - assert response.status == HTTPStatus.OK - -# Line 335: - assert response.status == 503 # noqa: PLR2004 -# → - assert response.status == HTTPStatus.SERVICE_UNAVAILABLE -``` - -Do NOT touch line 270 (`assert count == 3 # noqa: PLR2004`) — count, not a status code. - -- [ ] **Step 4: Run tests to verify no behavior change** - -Run: `just test tests/test_transports_httpx2.py tests/test_response.py tests/test_middleware.py` - -Expected: all tests pass. `HTTPStatus` members are `IntEnum`, so `assert response.status == HTTPStatus.OK` is equivalent to `assert response.status == 200`. - -- [ ] **Step 5: Run lint to catch unused noqas** - -Run: `just lint-ci` - -Expected: exits 0. Specifically, `RUF100` should NOT flag the removed noqas (because they were removed in this commit). If any of the three test files still has a bare `# noqa: PLR2004` on a status-code line that this task missed, ruff will pass but the grep in step 6 will catch it. - -- [ ] **Step 6: Grep to confirm no leftover status-code noqas** - -Run: - -```bash -grep -n 'PLR2004' tests/test_transports_httpx2.py tests/test_response.py tests/test_middleware.py -``` - -Expected: only non-status lines remain: -- `tests/test_response.py:115` (elapsed) -- `tests/test_response.py:123` (intentionally invalid status 99) -- `tests/test_middleware.py:270` (count == 3) - -If any status-code line is still listed, you missed a substitution — go back to the relevant step. - -- [ ] **Step 7: Commit** - -```bash -git add tests/test_transports_httpx2.py tests/test_response.py tests/test_middleware.py -git commit -m "$(cat <<'EOF' -test: use http.HTTPStatus constants for status-code assertions - -Replaces 11 instances of `assert status == # noqa: PLR2004` -with `assert status == HTTPStatus.` across three test files. -Each substitution removes a noqa; HTTPStatus members are IntEnum so -behavior is unchanged. - -Non-status-code PLR2004 noqas (counts, elapsed, intentionally-invalid -status==99) are out of scope. - -Partial: deferred-work "PLR2004 per-file-ignores" entry. -EOF -)" -``` - ---- - -### Task 4: HTTPStatus substitution in `src/httpware/transports/httpx2.py` - -**Goal:** Replace the `400` and `500` literals in the status-code dispatch block with `HTTPStatus` constants. The `< 600` synthetic upper bound has no stdlib equivalent; keep its noqa but add a per-line justification (matching the user's lint-suppression hierarchy). - -**Files:** -- Modify: `src/httpware/transports/httpx2.py` (lines 144-148, plus an import) - -- [ ] **Step 1: Read the current dispatch block** - -Run: `sed -n '140,160p' src/httpware/transports/httpx2.py` - -Expected (relevant lines): - -```python - if 400 <= status < 600: # noqa: PLR2004 - exc_class = STATUS_TO_EXCEPTION.get( - status, - ClientStatusError if status < 500 else ServerStatusError, # noqa: PLR2004 - ) -``` - -- [ ] **Step 2: Add the import** - -Add to the stdlib imports block at the top of the file (after `import json`, before `import time` — alphabetical, ruff isort will sort if needed): - -```python -from http import HTTPStatus -``` - -- [ ] **Step 3: Replace the dispatch block** - -Replace lines 144-148 (the `if 400 <= status < 600:` block) with: - -```python - if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic 5xx upper bound - exc_class = STATUS_TO_EXCEPTION.get( - status, - ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError, - ) -``` - -(The first line keeps its `# noqa: PLR2004` because `600` is still a literal; the inline justification documents why no constant replaces it. The second line loses its noqa because `HTTPStatus.INTERNAL_SERVER_ERROR` is not a magic number.) - -- [ ] **Step 4: Run transport tests to verify no behavior change** - -Run: `just test tests/test_transports_httpx2.py tests/test_errors.py` - -Expected: all tests pass. `HTTPStatus.BAD_REQUEST` is `400` as an `IntEnum`; comparisons work identically. - -- [ ] **Step 5: Run full lint** - -Run: `just lint-ci` - -Expected: exits 0. `RUF100` would flag the removed inner noqa as unused if you forgot to remove it; the new outer noqa is fine because `600` is still a magic literal. - -- [ ] **Step 6: Run full test suite** - -Run: `just test` - -Expected: exits 0. - -- [ ] **Step 7: Commit** - -```bash -git add src/httpware/transports/httpx2.py -git commit -m "$(cat <<'EOF' -refactor: use HTTPStatus constants in transport status dispatch - -Replaces 400 → HTTPStatus.BAD_REQUEST and 500 → -HTTPStatus.INTERNAL_SERVER_ERROR in the 4xx/5xx exception dispatch -block. The < 600 synthetic upper bound has no stdlib equivalent, so -its PLR2004 noqa stays — now with an inline justification. - -Closes deferred-work entry: "PLR2004 per-file-ignores" (for status -codes; non-status instances remain open). -EOF -)" -``` - ---- - -### Task 5: `Response.json()` charset fix (TDD) - -**Goal:** Route `Response.json()` through `self.text` so it honors the declared charset, and document the `json.JSONDecodeError` raise contract. Two-line behavior change; one new test. - -**Files:** -- Test: `tests/test_response.py` (new test function) -- Modify: `src/httpware/response.py:50-52` (the `.json()` method) - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_response.py`, immediately after `test_response_json_parses_body` (around line 84): - -```python -def test_response_json_uses_declared_charset() -> None: - body = '{"name": "café"}'.encode("iso-8859-1") - resp = Response( - status=HTTPStatus.OK, - headers={"content-type": "application/json; charset=iso-8859-1"}, - content=body, - url="/", - elapsed=0.0, - ) - assert resp.json() == {"name": "café"} -``` - -(Uses `HTTPStatus.OK` because Task 3 added the import to this file. If you're executing Task 5 before Task 3 — don't, follow the plan order — substitute `200` and add `# noqa: PLR2004`.) - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `just test tests/test_response.py::test_response_json_uses_declared_charset -v` - -Expected: FAIL. The body `b'{"name": "caf\xe9"}'` is invalid UTF-8 (the `\xe9` byte is Latin-1's `é`, not a valid UTF-8 continuation). `json.loads(self.content)` raises `json.JSONDecodeError` (or a `UnicodeDecodeError` wrapped inside one, depending on Python's exact error chain). - -If the test PASSES, something is wrong with your test setup — `self.content` should be raw bytes and `json.loads` should fail on invalid UTF-8. Verify by adding `print(resp.content)` temporarily and re-running. - -- [ ] **Step 3: Read the current `.json()` implementation** - -Run: `sed -n '50,53p' src/httpware/response.py` - -Expected: - -```python - def json(self) -> Any: # noqa: ANN401 - """Parse `content` as JSON.""" - return json.loads(self.content) -``` - -- [ ] **Step 4: Update `.json()`** - -Replace those three lines with: - -```python - def json(self) -> Any: # noqa: ANN401 - """Parse `content` as JSON using the declared charset (default UTF-8). - - Raises: - json.JSONDecodeError: if the body is not valid JSON. - """ - return json.loads(self.text) -``` - -- [ ] **Step 5: Run the new test to verify it passes** - -Run: `just test tests/test_response.py::test_response_json_uses_declared_charset -v` - -Expected: PASS. `self.text` decodes the body via `_parse_charset` → `"iso-8859-1"` → correct `"café"` string; `json.loads(str)` then parses cleanly. - -- [ ] **Step 6: Run the existing `.json()` test to verify no regression** - -Run: `just test tests/test_response.py::test_response_json_parses_body -v` - -Expected: PASS. UTF-8 body still decodes correctly through `self.text` (no charset declared → defaults to UTF-8). - -- [ ] **Step 7: Run the full response-tests file** - -Run: `just test tests/test_response.py` - -Expected: all tests pass. - -- [ ] **Step 8: Run full test suite + lint** - -Run: `just test && just lint-ci` - -Expected: both exit 0. - -- [ ] **Step 9: Commit** - -```bash -git add tests/test_response.py src/httpware/response.py -git commit -m "$(cat <<'EOF' -fix: Response.json() honors declared charset - -Routes the body through self.text instead of json.loads(self.content), -so a declared charset (e.g. iso-8859-1) is respected before JSON -parsing. ASCII / UTF-8 bodies are unchanged. Docstring now explicitly -documents the json.JSONDecodeError raise contract. - -Wrapping JSONDecodeError in a domain exception is left to a future -response-API revision. - -Closes deferred-work entries: "Response.json() raises raw and ignores -charset" (retro) and "Response.json() honor declared charset" (1-2). -EOF -)" -``` - ---- - -### Task 6: Update `planning/deferred-work.md` - -**Goal:** Remove the entries this PR closes and reword the PLR2004 entry to reflect what was actually done. - -**Files:** -- Modify: `planning/deferred-work.md` - -**Entries to remove:** -- "Story 1-2" section: `Response.json()` honor declared charset (consolidated with the retro entry — both are now resolved by Task 5) -- "Retrospective review" section: `Response.json()` raises raw and ignores charset (resolved by Task 5) -- "Story 1-1" section: `just publish` lacks env-var validation (Task 1) -- "Story 1-1" section: `uv_build>=0.11,<0.12` narrow window (Task 2) - -**Entry to reword:** -- "Story 1-5" section: PLR2004 per-file-ignores. The deferred-work proposal (per-file-ignores) was rejected at the spec stage; the actual fix (HTTPStatus substitution for status-code instances) shipped in Tasks 3-4. Reword to document the remaining ~11 non-status PLR2004 noqas as the still-open scope. - -- [ ] **Step 1: Read the current deferred-work entries to be touched** - -Run: `grep -n 'Response.json\|just publish\|uv_build\|PLR2004' planning/deferred-work.md` - -Expected: lines covering the four removals and one reword listed above. - -- [ ] **Step 2: Remove the Retro `Response.json()` bullet** - -In `planning/deferred-work.md`, delete the bullet that starts: - -```markdown -- **`Response.json()` raises raw `JSONDecodeError` and ignores declared charset** — `json.loads(self.content)` … -``` - -It's in the "Deferred from: retrospective review of stories 1-1 through 1-5 (2026-05-31)" section. - -- [ ] **Step 3: Reword the PLR2004 entry** - -In the "Story 1-5" section, replace the existing PLR2004 bullet: - -```markdown -- **`PLR2004` per-file-ignores** — `# noqa: PLR2004` repeated 5× in this test file; idiomatic fix is `tool.ruff.lint.per-file-ignores` for `tests/*`. Project-wide lint-config tidy. (`tests/test_decoders_pydantic.py:63,67,83,107,153`) -``` - -with: - -```markdown -- **`PLR2004` noqas on non-status-code literals** — status-code instances were migrated to `http.HTTPStatus` constants (no noqa needed). ~11 instances remain on counts, list lengths, primitive-decode assertions, `elapsed` floats, and intentionally-invalid status values across `tests/test_decoders_pydantic.py`, `tests/test_decoders_msgspec.py`, `tests/test_client_methods.py`, `tests/test_internal_auth.py`, `tests/test_transports_recorded.py`, `tests/test_client_lifecycle.py`, and `tests/test_response.py`. No stdlib constant exists for "I made two calls in this test"; either accept the bare noqas or add per-line justifications. Per the user's lint-suppression hierarchy, `per-file-ignores` is the *least-preferred* form and should not be used. -``` - -- [ ] **Step 4: Remove the Story 1-2 `Response.json()` bullet** - -In the "Story 1-2" section, delete the bullet: - -```markdown -- **`Response.json()` honor declared charset** — `json.loads(bytes)` auto-detects only UTF-8/16/32. Real APIs vary. (`src/httpware/response.py:44-45`) -``` - -- [ ] **Step 5: Remove the Story 1-1 `just publish` bullet** - -In the "Story 1-1" section, delete the bullet: - -```markdown -- **`just publish` lacks env-var validation** — recipe assumes `GITHUB_REF_NAME` and `PYPI_TOKEN` are set; running locally could corrupt the version. Add `test -n "$GITHUB_REF_NAME"` guard before release work. (`Justfile:25-29`) -``` - -- [ ] **Step 6: Remove the Story 1-1 `uv_build` bullet** - -In the "Story 1-1" section, delete the bullet: - -```markdown -- **`uv_build>=0.11,<0.12` narrow window** — single-minor band will expire as soon as uv_build 0.12 ships; bump when that happens. (`pyproject.toml:49`) -``` - -- [ ] **Step 7: Verify the file** - -Run: `grep -n 'Response.json\|just publish\|uv_build>=0.11,<0.12' planning/deferred-work.md` - -Expected: empty output. All four removals confirmed. - -Run: `grep -n 'PLR2004' planning/deferred-work.md` - -Expected: one line — the reworded bullet describing the remaining ~11 non-status noqas. - -- [ ] **Step 8: Commit** - -```bash -git add planning/deferred-work.md -git commit -m "$(cat <<'EOF' -docs: close deferred-work entries resolved by hygiene tidy PR - -Removes four entries closed by this PR (just publish guard, uv_build -band, Response.json() charset+raise — last one was duplicated across -the retro section and the original Story 1-2 review). - -Rewords the PLR2004 entry to document what was actually done (status -codes migrated to http.HTTPStatus) and what remains open (~11 non- -status noqas on counts and primitive values). -EOF -)" -``` - ---- - -### Task 7: Final verification - -**Goal:** Confirm the PR is internally consistent and ready for review. - -- [ ] **Step 1: Full lint + test** - -Run: `just lint-ci && just test` - -Expected: both exit 0. - -- [ ] **Step 2: Confirm the publish guard still works** - -Run: `env -i PATH="$PATH" HOME="$HOME" just publish; echo "exit=$?"; git status pyproject.toml` - -Expected: prints the guard error message, `exit=1`, and `git status pyproject.toml` shows no diff. - -- [ ] **Step 3: Confirm no bare status-code PLR2004 noqas remain** - -Run: - -```bash -grep -rn 'PLR2004' src/ tests/ -``` - -Expected: only non-status instances plus the one justified `< 600` noqa in `src/httpware/transports/httpx2.py`. Specifically, you should see: -- `src/httpware/transports/httpx2.py:NNN` — the `< 600` line with its inline `— 600 is the synthetic 5xx upper bound` justification -- `tests/test_decoders_pydantic.py` — 5 lines (out of scope per spec) -- `tests/test_decoders_msgspec.py` — 1 line -- `tests/test_client_methods.py` — 1 line -- `tests/test_internal_auth.py` — 1 line -- `tests/test_transports_recorded.py` — 1 line -- `tests/test_client_lifecycle.py` — 1 line -- `tests/test_response.py:115,123` — 2 lines (elapsed, intentional invalid status) -- `tests/test_middleware.py:270` — 1 line (count) - -No status-code (200/418/503/504) lines should be in this list. - -- [ ] **Step 4: Confirm closed deferred-work entries are gone** - -Run: - -```bash -grep -E 'just publish|uv_build>=0\.11,<0\.12|Response\.json' planning/deferred-work.md -``` - -Expected: empty output. - -- [ ] **Step 5: Review the commit log** - -Run: `git log --oneline origin/main..HEAD` - -Expected: six commits, in order: - -``` - docs: close deferred-work entries resolved by hygiene tidy PR - fix: Response.json() honors declared charset - refactor: use HTTPStatus constants in transport status dispatch - test: use http.HTTPStatus constants for status-code assertions - build: widen uv_build band to <1.0 - build: guard just publish against missing env vars -``` - -(Commit order is bottom-up because git log shows newest first.) - -- [ ] **Step 6: PR readiness check** - -Run: `git diff origin/main --stat` - -Expected ~7 files touched: -- `Justfile` -- `pyproject.toml` -- `uv.lock` (possibly) -- `src/httpware/transports/httpx2.py` -- `src/httpware/response.py` -- `tests/test_transports_httpx2.py` -- `tests/test_response.py` -- `tests/test_middleware.py` -- `planning/deferred-work.md` - -If any file outside this list shows up, investigate before pushing. - ---- - -## After completion - -The branch is ready to push and PR. Suggested PR title: - -> `chore: project hygiene tidy — publish guard, uv_build band, HTTPStatus, Response.json() charset` - -PR description should reference the spec at `planning/specs/2026-06-02-project-hygiene-tidy-design.md` and list the closed deferred-work entries. diff --git a/planning/changes/2026-06-03.01-input-validation-pass/design.md b/planning/changes/2026-06-03.01-input-validation-pass.md similarity index 100% rename from planning/changes/2026-06-03.01-input-validation-pass/design.md rename to planning/changes/2026-06-03.01-input-validation-pass.md diff --git a/planning/changes/2026-06-03.01-input-validation-pass/plan.md b/planning/changes/2026-06-03.01-input-validation-pass/plan.md deleted file mode 100644 index a9069a1..0000000 --- a/planning/changes/2026-06-03.01-input-validation-pass/plan.md +++ /dev/null @@ -1,786 +0,0 @@ -# Input-validation pass 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:** Land five v0-contract input-validation fixes in one PR: charset parser inner-whitespace bug; `Request.__post_init__` with URL, header/cookie, and mapping-field validation; `Timeout`/`Limits` negative-value guards; `ClientConfig.base_url` validation + normalization; deferred-work cleanup. - -**Architecture:** Six atomic-commit tasks executed in dependency order. Each validation task is a TDD cycle (failing test → `__post_init__` implementation → green). All validation lives in `__post_init__` on the affected frozen dataclasses; `with_*` methods inherit validation via `dataclasses.replace`. Exception types: `ValueError` for invalid values, `TypeError` for wrong runtime types. The `base_url` normalization in Task 4 also removes a redundant `rstrip("/")` from `AsyncClient._resolve_url` (DRY — once the stored value is canonical, downstream doesn't re-normalize). - -**Tech Stack:** Python 3.11+ frozen dataclasses with `__post_init__`, `object.__setattr__` for in-`__post_init__` field normalization, `pytest` (no Hypothesis — reserved for concurrency-sensitive code per CLAUDE.md). - ---- - -## Pre-flight - -Plan assumes a clean working tree at the spec's commit (`2026-06-03-input-validation-pass-design.md` already on `main`). Verify: - -```bash -git status # clean -git log -1 --oneline # should show the spec commit -``` - -The spec lives at `planning/specs/2026-06-03-input-validation-pass-design.md` — read it once if you haven't. - -Establish the baseline: - -```bash -just lint-ci -just test -``` - -Both must exit 0 before starting. Note the test count for sanity-checking later (should be 296 from the prior hygiene PR). - -Create a feature branch for this work: - -```bash -git checkout -b chore/input-validation-pass -``` - ---- - -### Task 1: Charset parser inner-whitespace fix - -**Goal:** Fix the `_parse_charset` helper in `response.py` so that `Content-Type: application/json; charset=" utf-8 "` (with whitespace inside the quotes) decodes correctly. Currently the inner whitespace survives the quote-stripping and `bytes.decode(" utf-8 ")` raises `LookupError`, causing `Response.text` to silently fall back to UTF-8 (mojibake if the actual charset differs). - -**Files:** -- Test: `tests/test_response.py` (add one test function) -- Modify: `src/httpware/response.py:20-25` (the `_parse_charset` function) - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_response.py`, placed near the existing `test_response_text_honors_explicit_charset` tests (somewhere in the charset-related test cluster — typically around lines 25–65): - -```python -def test_response_text_strips_inner_whitespace_in_quoted_charset() -> None: - body = "café".encode("iso-8859-1") - resp = Response( - status=HTTPStatus.OK, - headers={"content-type": 'text/plain; charset=" iso-8859-1 "'}, - content=body, - url="/", - elapsed=0.0, - ) - assert resp.text == "café" -``` - -(Uses `HTTPStatus.OK` because `from http import HTTPStatus` is already imported in this file from the prior hygiene PR.) - -- [ ] **Step 2: Run test to verify it fails** - -```bash -just test tests/test_response.py::test_response_text_strips_inner_whitespace_in_quoted_charset -v -``` - -Expected: FAIL. `_parse_charset` returns `" iso-8859-1 "` (with leading/trailing space), `bytes.decode(" iso-8859-1 ")` raises `LookupError`, the existing fallback at `response.py:47-48` returns `self.content.decode("utf-8")` which produces mojibake (`b'caf\xe9'` is invalid UTF-8 → also raises). Exact error may be `UnicodeDecodeError` rather than an assertion failure; either way the test fails. - -- [ ] **Step 3: Read current `_parse_charset`** - -```bash -sed -n '20,25p' src/httpware/response.py -``` - -Expected: - -```python -def _parse_charset(content_type: str) -> str | None: - for raw in content_type.split(";"): - part = raw.strip() - if part.lower().startswith(_CHARSET_PREFIX): - return part[len(_CHARSET_PREFIX) :].strip().strip('"').strip("'") - return None -``` - -- [ ] **Step 4: Apply the fix** - -Replace the `return` line in `_parse_charset` with one that adds a final `.strip()` after the quote-stripping chain: - -```python -def _parse_charset(content_type: str) -> str | None: - for raw in content_type.split(";"): - part = raw.strip() - if part.lower().startswith(_CHARSET_PREFIX): - return part[len(_CHARSET_PREFIX) :].strip().strip('"').strip("'").strip() - return None -``` - -- [ ] **Step 5: Run the test to verify it passes** - -```bash -just test tests/test_response.py::test_response_text_strips_inner_whitespace_in_quoted_charset -v -``` - -Expected: PASS. - -- [ ] **Step 6: Run full test suite + lint** - -```bash -just test -just lint-ci -``` - -Both exit 0. - -- [ ] **Step 7: Commit** - -```bash -git add src/httpware/response.py tests/test_response.py -git commit -m "$(cat <<'EOF' -fix: charset parser strips inner whitespace from quoted values - -Adds one final .strip() after the quote-stripping chain in -_parse_charset so that Content-Type: ...; charset=" utf-8 " decodes -correctly instead of falling back through LookupError -> mojibake. - -The other "concerns" listed in the deferred-work entry (substring -false-positives, mismatched quotes, multi-charset directives) do not -actually fire on the current code, per the spec's analysis. - -Closes deferred-work entry: "Charset parser robustness". - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 2: `Request.__post_init__` (URL + header/cookie + mapping validation) - -**Goal:** Add `__post_init__` to `Request` that validates: (a) `url` is a non-empty `str`; (b) every header and cookie name/value is a non-empty `str` with no `\r` or `\n`; (c) `headers`, `params`, `cookies`, `extensions` are each a `Mapping`. Adds a module-private `_validate_header_or_cookie` helper. - -**Files:** -- Test: `tests/test_request.py` (add ~8 new test functions for the new validation rules) -- Modify: `src/httpware/request.py` (add `__post_init__` + helper) - -**Important context:** Adding `__post_init__` to a frozen dataclass is idiomatic — no special handling required. The new `__post_init__` runs on every direct construction AND on every `dataclasses.replace` call (which is what every `with_*` method uses), so validation is inherited for free by `with_url`, `with_header`, `with_headers`, `with_cookie`, `with_cookies`, `with_query`, `with_extension`, and `with_extensions`. - -- [ ] **Step 1: Write all failing tests at once** - -Add the following tests to `tests/test_request.py`. Place them in a logical cluster — group by validation rule, roughly after the existing `with_*` tests. - -```python -def test_request_rejects_empty_url() -> None: - with pytest.raises(ValueError, match="url must be non-empty"): - Request(method="GET", url="") - - -def test_request_rejects_non_str_url() -> None: - with pytest.raises(TypeError, match="url must be str"): - Request(method="GET", url=None) # ty: ignore[invalid-argument-type] - - -def test_with_url_rejects_empty() -> None: - r = Request(method="GET", url="/") - with pytest.raises(ValueError, match="url must be non-empty"): - r.with_url("") - - -def test_request_rejects_header_with_crlf_in_value() -> None: - with pytest.raises(ValueError, match="header name and value must not contain CR or LF"): - Request(method="GET", url="/", headers={"X-Trace": "value\r\nInjected: yes"}) - - -def test_request_rejects_header_with_crlf_in_name() -> None: - with pytest.raises(ValueError, match="header name and value must not contain CR or LF"): - Request(method="GET", url="/", headers={"X-Bad\r\nInjected": "value"}) - - -def test_request_rejects_empty_header_name() -> None: - with pytest.raises(ValueError, match="header name and value must be non-empty"): - Request(method="GET", url="/", headers={"": "value"}) - - -def test_request_rejects_empty_header_value() -> None: - with pytest.raises(ValueError, match="header name and value must be non-empty"): - Request(method="GET", url="/", headers={"X-Trace": ""}) - - -def test_request_rejects_non_str_header_value() -> None: - with pytest.raises(TypeError, match="header name and value must be str"): - Request(method="GET", url="/", headers={"X-Trace": None}) # ty: ignore[invalid-argument-type] - - -def test_request_rejects_cookie_with_crlf() -> None: - with pytest.raises(ValueError, match="cookie name and value must not contain CR or LF"): - Request(method="GET", url="/", cookies={"session": "abc\r\nSet-Cookie: evil"}) - - -def test_request_rejects_empty_cookie_value() -> None: - with pytest.raises(ValueError, match="cookie name and value must be non-empty"): - Request(method="GET", url="/", cookies={"session": ""}) - - -def test_with_header_rejects_crlf() -> None: - r = Request(method="GET", url="/") - with pytest.raises(ValueError, match="header name and value must not contain CR or LF"): - r.with_header("X-Trace", "value\r\n") - - -def test_with_cookie_rejects_crlf() -> None: - r = Request(method="GET", url="/") - with pytest.raises(ValueError, match="cookie name and value must not contain CR or LF"): - r.with_cookie("session", "abc\r\n") - - -@pytest.mark.parametrize("field_name", ["headers", "params", "cookies", "extensions"]) -def test_request_rejects_none_mapping_field(field_name: str) -> None: - with pytest.raises(TypeError, match=f"{field_name} must be a Mapping"): - Request(method="GET", url="/", **{field_name: None}) # ty: ignore[invalid-argument-type] - - -@pytest.mark.parametrize("field_name", ["headers", "params", "cookies", "extensions"]) -def test_request_rejects_list_mapping_field(field_name: str) -> None: - with pytest.raises(TypeError, match=f"{field_name} must be a Mapping"): - Request(method="GET", url="/", **{field_name: []}) # ty: ignore[invalid-argument-type] - - -def test_with_query_none_raises() -> None: - r = Request(method="GET", url="/") - with pytest.raises(TypeError, match="params must be a Mapping"): - r.with_query(None) # ty: ignore[invalid-argument-type] -``` - -- [ ] **Step 2: Run tests to verify they all fail** - -```bash -just test tests/test_request.py -v 2>&1 | tail -30 -``` - -Expected: the ~15 new tests all FAIL (the validation doesn't exist yet). Pre-existing tests continue to PASS. - -If you see fewer than 15 FAILs, double-check Step 1 — some tests may not have been added. - -- [ ] **Step 3: Read current `request.py`** - -```bash -cat src/httpware/request.py -``` - -Understand the existing structure — `Request` is a `@dataclass(frozen=True, slots=True)` with no `__post_init__` yet. - -- [ ] **Step 4: Add the helper + `__post_init__`** - -Edit `src/httpware/request.py`. Add a module-private validator near the top of the file (after imports, before the `@dataclass` decorator): - -```python -def _validate_header_or_cookie(name: str, value: str, *, kind: str) -> None: - if not isinstance(name, str) or not isinstance(value, str): - msg = f"{kind} name and value must be str" - raise TypeError(msg) - if not name or not value: - msg = f"{kind} name and value must be non-empty" - raise ValueError(msg) - if any(c in name or c in value for c in ("\r", "\n")): - msg = f"{kind} name and value must not contain CR or LF" - raise ValueError(msg) -``` - -Then add `__post_init__` to the `Request` class, immediately after the field declarations (before the existing `def with_header(...)`): - -```python - def __post_init__(self) -> None: - if not isinstance(self.url, str): - msg = "url must be str" - raise TypeError(msg) - if not self.url: - msg = "url must be non-empty" - raise ValueError(msg) - for field_name in ("headers", "params", "cookies", "extensions"): - field_value = getattr(self, field_name) - if not isinstance(field_value, Mapping): - msg = f"{field_name} must be a Mapping (got {type(field_value).__name__})" - raise TypeError(msg) - for name, value in self.headers.items(): - _validate_header_or_cookie(name, value, kind="header") - for name, value in self.cookies.items(): - _validate_header_or_cookie(name, value, kind="cookie") -``` - -- [ ] **Step 5: Run tests to verify they all pass** - -```bash -just test tests/test_request.py -v 2>&1 | tail -20 -``` - -Expected: all tests in `test_request.py` pass (the ~15 new ones plus all pre-existing). - -If any pre-existing test now fails, inspect — it likely relied on the silent-acceptance behavior. The pre-flight grep should have caught these, but verify. - -- [ ] **Step 6: Run full test suite + lint** - -```bash -just test -just lint-ci -``` - -Both exit 0. - -Specifically watch for: tests in `tests/test_client_methods.py`, `tests/test_middleware.py`, `tests/test_transports_*.py` that construct Requests. The pre-flight scan suggested none of them construct bad Requests, but the full-suite run is the authoritative check. - -- [ ] **Step 7: Commit** - -```bash -git add src/httpware/request.py tests/test_request.py -git commit -m "$(cat <<'EOF' -fix: validate Request fields in __post_init__ - -Adds Request.__post_init__ that validates: -- url is a non-empty str -- headers, params, cookies, extensions are each a Mapping -- header and cookie names/values are non-empty str, no CR or LF - -with_* methods inherit validation via dataclasses.replace; no -per-method code needed. Header validation is minimal per spec -(reject CR/LF, non-str, empty); full RFC 9110 token validation is -out of scope. - -Closes deferred-work entries: "Header name/value validation", -"URL validation" (Request.url part), "with_query(None) handling". - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 3: `Timeout` + `Limits` negative-value validation - -**Goal:** Add `__post_init__` to `Timeout` and `Limits` in `config.py` that raises `ValueError` on negative field values. Zero is permitted. - -**Files:** -- Test: `tests/test_config.py` (add ~8 parametrized test cases) -- Modify: `src/httpware/config.py` (add two `__post_init__` methods) - -- [ ] **Step 1: Write failing tests** - -Add to `tests/test_config.py`, alongside the existing `Timeout`/`Limits` tests: - -```python -@pytest.mark.parametrize("field", ["connect", "read", "write", "pool"]) -def test_timeout_rejects_negative(field: str) -> None: - with pytest.raises(ValueError, match=f"Timeout.{field} must be non-negative"): - Timeout(**{field: -1.0}) - - -def test_timeout_accepts_zero() -> None: - # Zero is a valid sentinel (fail immediately on this phase). - Timeout(connect=0.0, read=0.0, write=0.0, pool=0.0) - - -@pytest.mark.parametrize("field", ["max_connections", "max_keepalive_connections"]) -def test_limits_rejects_negative_int(field: str) -> None: - with pytest.raises(ValueError, match=f"{field} must be non-negative"): - Limits(**{field: -1}) - - -def test_limits_rejects_negative_keepalive_expiry() -> None: - with pytest.raises(ValueError, match="keepalive_expiry must be non-negative"): - Limits(keepalive_expiry=-0.5) - - -def test_limits_accepts_zero() -> None: - Limits(max_connections=0, max_keepalive_connections=0, keepalive_expiry=0.0) -``` - -If `pytest` is not already imported at the top of `tests/test_config.py`, add `import pytest`. - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -just test tests/test_config.py -v 2>&1 | tail -20 -``` - -Expected: the new `_rejects_negative` tests FAIL (no validation yet). The `_accepts_zero` tests pass (current code already accepts zero). Pre-existing tests still pass. - -- [ ] **Step 3: Add `__post_init__` to `Timeout` and `Limits`** - -Edit `src/httpware/config.py`. Add `__post_init__` to the `Timeout` class (after the field declarations, before the next class): - -```python - def __post_init__(self) -> None: - for attr in ("connect", "read", "write", "pool"): - value = getattr(self, attr) - if value < 0: - msg = f"Timeout.{attr} must be non-negative (got {value})" - raise ValueError(msg) -``` - -Add `__post_init__` to the `Limits` class: - -```python - def __post_init__(self) -> None: - if self.max_connections < 0: - msg = f"max_connections must be non-negative (got {self.max_connections})" - raise ValueError(msg) - if self.max_keepalive_connections < 0: - msg = f"max_keepalive_connections must be non-negative (got {self.max_keepalive_connections})" - raise ValueError(msg) - if self.keepalive_expiry < 0: - msg = f"keepalive_expiry must be non-negative (got {self.keepalive_expiry})" - raise ValueError(msg) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -just test tests/test_config.py -v 2>&1 | tail -20 -``` - -Expected: all `test_config.py` tests pass. - -- [ ] **Step 5: Run full test suite + lint** - -```bash -just test -just lint-ci -``` - -Both exit 0. - -- [ ] **Step 6: Commit** - -```bash -git add src/httpware/config.py tests/test_config.py -git commit -m "$(cat <<'EOF' -fix: validate Timeout/Limits negatives in __post_init__ - -Both dataclasses now raise ValueError on construction if any field -is negative. Zero is permitted (Timeout zero = fail-immediately -sentinel; Limits zero = downstream's call on what it means, typically -"no limit"). - -Closes deferred-work entry: "Timeout / Limits negative-value -validation". - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 4: `ClientConfig.base_url` validation + normalization (+ remove redundant `rstrip` in `client.py`) - -**Goal:** Add `__post_init__` to `ClientConfig` that validates `base_url` (when not `None`) is a non-empty `str` and normalizes it by stripping a trailing slash. Then remove the now-redundant `base.rstrip("/")` in `AsyncClient._resolve_url` since the stored value is canonical. - -**Files:** -- Test: `tests/test_config.py` (add ~4 new test functions) -- Test: `tests/test_client_methods.py` (verify existing `base_url` behavior still works — no new test needed unless existing coverage is thin) -- Modify: `src/httpware/config.py` (add `ClientConfig.__post_init__`) -- Modify: `src/httpware/client.py:122` (remove `.rstrip("/")`) - -- [ ] **Step 1: Write failing tests** - -Add to `tests/test_config.py`: - -```python -def test_client_config_strips_trailing_slash_from_base_url() -> None: - cfg = ClientConfig(base_url="https://api.example.com/") - assert cfg.base_url == "https://api.example.com" - - -def test_client_config_leaves_base_url_without_trailing_slash() -> None: - cfg = ClientConfig(base_url="https://api.example.com") - assert cfg.base_url == "https://api.example.com" - - -def test_client_config_strips_multiple_trailing_slashes() -> None: - cfg = ClientConfig(base_url="https://api.example.com///") - assert cfg.base_url == "https://api.example.com" - - -def test_client_config_allows_none_base_url() -> None: - cfg = ClientConfig(base_url=None) - assert cfg.base_url is None - - -def test_client_config_rejects_empty_base_url() -> None: - with pytest.raises(ValueError, match="base_url must be a non-empty string or None"): - ClientConfig(base_url="") - - -def test_client_config_rejects_non_str_base_url() -> None: - with pytest.raises(ValueError, match="base_url must be a non-empty string or None"): - ClientConfig(base_url=123) # ty: ignore[invalid-argument-type] -``` - -Make sure `ClientConfig` is imported at the top of `test_config.py` (check via `grep -n 'ClientConfig' tests/test_config.py`; if not imported, add `from httpware.config import ClientConfig, Limits, Timeout` or extend the existing imports). - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -just test tests/test_config.py -v 2>&1 | tail -20 -``` - -Expected: the new `base_url` tests FAIL (no validation/normalization yet). The non-str rejection test may already raise something else (e.g., `AttributeError` later) — either way, the new tests fail. - -- [ ] **Step 3: Add `ClientConfig.__post_init__`** - -Edit `src/httpware/config.py`. Add `__post_init__` to the `ClientConfig` class: - -```python - def __post_init__(self) -> None: - if self.base_url is not None: - if not isinstance(self.base_url, str) or not self.base_url: - msg = "base_url must be a non-empty string or None" - raise ValueError(msg) - object.__setattr__(self, "base_url", self.base_url.rstrip("/")) -``` - -`object.__setattr__` is the standard pattern for mutating a frozen dataclass field inside `__post_init__`. Python's `dataclasses` documentation explicitly sanctions this. - -- [ ] **Step 4: Run `test_config.py` to verify it passes** - -```bash -just test tests/test_config.py -v 2>&1 | tail -20 -``` - -Expected: all `test_config.py` tests pass, including the new `base_url` ones. - -- [ ] **Step 5: Remove the redundant `rstrip` in `client.py`** - -Read the current `_resolve_url`: - -```bash -sed -n '116,123p' src/httpware/client.py -``` - -Expected: - -```python - def _resolve_url(self, path: str) -> str: - if path.startswith(("http://", "https://")): - return path - base = self._config.base_url - if base is None: - return path - return f"{base.rstrip('/')}/{path.lstrip('/')}" -``` - -Replace the last `return` line so that `base` is used directly (no `rstrip` call — the stored value is now canonical): - -```python - return f"{base}/{path.lstrip('/')}" -``` - -The full method should read: - -```python - def _resolve_url(self, path: str) -> str: - if path.startswith(("http://", "https://")): - return path - base = self._config.base_url - if base is None: - return path - return f"{base}/{path.lstrip('/')}" -``` - -- [ ] **Step 6: Run client-method tests to verify URL resolution still works** - -```bash -just test tests/test_client_methods.py -v 2>&1 | tail -10 -``` - -Expected: all pass. The existing tests cover both `base_url="https://api.example.com"` and `base_url="https://api.example.com/"` shapes; both should now produce the same resolved URL (`https://api.example.com/`). - -- [ ] **Step 7: Run full test suite + lint** - -```bash -just test -just lint-ci -``` - -Both exit 0. - -- [ ] **Step 8: Commit** - -```bash -git add src/httpware/config.py src/httpware/client.py tests/test_config.py -git commit -m "$(cat <<'EOF' -refactor: validate and normalize ClientConfig.base_url in __post_init__ - -ClientConfig.__post_init__ now: -- rejects empty string / non-str base_url with ValueError -- strips trailing slash so the stored value is canonical - -AsyncClient._resolve_url no longer does its own rstrip("/") on -base_url since the stored value is already canonical (DRY: one -source of truth for what a stored base_url looks like). - -Closes deferred-work entry: "URL validation" (base_url normalization -part; the Request.url non-empty check shipped in the prior Request -__post_init__ commit). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 5: Update `planning/deferred-work.md` - -**Goal:** Remove the five entries this PR closes from the Story 1-2 section. - -**Files:** -- Modify: `planning/deferred-work.md` - -**Entries to remove (all in the "Deferred from: code review of story-1-2 (2026-05-13)" section):** - -1. `**Charset parser robustness**` — closed by Task 1. -2. `**Header name/value validation**` — closed by Task 2 (headers + cookies). -3. `**URL validation**` — closed by Task 2 (`Request.url` non-empty) + Task 4 (`base_url` normalization). -4. `**`with_query(None)` handling**` — closed by Task 2 (Mapping-field validation). -5. `**`Timeout` / `Limits` negative-value validation**` — closed by Task 3. - -The remaining Story 1-2 bullets (multi-valued query params, streaming request bodies, `@final` to prevent subclassing) stay — they're different shapes of change, explicitly out of scope. - -- [ ] **Step 1: Read the current Story 1-2 section** - -```bash -grep -n -A 50 "code review of story-1-2 (2026-05-13)" planning/deferred-work.md -``` - -Confirm the five removal targets are present. - -- [ ] **Step 2: Delete each of the five bullets** - -Edit `planning/deferred-work.md` and remove these five bullet items from the Story 1-2 section (preserving the section header and the bullets that remain — multi-valued query params, streaming bodies, `@final`): - -- Charset parser robustness bullet (the one starting `- **Charset parser robustness**`) -- Header name/value validation bullet (starting `- **Header name/value validation**`) -- URL validation bullet (starting `- **URL validation**`) -- `with_query(None)` handling bullet (starting `- **`with_query(None)` handling**`) -- `Timeout` / `Limits` negative-value validation bullet (starting `- **`Timeout` / `Limits` negative-value validation**`) - -- [ ] **Step 3: Verify removals** - -```bash -grep -E 'Charset parser|Header name/value|URL validation|with_query|Timeout.*Limits.*negative' planning/deferred-work.md -``` - -Expected: empty output. - -- [ ] **Step 4: Verify remaining Story 1-2 bullets are intact** - -```bash -grep -n 'Multi-valued query params\|Streaming.*async-iterable\|@final to prevent subclassing' planning/deferred-work.md -``` - -Expected: three matches, all in the Story 1-2 section. - -- [ ] **Step 5: Run lint to confirm no formatting issues** - -```bash -just lint-ci -``` - -Expected: exits 0 (eof-fixer is happy; ruff has no opinion on markdown). - -- [ ] **Step 6: Commit** - -```bash -git add planning/deferred-work.md -git commit -m "$(cat <<'EOF' -docs: close deferred-work entries resolved by input-validation pass - -Removes five Story 1-2 entries closed by this PR: charset parser -robustness, header name/value validation, URL validation, with_query -(None) handling, Timeout/Limits negative-value validation. - -The remaining Story 1-2 entries (multi-valued query params, streaming -request bodies, @final subclassing) stay open — they're different -shapes of change, explicitly out of scope for this pass. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 6: Final verification - -**Goal:** Confirm the branch is internally consistent and PR-ready. - -- [ ] **Step 1: Full lint + test** - -```bash -just lint-ci && just test -``` - -Both exit 0. Test count should be 296 + new tests from Tasks 1-4 (roughly 30 new tests; expect somewhere around 326). - -- [ ] **Step 2: Confirm `Request.__post_init__` validation rules** - -Quick sanity check that the validation catches the specified inputs: - -```bash -just test tests/test_request.py -k "rejects" -v 2>&1 | tail -20 -``` - -Expected: all `*_rejects_*` tests in `test_request.py` pass. - -- [ ] **Step 3: Confirm closed deferred-work entries are gone** - -```bash -grep -E 'Charset parser|Header name/value|URL validation|with_query|Timeout.*Limits.*negative' planning/deferred-work.md -``` - -Expected: empty output. - -- [ ] **Step 4: Confirm no regression in pre-existing tests** - -```bash -just test tests/test_client_methods.py tests/test_middleware.py tests/test_transports_httpx2.py 2>&1 | tail -5 -``` - -Expected: all pass. These files have the most Request construction; if any broke from the new validation, this catches it. - -- [ ] **Step 5: Review commit log** - -```bash -git log --oneline main..HEAD -``` - -(Or `origin/main..HEAD` if `main` hasn't been synced.) - -Expected: five commits, newest first: - -``` - docs: close deferred-work entries resolved by input-validation pass - refactor: validate and normalize ClientConfig.base_url in __post_init__ - fix: validate Timeout/Limits negatives in __post_init__ - fix: validate Request fields in __post_init__ - fix: charset parser strips inner whitespace from quoted values -``` - -- [ ] **Step 6: PR readiness check** - -```bash -git diff --stat main -``` - -Expected ~5-7 files touched: - -- `src/httpware/request.py` -- `src/httpware/response.py` -- `src/httpware/config.py` -- `src/httpware/client.py` -- `tests/test_request.py` -- `tests/test_config.py` -- `tests/test_response.py` -- `planning/deferred-work.md` - -If any file outside this list shows up, investigate before pushing. - ---- - -## After completion - -Branch is ready to push and PR. Suggested PR title: - -> `chore: input-validation pass — Request/Timeout/Limits/ClientConfig __post_init__ guards + charset parser fix` - -PR description should reference the spec at `planning/specs/2026-06-03-input-validation-pass-design.md` and list the closed deferred-work entries. diff --git a/planning/changes/2026-06-03.02-thin-httpx2-wrapper/design.md b/planning/changes/2026-06-03.02-thin-httpx2-wrapper.md similarity index 100% rename from planning/changes/2026-06-03.02-thin-httpx2-wrapper/design.md rename to planning/changes/2026-06-03.02-thin-httpx2-wrapper.md diff --git a/planning/changes/2026-06-03.02-thin-httpx2-wrapper/plan.md b/planning/changes/2026-06-03.02-thin-httpx2-wrapper/plan.md deleted file mode 100644 index 4c65333..0000000 --- a/planning/changes/2026-06-03.02-thin-httpx2-wrapper/plan.md +++ /dev/null @@ -1,2523 +0,0 @@ -# Thin httpx2 wrapper (v0.2 pivot) 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:** Re-cut `httpware` as a thin opinionated wrapper around `httpx2`. Drop the `Transport` protocol, custom `Request`/`Response`/`Limits`/`Timeout`/`ClientConfig` value types, `RecordedTransport`, auth coercion, and `with_options`. Keep typed decoders, middleware chain, and the status-keyed exception tree. Ship as `0.2.0`. - -**Architecture:** `AsyncClient` owns (or wraps) an `httpx2.AsyncClient`. Per-method calls delegate `build_request` to httpx2, run the request through a middleware chain composed at `__init__`, hit an internal terminal that calls `httpx2.AsyncClient.send`, maps `httpx2` exceptions to `httpware` exceptions, and raises a `StatusError` subclass on 4xx/5xx. Decoders run after the chain if `response_model=` is set. Three protocol seams remain: `AsyncClient ↔ Middleware`, `AsyncClient ↔ ResponseDecoder`, `httpware ↔ optional extras`. - -**Tech Stack:** Python 3.11+, `httpx2`, `pydantic` (default decoder), `msgspec` (opt-in via extras), `ty` (type checker), `ruff` (linter), `pytest` + `pytest-asyncio` (auto mode), `hypothesis` (property tests), `uv` (package manager), `just` (task runner). - -**Spec:** `planning/specs/2026-06-03-thin-httpx2-wrapper-design.md` - -**Scope check:** Single structural PR per spec section 13. Epic 3 (resilience), Epic 4 (streaming), Epic 5 (observability) are explicitly out of scope and land later as ordinary stories. - ---- - -## File map - -**Surviving with edits:** - -- `src/httpware/__init__.py` — exports rewritten. -- `src/httpware/decoders/__init__.py` — unchanged (protocol stays). -- `src/httpware/decoders/pydantic.py` — unchanged. -- `src/httpware/decoders/msgspec.py` — unchanged. -- `src/httpware/_internal/import_checker.py` — unchanged. -- `tests/test_decoders_pydantic.py` — unchanged. -- `tests/test_decoders_msgspec.py` — unchanged. -- `tests/test_decoders_pydantic_bench.py` — unchanged (perf marker). -- `tests/test_optional_extras_isolation.py` — unchanged. -- `tests/conftest.py` — unchanged. -- `CLAUDE.md` — rewrite invariants + module layout sections. -- `docs/dev/engineering.md` — rewrite sections 2, 3, 5, 8. -- `planning/deferred-work.md` — sweep items obsoleted by pivot. -- `pyproject.toml` — bump version, drop the `httpx2 leakage` ruff/ty notes if present. - -**New files:** - -- `src/httpware/client.py` (full rewrite — delete & re-create). -- `src/httpware/errors.py` (full rewrite — delete & re-create). -- `src/httpware/middleware/__init__.py` (full rewrite — delete & re-create). -- `src/httpware/middleware/chain.py` (new file, holds `compose`). -- `tests/test_errors.py` (full rewrite — delete & re-create). -- `tests/test_middleware.py` (full rewrite — delete & re-create). -- `tests/test_client_construction.py` (full rewrite — delete & re-create). -- `tests/test_client_lifecycle.py` (full rewrite — delete & re-create). -- `tests/test_client_methods.py` (full rewrite — delete & re-create). -- `tests/test_client_middleware_wiring.py` (full rewrite — delete & re-create). -- `tests/test_client_response_model.py` (full rewrite — delete & re-create). -- `tests/test_client_typing.py` (full rewrite — delete & re-create). -- `tests/test_public_api.py` (full rewrite — delete & re-create). -- `tests/test_error_mapping_terminal.py` (new — terminal-level error translation). - -**Deleted:** - -- `src/httpware/request.py` -- `src/httpware/response.py` -- `src/httpware/config.py` -- `src/httpware/transports/` (entire directory) -- `src/httpware/_internal/auth.py` -- `src/httpware/_internal/chain.py` -- `tests/test_request.py` -- `tests/test_response.py` -- `tests/test_config.py` -- `tests/test_transports_httpx2.py` -- `tests/test_transports_recorded.py` -- `tests/test_internal_auth.py` -- `tests/test_no_httpx2_leakage.py` - ---- - -## Task 1: Pre-flight - -**Files:** -- Inspect: working tree, current branch, baseline test status. - -- [ ] **Step 1: Verify clean working tree on a fresh branch** - -Run: -```bash -git status -git switch -c feat/v0.2-thin-httpx2-wrapper -``` -Expected: working tree is clean; new branch created off `main`. - -- [ ] **Step 2: Capture the baseline pass/fail count** - -Run: -```bash -just install -just test 2>&1 | tail -20 -``` -Expected: full suite passes (100% coverage on the existing modules). Record the totals (e.g. "147 passed"). - -This is the "before" snapshot; we will track regressions against it. - -- [ ] **Step 3: Confirm `httpx2.MockTransport` exists in the installed version** - -Run: -```bash -uv run python -c "import httpx2; print(httpx2.MockTransport)" -``` -Expected: `` (no AttributeError). If absent, stop and tell the user — the pivot needs MockTransport for the testing pattern. - -- [ ] **Step 4: Commit a marker tag for rollback** - -Run: -```bash -git tag pre-v0.2-pivot -``` -Expected: tag created at the current `HEAD` (no commit yet on the feature branch). - ---- - -## Task 2: Tear-down (one explicit deletion commit) - -**Files:** -- Delete: `src/httpware/request.py`, `src/httpware/response.py`, `src/httpware/config.py`, `src/httpware/transports/`, `src/httpware/_internal/auth.py`, `src/httpware/_internal/chain.py`. -- Delete: `tests/test_request.py`, `tests/test_response.py`, `tests/test_config.py`, `tests/test_transports_httpx2.py`, `tests/test_transports_recorded.py`, `tests/test_internal_auth.py`, `tests/test_no_httpx2_leakage.py`. -- Stub: `src/httpware/__init__.py`, `src/httpware/client.py`, `src/httpware/errors.py`, `src/httpware/middleware/__init__.py`. -- Delete: `tests/test_errors.py`, `tests/test_middleware.py`, `tests/test_client_*.py`, `tests/test_public_api.py`. - -- [ ] **Step 1: Remove the deleted files** - -Run: -```bash -git rm src/httpware/request.py -git rm src/httpware/response.py -git rm src/httpware/config.py -git rm -r src/httpware/transports -git rm src/httpware/_internal/auth.py -git rm src/httpware/_internal/chain.py -git rm tests/test_request.py tests/test_response.py tests/test_config.py -git rm tests/test_transports_httpx2.py tests/test_transports_recorded.py -git rm tests/test_internal_auth.py tests/test_no_httpx2_leakage.py -git rm tests/test_errors.py tests/test_middleware.py -git rm tests/test_client_construction.py tests/test_client_lifecycle.py -git rm tests/test_client_methods.py tests/test_client_middleware_wiring.py -git rm tests/test_client_response_model.py tests/test_client_typing.py -git rm tests/test_public_api.py -``` - -- [ ] **Step 2: Stub the surviving package files to a minimal compilable state** - -Replace `src/httpware/__init__.py` with: -```python -"""httpware — thin async HTTP client wrapper over httpx2.""" -``` - -Replace `src/httpware/client.py` with: -```python -"""AsyncClient — implemented in later tasks of the v0.2 pivot.""" -``` - -Replace `src/httpware/errors.py` with: -```python -"""Exception hierarchy — implemented in later tasks of the v0.2 pivot.""" -``` - -Replace `src/httpware/middleware/__init__.py` with: -```python -"""Middleware protocol — implemented in later tasks of the v0.2 pivot.""" -``` - -- [ ] **Step 3: Confirm decoders + extras-isolation tests still pass** - -Run: -```bash -just test tests/test_decoders_pydantic.py tests/test_decoders_msgspec.py tests/test_optional_extras_isolation.py 2>&1 | tail -10 -``` -Expected: all three files pass. (These are the only tests that survive the tear-down.) - -- [ ] **Step 4: Commit the tear-down** - -Run: -```bash -git add -A -git commit -m "refactor(v0.2): tear down 0.1 surfaces ahead of thin-wrapper rewrite - -Remove Request/Response/Config value types, Transport protocol, -Httpx2Transport, RecordedTransport, auth coercion, and the no-leakage -CI invariant. Decoders survive. New AsyncClient/errors/middleware land -in subsequent commits." -``` - ---- - -## Task 3: Errors — failing tests - -**Files:** -- Create: `tests/test_errors.py` - -- [ ] **Step 1: Write the failing test file** - -Create `tests/test_errors.py`: -```python -"""Tests for the status-keyed exception tree in httpware.errors.""" - -import builtins -import pickle - -import httpx2 -import pytest - -from httpware.errors import ( - STATUS_TO_EXCEPTION, - BadRequestError, - ClientError, - ClientStatusError, - ConflictError, - ForbiddenError, - InternalServerError, - NotFoundError, - RateLimitedError, - ServerStatusError, - ServiceUnavailableError, - StatusError, - TimeoutError, # noqa: A004 - TransportError, - UnauthorizedError, - UnprocessableEntityError, -) - - -def _make_response(status: int, *, url: str = "https://example.test/x", method: str = "GET") -> httpx2.Response: - request = httpx2.Request(method, url) - return httpx2.Response(status, request=request) - - -def test_inheritance_tree() -> None: - assert issubclass(StatusError, ClientError) - assert issubclass(TransportError, ClientError) - assert issubclass(TimeoutError, ClientError) - assert issubclass(TimeoutError, builtins.TimeoutError) - assert issubclass(ClientStatusError, StatusError) - assert issubclass(ServerStatusError, StatusError) - for exc in ( - BadRequestError, - UnauthorizedError, - ForbiddenError, - NotFoundError, - ConflictError, - UnprocessableEntityError, - RateLimitedError, - ): - assert issubclass(exc, ClientStatusError), exc - for exc in (InternalServerError, ServiceUnavailableError): - assert issubclass(exc, ServerStatusError), exc - - -def test_status_to_exception_table() -> None: - assert STATUS_TO_EXCEPTION == { - 400: BadRequestError, - 401: UnauthorizedError, - 403: ForbiddenError, - 404: NotFoundError, - 409: ConflictError, - 422: UnprocessableEntityError, - 429: RateLimitedError, - 500: InternalServerError, - 503: ServiceUnavailableError, - } - - -def test_status_error_stores_response() -> None: - response = _make_response(404) - exc = NotFoundError(response) - assert exc.response is response - - -def test_status_error_summary_message_includes_status_method_url() -> None: - exc = NotFoundError(_make_response(404, url="https://example.test/missing", method="GET")) - assert str(exc) == "404 GET https://example.test/missing" - - -def test_status_error_strips_userinfo_in_summary_message() -> None: - exc = NotFoundError(_make_response(404, url="https://user:pass@example.test/x")) - assert "user" not in str(exc) - assert "pass" not in str(exc) - assert str(exc) == "404 GET https://example.test/x" - - -def test_status_error_repr_strips_userinfo() -> None: - exc = NotFoundError(_make_response(404, url="https://user:pass@example.test/x")) - r = repr(exc) - assert "user" not in r - assert "pass" not in r - assert "NotFoundError" in r - assert "status=404" in r - - -def test_status_error_pickleable() -> None: - exc = NotFoundError(_make_response(404, url="https://example.test/x")) - restored = pickle.loads(pickle.dumps(exc)) - assert isinstance(restored, NotFoundError) - assert restored.response.status_code == 404 - assert str(restored.response.request.url) == "https://example.test/x" - - -@pytest.mark.parametrize( - ("status", "expected"), - [ - (400, BadRequestError), - (401, UnauthorizedError), - (404, NotFoundError), - (429, RateLimitedError), - (500, InternalServerError), - (503, ServiceUnavailableError), - ], -) -def test_per_status_subclasses_construct(status: int, expected: type[StatusError]) -> None: - response = _make_response(status) - exc = expected(response) - assert isinstance(exc, expected) - assert exc.response.status_code == status - - -def test_timeout_error_is_builtin_timeout_error() -> None: - exc = TimeoutError("timed out") - assert isinstance(exc, builtins.TimeoutError) - assert isinstance(exc, ClientError) - - -def test_transport_error_is_client_error() -> None: - exc = TransportError("connection refused") - assert isinstance(exc, ClientError) -``` - -- [ ] **Step 2: Run the failing test** - -Run: -```bash -just test tests/test_errors.py 2>&1 | tail -15 -``` -Expected: collection error (`ImportError`) — the symbols don't exist yet in `errors.py`. - ---- - -## Task 4: Errors — implementation - -**Files:** -- Modify: `src/httpware/errors.py` - -- [ ] **Step 1: Replace the stub with the full exception module** - -Replace `src/httpware/errors.py` with: -```python -"""Status-keyed exception hierarchy. - -Auto-raise rule lives at AsyncClient's internal terminal (see client.py). -Unknown 4xx falls back to ClientStatusError; unknown 5xx to ServerStatusError. -The fallback assumes 400 <= status < 600. - -__repr__ and the summary message strip user:pass@ userinfo from -response.request.url to avoid leaking credentials in tracebacks. -Query-string secrets are NOT stripped here. -""" - -import builtins -from collections.abc import Mapping -from typing import Any -from urllib.parse import urlsplit, urlunsplit - -import httpx2 - - -def _strip_userinfo(url: str) -> str: - if "@" not in url or "://" not in url: - return url - parts = urlsplit(url) - if parts.username is None and parts.password is None: - return url - netloc = parts.hostname or "" - if parts.port is not None: - netloc = f"{netloc}:{parts.port}" - return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) - - -class ClientError(Exception): - """Root of the httpware exception tree.""" - - -class TransportError(ClientError): - """Connection / network / protocol failure raised before a response was received.""" - - -class TimeoutError(ClientError, builtins.TimeoutError): # noqa: A001 - """Client-side timeout (connect / read / write / pool). - - Inherits from both ``httpware.ClientError`` and ``builtins.TimeoutError`` so - ``except builtins.TimeoutError`` / ``except OSError`` (the form - ``asyncio.wait_for`` uses) also catches httpware-raised timeouts. - Deliberate shadowing of the builtin; do not rename. - """ - - -def _reconstruct_status_error(cls: "type[StatusError]", response: httpx2.Response) -> "StatusError": - return cls(response) - - -class StatusError(ClientError): - """Base for HTTP-status-keyed errors. - - Holds the raw httpx2.Response. Subclasses do not override __init__. - """ - - response: httpx2.Response - - def __init__(self, response: httpx2.Response) -> None: - self.response = response - super().__init__(self._summary()) - - def _summary(self) -> str: - method = self.response.request.method - url = _strip_userinfo(str(self.response.request.url)) - return f"{self.response.status_code} {method} {url}" - - def __repr__(self) -> str: - cls_name = type(self).__name__ - method = self.response.request.method - url = _strip_userinfo(str(self.response.request.url)) - return f"<{cls_name} status={self.response.status_code} method={method} url={url}>" - - def __reduce__(self) -> tuple[Any, ...]: - return (_reconstruct_status_error, (type(self), self.response)) - - -class ClientStatusError(StatusError): - """Base for 4xx HTTP status errors.""" - - -class ServerStatusError(StatusError): - """Base for 5xx HTTP status errors.""" - - -class BadRequestError(ClientStatusError): - """HTTP 400.""" - - -class UnauthorizedError(ClientStatusError): - """HTTP 401.""" - - -class ForbiddenError(ClientStatusError): - """HTTP 403.""" - - -class NotFoundError(ClientStatusError): - """HTTP 404.""" - - -class ConflictError(ClientStatusError): - """HTTP 409.""" - - -class UnprocessableEntityError(ClientStatusError): - """HTTP 422.""" - - -class RateLimitedError(ClientStatusError): - """HTTP 429.""" - - -class InternalServerError(ServerStatusError): - """HTTP 500.""" - - -class ServiceUnavailableError(ServerStatusError): - """HTTP 503.""" - - -STATUS_TO_EXCEPTION: Mapping[int, type[StatusError]] = { - 400: BadRequestError, - 401: UnauthorizedError, - 403: ForbiddenError, - 404: NotFoundError, - 409: ConflictError, - 422: UnprocessableEntityError, - 429: RateLimitedError, - 500: InternalServerError, - 503: ServiceUnavailableError, -} -``` - -- [ ] **Step 2: Run the test suite for errors** - -Run: -```bash -just test tests/test_errors.py 2>&1 | tail -10 -``` -Expected: all tests pass. - -- [ ] **Step 3: Lint** - -Run: -```bash -just lint 2>&1 | tail -10 -``` -Expected: zero issues. Fix any that surface (typically `D205` or similar — adjust docstrings inline). - -- [ ] **Step 4: Commit** - -Run: -```bash -git add src/httpware/errors.py tests/test_errors.py -git commit -m "feat(errors): status-keyed exception tree holding httpx2.Response" -``` - ---- - -## Task 5: Middleware — failing tests - -**Files:** -- Create: `tests/test_middleware.py` - -- [ ] **Step 1: Write the failing test file** - -Create `tests/test_middleware.py`: -```python -"""Tests for the Middleware protocol, Next type, chain composition, and decorators.""" - -import httpx2 -import pytest - -from httpware.middleware import ( - Middleware, - Next, - after_response, - before_request, - on_error, -) -from httpware.middleware.chain import compose - - -def _make_request(url: str = "https://example.test/x") -> httpx2.Request: - return httpx2.Request("GET", url) - - -def _make_response(status: int = 200, *, request: httpx2.Request | None = None) -> httpx2.Response: - if request is None: - request = _make_request() - return httpx2.Response(status, request=request) - - -async def test_middleware_protocol_is_runtime_checkable() -> None: - class _OkMiddleware: - async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 - return await next(request) - - assert isinstance(_OkMiddleware(), Middleware) - - -async def test_empty_chain_calls_terminal_directly() -> None: - seen: list[httpx2.Request] = [] - - async def terminal(request: httpx2.Request) -> httpx2.Response: - seen.append(request) - return _make_response(200, request=request) - - dispatch = compose((), terminal) - request = _make_request() - response = await dispatch(request) - assert response.status_code == 200 - assert seen == [request] - - -async def test_chain_runs_middleware_in_order() -> None: - order: list[str] = [] - - class _M: - def __init__(self, label: str) -> None: - self.label = label - - async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 - order.append(f"{self.label}.before") - response = await next(request) - order.append(f"{self.label}.after") - return response - - async def terminal(request: httpx2.Request) -> httpx2.Response: - order.append("terminal") - return _make_response(200, request=request) - - dispatch = compose((_M("a"), _M("b")), terminal) - await dispatch(_make_request()) - assert order == ["a.before", "b.before", "terminal", "b.after", "a.after"] - - -async def test_before_request_decorator_transforms_request() -> None: - @before_request - async def add_header(request: httpx2.Request) -> httpx2.Request: - return httpx2.Request( - request.method, request.url, headers={**request.headers, "X-Custom": "1"} - ) - - captured: list[httpx2.Request] = [] - - async def terminal(request: httpx2.Request) -> httpx2.Response: - captured.append(request) - return _make_response(200, request=request) - - dispatch = compose((add_header,), terminal) - await dispatch(_make_request()) - assert captured[0].headers["x-custom"] == "1" - - -async def test_after_response_decorator_transforms_response() -> None: - @after_response - async def upgrade_status(request: httpx2.Request, response: httpx2.Response) -> httpx2.Response: - return httpx2.Response(299, request=request, headers=response.headers, content=response.content) - - async def terminal(request: httpx2.Request) -> httpx2.Response: - return _make_response(200, request=request) - - dispatch = compose((upgrade_status,), terminal) - response = await dispatch(_make_request()) - assert response.status_code == 299 - - -async def test_on_error_decorator_can_translate_exception() -> None: - @on_error - async def swallow(request: httpx2.Request, exc: Exception) -> httpx2.Response | None: - if isinstance(exc, RuntimeError) and str(exc) == "boom": - return _make_response(503, request=request) - return None - - async def terminal(request: httpx2.Request) -> httpx2.Response: - msg = "boom" - raise RuntimeError(msg) - - dispatch = compose((swallow,), terminal) - response = await dispatch(_make_request()) - assert response.status_code == 503 - - -async def test_on_error_returns_none_reraises() -> None: - @on_error - async def passthrough( - request: httpx2.Request, # noqa: ARG001 - exc: Exception, # noqa: ARG001 - ) -> httpx2.Response | None: - return None - - async def terminal(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - msg = "boom" - raise RuntimeError(msg) - - dispatch = compose((passthrough,), terminal) - with pytest.raises(RuntimeError, match="boom"): - await dispatch(_make_request()) - - -async def test_on_error_lets_cancelled_propagate() -> None: - import asyncio - - @on_error - async def swallow_all( - request: httpx2.Request, # noqa: ARG001 - exc: Exception, # noqa: ARG001 - ) -> httpx2.Response | None: - msg = "should not catch CancelledError" - raise AssertionError(msg) - - async def terminal(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - raise asyncio.CancelledError - - dispatch = compose((swallow_all,), terminal) - with pytest.raises(asyncio.CancelledError): - await dispatch(_make_request()) -``` - -- [ ] **Step 2: Run the failing tests** - -Run: -```bash -just test tests/test_middleware.py 2>&1 | tail -15 -``` -Expected: `ImportError` or collection failure — `Middleware`, `Next`, `compose`, decorators don't exist yet. - ---- - -## Task 6: Middleware — implementation - -**Files:** -- Modify: `src/httpware/middleware/__init__.py` -- Create: `src/httpware/middleware/chain.py` - -- [ ] **Step 1: Implement the protocol and decorators** - -Replace `src/httpware/middleware/__init__.py` with: -```python -"""Middleware protocol, Next type, and phase-shortcut decorators. - -Middleware operates directly on httpx2.Request / httpx2.Response — there is -no httpware-owned request type. The chain is composed at AsyncClient.__init__ -(see client.py) and frozen for the client's lifetime. -""" - -from collections.abc import Awaitable, Callable -from typing import Protocol, TypeAlias, runtime_checkable - -import httpx2 - -from httpware.middleware.chain import compose - - -Next: TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Response]] - - -@runtime_checkable -class Middleware(Protocol): - """Structural protocol every middleware satisfies.""" - - async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 - """Process `request`; call `next(request)` to forward, or synthesize a Response.""" - ... - - -def before_request(f: Callable[[httpx2.Request], Awaitable[httpx2.Request]]) -> Middleware: - """Wrap an async request transform into a Middleware.""" - - class _BeforeRequestMiddleware: - async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.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[[httpx2.Request, httpx2.Response], Awaitable[httpx2.Response]], -) -> Middleware: - """Wrap an async response transform into a Middleware.""" - - class _AfterResponseMiddleware: - async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.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[[httpx2.Request, Exception], Awaitable[httpx2.Response | None]], -) -> Middleware: - """Wrap an async error handler into a Middleware. - - Catches Exception (not BaseException), so asyncio.CancelledError propagates. - Handler returning None re-raises; returning a Response replaces the failure. - """ - - class _OnErrorMiddleware: - async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.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() -``` - -- [ ] **Step 2: Implement chain composition** - -Create `src/httpware/middleware/chain.py`: -```python -"""Chain composition for the middleware stack.""" - -from collections.abc import Awaitable, Callable, Sequence -from typing import TYPE_CHECKING, TypeAlias - -import httpx2 - - -if TYPE_CHECKING: - from httpware.middleware import Middleware - - -_Next: TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Response]] - - -def compose(middleware: "Sequence[Middleware]", terminal: _Next) -> _Next: - """Fold `middleware` into a single callable around `terminal`. - - The first middleware in the sequence is the outermost wrapper. - """ - dispatch: _Next = terminal - for layer in reversed(middleware): - dispatch = _wrap(layer, dispatch) - return dispatch - - -def _wrap(layer: "Middleware", inner: _Next) -> _Next: - async def call(request: httpx2.Request) -> httpx2.Response: - return await layer(request, inner) - - return call -``` - -- [ ] **Step 3: Run middleware tests** - -Run: -```bash -just test tests/test_middleware.py 2>&1 | tail -10 -``` -Expected: all tests pass. - -- [ ] **Step 4: Lint** - -Run: -```bash -just lint 2>&1 | tail -10 -``` -Expected: zero issues. The `if TYPE_CHECKING:` in `chain.py` avoids a circular import (`chain.py` is imported by `middleware/__init__.py`); keep it. - -- [ ] **Step 5: Commit** - -Run: -```bash -git add src/httpware/middleware tests/test_middleware.py -git commit -m "feat(middleware): protocol and chain retyped on httpx2.Request/Response" -``` - ---- - -## Task 7: AsyncClient — failing tests for construction & ownership - -**Files:** -- Create: `tests/test_client_construction.py` - -- [ ] **Step 1: Write the failing tests** - -Create `tests/test_client_construction.py`: -```python -"""Tests for AsyncClient construction and ownership semantics.""" - -import httpx2 -import pytest - -from httpware import AsyncClient - - -def test_construction_with_no_args_works() -> None: - client = AsyncClient() - assert isinstance(client, AsyncClient) - - -def test_construction_with_forwarded_kwargs() -> None: - client = AsyncClient( - base_url="https://example.test", - headers={"x-shared": "1"}, - params={"trace": "yes"}, - timeout=10.0, - ) - assert isinstance(client, AsyncClient) - - -def test_construction_with_caller_owned_httpx2_client() -> None: - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) - caller = httpx2.AsyncClient(transport=transport) - client = AsyncClient(httpx2_client=caller) - assert isinstance(client, AsyncClient) - - -@pytest.mark.parametrize( - "kwargs", - [ - {"base_url": "https://example.test"}, - {"headers": {"x": "1"}}, - {"params": {"x": "1"}}, - {"cookies": {"x": "1"}}, - {"timeout": 5.0}, - {"limits": httpx2.Limits(max_connections=10)}, - {"auth": httpx2.BasicAuth("u", "p")}, - ], -) -def test_caller_owned_client_with_forwarded_kwargs_is_typeerror(kwargs: dict) -> None: - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) - caller = httpx2.AsyncClient(transport=transport) - with pytest.raises(TypeError, match="httpx2_client"): - AsyncClient(httpx2_client=caller, **kwargs) - - -def test_default_decoder_is_pydantic_decoder() -> None: - from httpware.decoders.pydantic import PydanticDecoder - - client = AsyncClient() - assert isinstance(client._decoder, PydanticDecoder) # noqa: SLF001 - - -def test_explicit_decoder_is_honored() -> None: - class _Stub: - def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 - return None - - client = AsyncClient(decoder=_Stub()) - assert isinstance(client._decoder, _Stub) # noqa: SLF001 - - -def test_explicit_middleware_is_honored() -> None: - captured: list[str] = [] - - class _Tag: - async def __call__(self, request, next): # noqa: A002, ANN001 - captured.append("tag") - return await next(request) - - client = AsyncClient(middleware=(_Tag(),)) - assert client._user_middleware == (client._user_middleware[0],) # noqa: SLF001 - assert len(client._user_middleware) == 1 # noqa: SLF001 -``` - -- [ ] **Step 2: Run the failing tests** - -Run: -```bash -just test tests/test_client_construction.py 2>&1 | tail -15 -``` -Expected: `ImportError` — `AsyncClient` doesn't exist yet. - ---- - -## Task 8: AsyncClient — construction implementation - -**Files:** -- Modify: `src/httpware/client.py` - -- [ ] **Step 1: Implement `AsyncClient.__init__` and the terminal/chain wiring** - -Replace `src/httpware/client.py` with: -```python -"""AsyncClient — the thin httpx2 wrapper.""" - -import typing -from collections.abc import Sequence - -import httpx2 - -from httpware.decoders import ResponseDecoder -from httpware.decoders.pydantic import PydanticDecoder -from httpware.errors import ( - STATUS_TO_EXCEPTION, - ClientStatusError, - ServerStatusError, - TimeoutError, # noqa: A004 - TransportError, -) -from httpware.middleware import Middleware, Next -from httpware.middleware.chain import compose - - -T = typing.TypeVar("T") - - -_FORWARDED_KWARG_NAMES = ("base_url", "headers", "params", "cookies", "timeout", "limits", "auth") -_HTTPX2_CLIENT_CONFLICT_MESSAGE = ( - "AsyncClient(httpx2_client=...) cannot be combined with any of " - f"{_FORWARDED_KWARG_NAMES}; configure the httpx2.AsyncClient you pass instead." -) - - -class AsyncClient: - """Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware.""" - - _httpx2_client: httpx2.AsyncClient - _owns_client: bool - _decoder: ResponseDecoder - _user_middleware: tuple[Middleware, ...] - _dispatch: Next - - def __init__( # noqa: PLR0913 - self, - *, - base_url: str = "", - headers: dict[str, str] | None = None, - params: dict[str, str] | None = None, - cookies: dict[str, str] | None = None, - timeout: httpx2.Timeout | float | None = None, - limits: httpx2.Limits | None = None, - auth: httpx2.Auth | None = None, - httpx2_client: httpx2.AsyncClient | None = None, - decoder: ResponseDecoder | None = None, - middleware: Sequence[Middleware] = (), - ) -> None: - if httpx2_client is not None: - forwarded = { - "base_url": base_url, - "headers": headers, - "params": params, - "cookies": cookies, - "timeout": timeout, - "limits": limits, - "auth": auth, - } - if any(value not in (None, "") for value in forwarded.values()): - raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE) - self._httpx2_client = httpx2_client - self._owns_client = False - else: - kwargs: dict[str, typing.Any] = {} - if base_url: - kwargs["base_url"] = base_url - if headers is not None: - kwargs["headers"] = headers - if params is not None: - kwargs["params"] = params - if cookies is not None: - kwargs["cookies"] = cookies - if timeout is not None: - kwargs["timeout"] = timeout - if limits is not None: - kwargs["limits"] = limits - if auth is not None: - kwargs["auth"] = auth - self._httpx2_client = httpx2.AsyncClient(**kwargs) - self._owns_client = True - - self._decoder = decoder if decoder is not None else PydanticDecoder() - self._user_middleware = tuple(middleware) - self._dispatch = compose(self._user_middleware, self._terminal) - - async def _terminal(self, request: httpx2.Request) -> httpx2.Response: - try: - response = await self._httpx2_client.send(request) - except httpx2.TimeoutException as exc: - raise TimeoutError(str(exc)) from exc - except (httpx2.InvalidURL, httpx2.CookieConflict) as exc: - raise TransportError(str(exc)) from exc - except httpx2.HTTPError as exc: - raise TransportError(str(exc)) from exc - except RuntimeError as exc: - if "closed" in str(exc): - raise TransportError(str(exc)) from exc - raise - status = response.status_code - if 400 <= status < 600: # noqa: PLR2004 - exc_class = STATUS_TO_EXCEPTION.get( - status, - ClientStatusError if status < 500 else ServerStatusError, # noqa: PLR2004 - ) - raise exc_class(response) - return response -``` - -- [ ] **Step 2: Wire AsyncClient into the public package** - -Replace `src/httpware/__init__.py` with: -```python -"""httpware — thin async HTTP client wrapper over httpx2.""" - -from httpware.client import AsyncClient -from httpware.decoders import ResponseDecoder -from httpware.decoders.pydantic import PydanticDecoder -from httpware.errors import ( - STATUS_TO_EXCEPTION, - BadRequestError, - ClientError, - ClientStatusError, - ConflictError, - ForbiddenError, - InternalServerError, - NotFoundError, - RateLimitedError, - ServerStatusError, - ServiceUnavailableError, - StatusError, - TimeoutError, # noqa: A004 - TransportError, - UnauthorizedError, - UnprocessableEntityError, -) -from httpware.middleware import Middleware, Next, after_response, before_request, on_error - - -__all__ = [ - "STATUS_TO_EXCEPTION", - "AsyncClient", - "BadRequestError", - "ClientError", - "ClientStatusError", - "ConflictError", - "ForbiddenError", - "InternalServerError", - "Middleware", - "Next", - "NotFoundError", - "PydanticDecoder", - "RateLimitedError", - "ResponseDecoder", - "ServerStatusError", - "ServiceUnavailableError", - "StatusError", - "TimeoutError", - "TransportError", - "UnauthorizedError", - "UnprocessableEntityError", - "after_response", - "before_request", - "on_error", -] -``` - -- [ ] **Step 3: Run the construction tests** - -Run: -```bash -just test tests/test_client_construction.py 2>&1 | tail -10 -``` -Expected: all tests pass. - -- [ ] **Step 4: Lint** - -Run: -```bash -just lint 2>&1 | tail -10 -``` -Expected: zero issues. - -- [ ] **Step 5: Commit** - -Run: -```bash -git add src/httpware/client.py src/httpware/__init__.py tests/test_client_construction.py -git commit -m "feat(client): AsyncClient construction and ownership semantics" -``` - ---- - -## Task 9: AsyncClient — failing tests for `send()` and the terminal error path - -**Files:** -- Create: `tests/test_error_mapping_terminal.py` - -- [ ] **Step 1: Write the failing tests** - -Create `tests/test_error_mapping_terminal.py`: -```python -"""Tests for the AsyncClient internal terminal's exception mapping.""" - -import httpx2 -import pytest - -from httpware import ( - AsyncClient, - BadRequestError, - InternalServerError, - NotFoundError, - RateLimitedError, - ServerStatusError, - TimeoutError, # noqa: A004 - TransportError, -) - - -def _client_with_handler(handler) -> AsyncClient: # noqa: ANN001 - transport = httpx2.MockTransport(handler) - return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - - -async def test_terminal_returns_response_on_2xx() -> None: - client = _client_with_handler(lambda req: httpx2.Response(200, json={"ok": True}, request=req)) - response = await client.send(httpx2.Request("GET", "https://example.test/x")) - assert response.status_code == 200 - assert response.json() == {"ok": True} - - -@pytest.mark.parametrize( - ("status", "exc_type"), - [ - (400, BadRequestError), - (404, NotFoundError), - (429, RateLimitedError), - (500, InternalServerError), - ], -) -async def test_known_status_codes_raise_typed_subclass(status: int, exc_type: type) -> None: - client = _client_with_handler(lambda req: httpx2.Response(status, request=req)) - with pytest.raises(exc_type) as info: - await client.send(httpx2.Request("GET", "https://example.test/x")) - assert info.value.response.status_code == status - - -async def test_unknown_4xx_falls_back_to_client_status_error() -> None: - from httpware import ClientStatusError - - client = _client_with_handler(lambda req: httpx2.Response(418, request=req)) - with pytest.raises(ClientStatusError) as info: - await client.send(httpx2.Request("GET", "https://example.test/x")) - assert info.value.response.status_code == 418 - assert type(info.value) is ClientStatusError - - -async def test_unknown_5xx_falls_back_to_server_status_error() -> None: - client = _client_with_handler(lambda req: httpx2.Response(599, request=req)) - with pytest.raises(ServerStatusError) as info: - await client.send(httpx2.Request("GET", "https://example.test/x")) - assert info.value.response.status_code == 599 - assert type(info.value) is ServerStatusError - - -async def test_3xx_does_not_raise() -> None: - client = _client_with_handler(lambda req: httpx2.Response(301, request=req, headers={"location": "/y"})) - response = await client.send(httpx2.Request("GET", "https://example.test/x")) - assert response.status_code == 301 - - -async def test_httpx2_timeout_maps_to_httpware_timeout() -> None: - def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - msg = "read timeout" - raise httpx2.ReadTimeout(msg) - - client = _client_with_handler(handler) - with pytest.raises(TimeoutError, match="read timeout"): - await client.send(httpx2.Request("GET", "https://example.test/x")) - - -async def test_httpx2_connect_error_maps_to_transport_error() -> None: - def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - msg = "connect refused" - raise httpx2.ConnectError(msg) - - client = _client_with_handler(handler) - with pytest.raises(TransportError, match="connect refused"): - await client.send(httpx2.Request("GET", "https://example.test/x")) - - -async def test_send_on_closed_client_raises_transport_error() -> None: - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) - underlying = httpx2.AsyncClient(transport=transport) - client = AsyncClient(httpx2_client=underlying) - await underlying.aclose() - with pytest.raises(TransportError): - await client.send(httpx2.Request("GET", "https://example.test/x")) -``` - -- [ ] **Step 2: Run the failing tests** - -Run: -```bash -just test tests/test_error_mapping_terminal.py 2>&1 | tail -15 -``` -Expected: failures — `AsyncClient.send` doesn't exist yet. - ---- - -## Task 10: AsyncClient — `send()` implementation - -**Files:** -- Modify: `src/httpware/client.py` - -- [ ] **Step 1: Add `send()` and `build_request()` to the existing `AsyncClient`** - -Append the following methods to the `AsyncClient` class in `src/httpware/client.py` (insert immediately after `_terminal`): - -```python - @typing.overload - async def send(self, request: httpx2.Request, *, response_model: None = None) -> httpx2.Response: ... - - @typing.overload - async def send(self, request: httpx2.Request, *, response_model: type[T]) -> T: ... - - async def send( - self, - request: httpx2.Request, - *, - response_model: type[T] | None = None, - ) -> httpx2.Response | T: - """Send `request` through the middleware chain. Decode if `response_model` is set.""" - response = await self._dispatch(request) - if response_model is None: - return response - return self._decoder.decode(response.content, response_model) - - def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request: - """Delegate request construction to the wrapped httpx2.AsyncClient.""" - return self._httpx2_client.build_request(method, url, **kwargs) -``` - -- [ ] **Step 2: Run the terminal-error-mapping tests** - -Run: -```bash -just test tests/test_error_mapping_terminal.py 2>&1 | tail -10 -``` -Expected: all tests pass. - -- [ ] **Step 3: Lint** - -Run: -```bash -just lint 2>&1 | tail -10 -``` -Expected: zero issues. If `ty` complains about the `**kwargs: typing.Any` shape, adjust the kwargs annotation as ruff/ty advise — `httpx2.AsyncClient.build_request` is the upstream type oracle. - -- [ ] **Step 4: Commit** - -Run: -```bash -git add src/httpware/client.py tests/test_error_mapping_terminal.py -git commit -m "feat(client): send() + build_request(), terminal error mapping" -``` - ---- - -## Task 11: Per-method surface — failing tests - -**Files:** -- Create: `tests/test_client_methods.py` - -- [ ] **Step 1: Write the failing tests** - -Create `tests/test_client_methods.py`: -```python -"""Tests for the per-method API surface of AsyncClient.""" - -import httpx2 -import pytest - -from httpware import AsyncClient, NotFoundError - - -def _echo_handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response( - 200, - request=request, - json={ - "method": request.method, - "url": str(request.url), - "headers": dict(request.headers), - "content": request.content.decode() if request.content else "", - }, - ) - - -def _client_with_handler(handler, **kwargs) -> AsyncClient: # noqa: ANN001, ANN003 - transport = httpx2.MockTransport(handler) - return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport, **kwargs)) - - -async def test_get_returns_httpx2_response() -> None: - client = _client_with_handler(_echo_handler) - response = await client.get("https://example.test/x") - assert isinstance(response, httpx2.Response) - assert response.json()["method"] == "GET" - - -@pytest.mark.parametrize( - "method_name", - ["get", "post", "put", "patch", "delete", "head", "options"], -) -async def test_each_per_method_helper_exists_and_uses_correct_verb(method_name: str) -> None: - client = _client_with_handler(_echo_handler) - method = getattr(client, method_name) - response = await method("https://example.test/x") - assert response.json()["method"] == method_name.upper() - - -async def test_post_json_body_serialized() -> None: - client = _client_with_handler(_echo_handler) - response = await client.post("https://example.test/x", json={"k": "v"}) - payload = response.json() - assert "application/json" in payload["headers"]["content-type"] - assert payload["content"] == '{"k": "v"}' - - -async def test_get_with_params_forwards_query() -> None: - captured: list[httpx2.Request] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - captured.append(request) - return httpx2.Response(200, request=request) - - client = _client_with_handler(handler) - await client.get("https://example.test/x", params={"a": "1"}) - assert "a=1" in str(captured[0].url) - - -async def test_get_with_headers_merges() -> None: - captured: list[httpx2.Request] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - captured.append(request) - return httpx2.Response(200, request=request) - - client = _client_with_handler(handler) - await client.get("https://example.test/x", headers={"x-trace": "abc"}) - assert captured[0].headers["x-trace"] == "abc" - - -async def test_get_raises_typed_status_error_on_404() -> None: - client = _client_with_handler(lambda req: httpx2.Response(404, request=req)) - with pytest.raises(NotFoundError): - await client.get("https://example.test/missing") - - -async def test_request_method_takes_arbitrary_verb() -> None: - client = _client_with_handler(_echo_handler) - response = await client.request("PROPFIND", "https://example.test/x") - assert response.json()["method"] == "PROPFIND" - - -async def test_base_url_is_applied() -> None: - captured: list[httpx2.Request] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - captured.append(request) - return httpx2.Response(200, request=request) - - transport = httpx2.MockTransport(handler) - underlying = httpx2.AsyncClient(transport=transport, base_url="https://example.test") - client = AsyncClient(httpx2_client=underlying) - await client.get("/relative") - assert str(captured[0].url) == "https://example.test/relative" -``` - -- [ ] **Step 2: Run the failing tests** - -Run: -```bash -just test tests/test_client_methods.py 2>&1 | tail -15 -``` -Expected: failures — per-method helpers don't exist yet. - ---- - -## Task 12: Per-method surface — implementation - -**Files:** -- Modify: `src/httpware/client.py` - -- [ ] **Step 1: Add per-method helpers** - -Append the following block to the `AsyncClient` class in `src/httpware/client.py`, immediately after `build_request`. Each method has two overloads + the runtime body. To keep this task tractable, the methods share a private helper: - -```python - async def _request_with_body( - self, - method: str, - url: str, - *, - params: typing.Any | None = None, - headers: typing.Any | None = None, - cookies: typing.Any | None = None, - timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, - extensions: typing.Any | None = None, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, - response_model: type[T] | None = None, - ) -> httpx2.Response | T: - kwargs: dict[str, typing.Any] = {} - if params is not None: - kwargs["params"] = params - if headers is not None: - kwargs["headers"] = headers - if cookies is not None: - kwargs["cookies"] = cookies - if timeout is not httpx2.USE_CLIENT_DEFAULT: - kwargs["timeout"] = timeout - if extensions is not None: - kwargs["extensions"] = extensions - if json is not None: - kwargs["json"] = json - if content is not None: - kwargs["content"] = content - if data is not None: - kwargs["data"] = data - if files is not None: - kwargs["files"] = files - request = self._httpx2_client.build_request(method, url, **kwargs) - return await self.send(request, response_model=response_model) -``` - -Then add the eight per-method helpers. Pattern (full code shown for `get`, `post`; identical shape for `put`, `patch`, `delete`, `head`, `options`): - -```python - @typing.overload - async def get( - self, - url: str, - *, - params: typing.Any | None = None, - headers: typing.Any | None = None, - cookies: typing.Any | None = None, - timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, - extensions: typing.Any | None = None, - response_model: None = None, - ) -> httpx2.Response: ... - - @typing.overload - async def get( - self, - url: str, - *, - params: typing.Any | None = None, - headers: typing.Any | None = None, - cookies: typing.Any | None = None, - timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, - extensions: typing.Any | None = None, - response_model: type[T], - ) -> T: ... - - async def get( - self, - url: str, - *, - params: typing.Any | None = None, - headers: typing.Any | None = None, - cookies: typing.Any | None = None, - timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, - extensions: typing.Any | None = None, - response_model: type[T] | None = None, - ) -> httpx2.Response | T: - """Send a GET request.""" - return await self._request_with_body( - "GET", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - response_model=response_model, - ) - - @typing.overload - async def post( - self, - url: str, - *, - params: typing.Any | None = None, - headers: typing.Any | None = None, - cookies: typing.Any | None = None, - timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, - extensions: typing.Any | None = None, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, - response_model: None = None, - ) -> httpx2.Response: ... - - @typing.overload - async def post( - self, - url: str, - *, - params: typing.Any | None = None, - headers: typing.Any | None = None, - cookies: typing.Any | None = None, - timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, - extensions: typing.Any | None = None, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, - response_model: type[T], - ) -> T: ... - - async def post( - self, - url: str, - *, - params: typing.Any | None = None, - headers: typing.Any | None = None, - cookies: typing.Any | None = None, - timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, - extensions: typing.Any | None = None, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, - response_model: type[T] | None = None, - ) -> httpx2.Response | T: - """Send a POST request.""" - return await self._request_with_body( - "POST", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) -``` - -Repeat the `post` shape for `put` (`"PUT"`), `patch` (`"PATCH"`), and `delete` (`"DELETE"`). For `head`, `options`, copy the `get` shape (no body kwargs). - -Add `request` (arbitrary verb) using the post-shape with an explicit `method` first parameter: - -```python - @typing.overload - async def request( - self, - method: str, - url: str, - *, - params: typing.Any | None = None, - headers: typing.Any | None = None, - cookies: typing.Any | None = None, - timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, - extensions: typing.Any | None = None, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, - response_model: None = None, - ) -> httpx2.Response: ... - - @typing.overload - async def request( - self, - method: str, - url: str, - *, - params: typing.Any | None = None, - headers: typing.Any | None = None, - cookies: typing.Any | None = None, - timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, - extensions: typing.Any | None = None, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, - response_model: type[T], - ) -> T: ... - - async def request( - self, - method: str, - url: str, - *, - params: typing.Any | None = None, - headers: typing.Any | None = None, - cookies: typing.Any | None = None, - timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, - extensions: typing.Any | None = None, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, - response_model: type[T] | None = None, - ) -> httpx2.Response | T: - """Send a request with an arbitrary HTTP method.""" - return await self._request_with_body( - method, - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - extensions=extensions, - json=json, - content=content, - data=data, - files=files, - response_model=response_model, - ) -``` - -- [ ] **Step 2: Run per-method tests** - -Run: -```bash -just test tests/test_client_methods.py 2>&1 | tail -10 -``` -Expected: all pass. - -- [ ] **Step 3: Lint** - -Run: -```bash -just lint 2>&1 | tail -10 -``` -Expected: zero issues. `pylint.max-args = 10` is already set in `pyproject.toml`. The per-file `ASYNC109` ignore for `src/httpware/client.py` is already declared. - -- [ ] **Step 4: Commit** - -Run: -```bash -git add src/httpware/client.py tests/test_client_methods.py -git commit -m "feat(client): per-method API surface (get/post/put/patch/delete/head/options/request)" -``` - ---- - -## Task 13: Response-model decoding — failing tests - -**Files:** -- Create: `tests/test_client_response_model.py` - -- [ ] **Step 1: Write the failing tests** - -Create `tests/test_client_response_model.py`: -```python -"""Tests for response_model decoding integration.""" - -import httpx2 -import pydantic -import pytest - -from httpware import AsyncClient - - -class _User(pydantic.BaseModel): - id: int - name: str - - -def _client_with_payload(payload: bytes, content_type: str = "application/json") -> AsyncClient: - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, content=payload, headers={"content-type": content_type}, request=request) - - transport = httpx2.MockTransport(handler) - return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - - -async def test_get_with_response_model_returns_typed_object() -> None: - client = _client_with_payload(b'{"id": 1, "name": "ada"}') - user = await client.get("https://example.test/u", response_model=_User) - assert isinstance(user, _User) - assert user == _User(id=1, name="ada") - - -async def test_post_with_response_model_returns_typed_object() -> None: - client = _client_with_payload(b'{"id": 2, "name": "bob"}') - user = await client.post("https://example.test/u", json={"name": "bob"}, response_model=_User) - assert isinstance(user, _User) - - -async def test_send_with_response_model_returns_typed_object() -> None: - client = _client_with_payload(b'{"id": 3, "name": "cat"}') - request = client.build_request("GET", "https://example.test/u") - user = await client.send(request, response_model=_User) - assert isinstance(user, _User) - - -async def test_decoder_validation_error_propagates_unwrapped() -> None: - client = _client_with_payload(b'{"id": "not-an-int", "name": "x"}') - with pytest.raises(pydantic.ValidationError): - await client.get("https://example.test/u", response_model=_User) - - -async def test_status_error_raised_before_decoder_runs() -> None: - from httpware import NotFoundError - - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(404, content=b'{"id": 1, "name": "x"}', request=request) - - transport = httpx2.MockTransport(handler) - client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - with pytest.raises(NotFoundError): - await client.get("https://example.test/u", response_model=_User) -``` - -- [ ] **Step 2: Run the tests** - -Run: -```bash -just test tests/test_client_response_model.py 2>&1 | tail -10 -``` -Expected: all tests pass — the decoder path was wired in Task 10, the per-method path in Task 12. - -- [ ] **Step 3: Commit** - -Run: -```bash -git add tests/test_client_response_model.py -git commit -m "test(client): response_model decoding across get/post/send paths" -``` - ---- - -## Task 14: Middleware wiring — tests - -**Files:** -- Create: `tests/test_client_middleware_wiring.py` - -- [ ] **Step 1: Write the tests** - -Create `tests/test_client_middleware_wiring.py`: -```python -"""Tests for AsyncClient ↔ middleware chain integration.""" - -import httpx2 - -from httpware import AsyncClient, after_response, before_request, on_error - - -async def test_before_request_runs() -> None: - @before_request - async def add_header(request: httpx2.Request) -> httpx2.Request: - return httpx2.Request( - request.method, - request.url, - headers={**request.headers, "x-injected": "1"}, - ) - - captured: list[httpx2.Request] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - captured.append(request) - return httpx2.Response(200, request=request) - - transport = httpx2.MockTransport(handler) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=(add_header,), - ) - await client.get("https://example.test/x") - assert captured[0].headers["x-injected"] == "1" - - -async def test_after_response_runs() -> None: - @after_response - async def tag_status(request: httpx2.Request, response: httpx2.Response) -> httpx2.Response: - return httpx2.Response( - 299, request=request, headers=response.headers, content=response.content - ) - - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=(tag_status,), - ) - response = await client.get("https://example.test/x") - assert response.status_code == 299 - - -async def test_on_error_catches_status_error() -> None: - @on_error - async def convert_404(request: httpx2.Request, exc: Exception) -> httpx2.Response | None: - from httpware import NotFoundError - - if isinstance(exc, NotFoundError): - return httpx2.Response(200, request=request, content=b"recovered") - return None - - transport = httpx2.MockTransport(lambda req: httpx2.Response(404, request=req)) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=(convert_404,), - ) - response = await client.get("https://example.test/x") - assert response.status_code == 200 - assert response.content == b"recovered" - - -async def test_middleware_runs_outer_to_inner_then_inner_to_outer() -> None: - order: list[str] = [] - - class _Tag: - def __init__(self, name: str) -> None: - self.name = name - - async def __call__(self, request, next): # noqa: A002, ANN001 - order.append(f"{self.name}.in") - response = await next(request) - order.append(f"{self.name}.out") - return response - - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=(_Tag("a"), _Tag("b")), - ) - await client.get("https://example.test/x") - assert order == ["a.in", "b.in", "b.out", "a.out"] -``` - -- [ ] **Step 2: Run the tests** - -Run: -```bash -just test tests/test_client_middleware_wiring.py 2>&1 | tail -10 -``` -Expected: all tests pass. - -- [ ] **Step 3: Commit** - -Run: -```bash -git add tests/test_client_middleware_wiring.py -git commit -m "test(client): middleware chain runs around the terminal" -``` - ---- - -## Task 15: Lifecycle — tests and `__aenter__`/`__aexit__` - -**Files:** -- Create: `tests/test_client_lifecycle.py` -- Modify: `src/httpware/client.py` - -- [ ] **Step 1: Write the failing tests** - -Create `tests/test_client_lifecycle.py`: -```python -"""Tests for AsyncClient.__aenter__/__aexit__ lifecycle and ownership.""" - -import httpx2 - -from httpware import AsyncClient - - -async def test_aexit_closes_owned_httpx2_client() -> None: - client = AsyncClient() - async with client: - pass - assert client._httpx2_client.is_closed # noqa: SLF001 - - -async def test_aexit_does_not_close_borrowed_httpx2_client() -> None: - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req)) - underlying = httpx2.AsyncClient(transport=transport) - client = AsyncClient(httpx2_client=underlying) - async with client: - pass - assert not underlying.is_closed - await underlying.aclose() - - -async def test_aexit_is_idempotent_for_owned_client() -> None: - client = AsyncClient() - async with client: - pass - # Second use should not raise — the boolean prevents a double-close on httpx2 internals. - await client.__aexit__(None, None, None) -``` - -- [ ] **Step 2: Run them to confirm failure** - -Run: -```bash -just test tests/test_client_lifecycle.py 2>&1 | tail -10 -``` -Expected: failures — `__aenter__`/`__aexit__` don't exist yet. - -- [ ] **Step 3: Add lifecycle methods to AsyncClient** - -Append these two methods to the `AsyncClient` class in `src/httpware/client.py`: - -```python - async def __aenter__(self) -> typing.Self: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: object, - ) -> None: - if self._owns_client and not self._httpx2_client.is_closed: - await self._httpx2_client.aclose() -``` - -- [ ] **Step 4: Run lifecycle tests** - -Run: -```bash -just test tests/test_client_lifecycle.py 2>&1 | tail -10 -``` -Expected: all tests pass. - -- [ ] **Step 5: Commit** - -Run: -```bash -git add src/httpware/client.py tests/test_client_lifecycle.py -git commit -m "feat(client): __aenter__/__aexit__ honors owned vs. borrowed httpx2 client" -``` - ---- - -## Task 16: Typing overloads — tests - -**Files:** -- Create: `tests/test_client_typing.py` - -- [ ] **Step 1: Write the typing tests** - -Create `tests/test_client_typing.py`: -```python -"""Static-typing tests for AsyncClient overloads. - -These assert overload selection at runtime via isinstance checks. ty/mypy -catches the static-typing variant during `just lint`. -""" - -import httpx2 -import pydantic - -from httpware import AsyncClient - - -class _User(pydantic.BaseModel): - id: int - name: str - - -async def test_get_without_response_model_returns_response() -> None: - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req, json={"id": 1, "name": "a"})) - client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - result = await client.get("https://example.test/x") - assert isinstance(result, httpx2.Response) - - -async def test_get_with_response_model_returns_typed() -> None: - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req, json={"id": 1, "name": "a"})) - client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - result = await client.get("https://example.test/x", response_model=_User) - assert isinstance(result, _User) - - -async def test_send_without_response_model_returns_response() -> None: - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req, json={"id": 1, "name": "a"})) - client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - result = await client.send(httpx2.Request("GET", "https://example.test/x")) - assert isinstance(result, httpx2.Response) - - -async def test_send_with_response_model_returns_typed() -> None: - transport = httpx2.MockTransport(lambda req: httpx2.Response(200, request=req, json={"id": 1, "name": "a"})) - client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - result = await client.send(httpx2.Request("GET", "https://example.test/x"), response_model=_User) - assert isinstance(result, _User) -``` - -- [ ] **Step 2: Run them and lint** - -Run: -```bash -just test tests/test_client_typing.py -just lint -``` -Expected: tests pass; `ty check` reports zero issues. - -- [ ] **Step 3: Commit** - -Run: -```bash -git add tests/test_client_typing.py -git commit -m "test(client): overload selection of response_model for get/send" -``` - ---- - -## Task 17: Public-API surface test - -**Files:** -- Create: `tests/test_public_api.py` - -- [ ] **Step 1: Write the public-API test** - -Create `tests/test_public_api.py`: -```python -"""Public API surface — what `from httpware import ...` exposes.""" - -import httpware - - -def test_all_exports_resolve() -> None: - for symbol in httpware.__all__: - assert hasattr(httpware, symbol), f"{symbol} declared in __all__ but missing" - - -def test_no_removed_symbols_leaked() -> None: - removed = { - "Request", - "Response", - "StreamResponse", - "Timeout", - "Limits", - "ClientConfig", - "Transport", - "Httpx2Transport", - "RecordedTransport", - "AuthValue", - } - leaked = removed & set(dir(httpware)) - assert not leaked, f"removed 0.1 symbols still exposed: {leaked}" - - -def test_expected_exports() -> None: - expected = { - "AsyncClient", - "Middleware", - "Next", - "ResponseDecoder", - "PydanticDecoder", - "ClientError", - "TransportError", - "TimeoutError", - "StatusError", - "ClientStatusError", - "ServerStatusError", - "BadRequestError", - "UnauthorizedError", - "ForbiddenError", - "NotFoundError", - "ConflictError", - "UnprocessableEntityError", - "RateLimitedError", - "InternalServerError", - "ServiceUnavailableError", - "STATUS_TO_EXCEPTION", - "before_request", - "after_response", - "on_error", - } - missing = expected - set(httpware.__all__) - assert not missing, f"expected exports missing from __all__: {missing}" -``` - -- [ ] **Step 2: Run it** - -Run: -```bash -just test tests/test_public_api.py -``` -Expected: all tests pass. If `test_no_removed_symbols_leaked` fails, an export survived the tear-down; trace and remove from `__init__.py`. - -- [ ] **Step 3: Commit** - -Run: -```bash -git add tests/test_public_api.py -git commit -m "test(public-api): assert v0.2 surface + no leaked 0.1 names" -``` - ---- - -## Task 18: Full test suite + coverage gate - -**Files:** -- All tests. - -- [ ] **Step 1: Run the full suite with coverage** - -Run: -```bash -just test 2>&1 | tail -30 -``` -Expected: every test passes; line coverage at 100% on `src/httpware/`. Note the new total count. - -- [ ] **Step 2: Lint, format, type-check** - -Run: -```bash -just lint 2>&1 | tail -15 -``` -Expected: zero issues across all linters. - -- [ ] **Step 3: Run `import httpware` in a clean subprocess and confirm no `msgspec` leak** - -Run: -```bash -just test tests/test_optional_extras_isolation.py 2>&1 | tail -5 -``` -Expected: pass. - -- [ ] **Step 4: If coverage gaps appear, add a tiny targeted test for each uncovered branch** - -This is a per-line-coverage spot fix. Most gaps will be in the `_terminal` error branches; add a parametrized test in `tests/test_error_mapping_terminal.py` for whichever specific `httpx2` exception subclass is uncovered. - -- [ ] **Step 5: Commit (only if step 4 produced changes)** - -Run: -```bash -git add tests/ -git commit -m "test(coverage): close remaining branch gaps in terminal error mapping" -``` - ---- - -## Task 19: Rewrite `CLAUDE.md` - -**Files:** -- Modify: `CLAUDE.md` - -- [ ] **Step 1: Update the architecture-invariants section** - -Open `CLAUDE.md`. In the "Architecture invariants (CI-enforced)" section, **delete** the two bullets: - -- `- **No httpx2 leakage**: ...` -- `- **No httpx2 private API**: ...` - -Replace with a single bullet: - -```markdown -- **No `httpx2` private API**: `grep -rE 'httpx2\._' src/httpware/` must return zero matches. Public symbols only. -``` - -In the "Module layout" section, **replace** the diagram with: - -```text -src/httpware/ -├── __init__.py # public exports + __all__ -├── client.py # AsyncClient (thin wrapper over httpx2.AsyncClient) -├── errors.py # status-keyed exception hierarchy holding httpx2.Response -├── middleware/ # protocol, Next type, chain composition, phase decorators -├── decoders/ # ResponseDecoder protocol + Pydantic/msgspec adapters -├── _internal/ # private cross-module helpers -└── py.typed -``` - -In the "Protocol seams" section, **replace** the five-seam list with three: - -```markdown -Three documented internal boundaries. AI agents must respect them — never cross a seam except through its documented protocol. - -1. **`AsyncClient ↔ Middleware`** — middleware chain composed at `AsyncClient.__init__`, frozen for the client's lifetime. Internal terminal calls `httpx2.AsyncClient.send`, maps exceptions, raises `StatusError` on 4xx/5xx. -2. **`AsyncClient ↔ ResponseDecoder`** — called when `response_model` is provided. Signature: `decode(content: bytes, model: type[T]) -> T`. -3. **`httpware ↔ optional extras`** — each opt-in dependency imported only inside its dedicated module. -``` - -- [ ] **Step 2: Update project-overview text** - -In the "Project Overview" section, **replace** "The framework owns the abstraction layer above the underlying HTTP client (`httpx2` by default); consumers never import the transport." with "The framework is a thin opinionated wrapper around `httpx2`: it re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain, typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx." - -- [ ] **Step 3: Confirm the file lints** - -Run: -```bash -just lint 2>&1 | tail -5 -``` -Expected: zero issues (CLAUDE.md is markdown; ruff doesn't touch it, but `eof-fixer` does — confirm the trailing newline). - -- [ ] **Step 4: Commit** - -Run: -```bash -git add CLAUDE.md -git commit -m "docs(claude): retire no-leakage invariant; collapse seams to three" -``` - ---- - -## Task 20: Rewrite `docs/dev/engineering.md` - -**Files:** -- Modify: `docs/dev/engineering.md` - -- [ ] **Step 1: Rewrite Section 1 (Project intent)** - -Replace section 1 with: - -```markdown -## 1. Project intent - -`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; Pydantic ships as the default, msgspec as an opt-in extra), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx. - -The 0.1.0 release attempted to own a full abstraction over the underlying HTTP client. v0.2 walks that back: `httpx2` is part of the public surface. -``` - -- [ ] **Step 2: Rewrite Section 2 (Architectural invariants)** - -Replace section 2 with: - -```markdown -## 2. Architectural invariants (CI-enforced) - -These are non-negotiable. CI rejects PRs that violate them. - -- **No `httpx2._` private API.** *Why:* private symbols can change between patch releases. We accept the public-API surface as the contract. -- **No `from __future__ import annotations`.** *Why:* Python 3.11+ floor; PEP 604/585 syntax is native. -- **No `print()`.** *Why:* ruff-enforced. Libraries log; they do not print. -- **No global logging config.** *Why:* `logging.basicConfig()` from a library mutates the consumer's logging tree. We only acquire `logging.getLogger("httpware")` or namespaced child loggers. -- **Type suppressions use `# ty: ignore[]`.** *Why:* this project uses `ty`, not `mypy`. `# type: ignore` is silently accepted; `# ty: ignore[]` is checked and rule-specific. - -The 0.1.0 "no `httpx2` leakage outside `transports/httpx2.py`" invariant is **retired in v0.2**. Exposing `httpx2.Request`/`httpx2.Response` is the design. -``` - -- [ ] **Step 3: Rewrite Section 3 (The five protocol seams) → three seams** - -Replace section 3 wholesale with the three-seam content from the spec (sections 4.A, 4.B, 4.C). - -- [ ] **Step 4: Rewrite Section 5 (Module layout)** - -Replace the layout diagram and "Planned modules" subsection with the layout from spec section 5 (single tree, no "planned modules" — they all land or get deleted in this pivot). - -- [ ] **Step 5: Rewrite Section 8 (Remaining roadmap)** - -Replace section 8's story list with the updated roadmap from spec section 12 (the four-category breakdown: deleted, rewritten, surviving, plus the explicit story IDs). - -- [ ] **Step 6: Confirm formatting and commit** - -Run: -```bash -just lint 2>&1 | tail -5 -git add docs/dev/engineering.md -git commit -m "docs(engineering): rewrite sections 1, 2, 3, 5, 8 for the v0.2 pivot" -``` - ---- - -## Task 21: Sweep `planning/deferred-work.md` - -**Files:** -- Modify: `planning/deferred-work.md` - -- [ ] **Step 1: Identify and close obsoleted entries** - -Replace the contents of `planning/deferred-work.md` with: - -```markdown -# Deferred Work - -Items raised in reviews that are real but not actionable now. - -## Open - -### Decoder-side - -- **`_get_adapter` `lru_cache` is module-global, not per-decoder instance** — keyed by `model` only; two `PydanticDecoder()` instances with different configurations (none today) would share adapters, and the cache survives across tests unless explicitly cleared. Revisit if/when a configurable `PydanticDecoder(mode=..., strict=...)` lands. (`src/httpware/decoders/pydantic.py:12-14`) -- **Empty/malformed payload tests** — `b""`, `b"null"`, `b"{}"`, invalid UTF-8: current pydantic-core behavior is correct but unpinned; a future pydantic upgrade could change error types undetected. (`tests/test_decoders_pydantic.py`) - -### Tooling - -- **Unpinned `ruff`/`ty` with `select=["ALL"]`** — any new ruff release adds rules and can break CI overnight. Pin major versions or pin specific rules when a regression occurs. (`pyproject.toml` `[dependency-groups] lint`, `[tool.ruff.lint] select`) -- **No `[test]` extra; CI installs all extras** — `just install` runs `uv sync --all-extras --group lint`, so every CI run pulls msgspec/otel/niquests even though most tests don't need them. Declare a `test` extra (or move test-only deps into a dedicated dependency-group) and switch CI to the narrower install. (`pyproject.toml` `[project.optional-dependencies]`, `Justfile:install`) -- **`pydantic` import not guarded the way `msgspec` is** — `decoders/pydantic.py` imports `pydantic` at module top; `decoders/msgspec.py` guards via `is_msgspec_installed`. Either drop the optional-extras framing for pydantic (it is already a required dependency) or guard pydantic the same way. (`src/httpware/decoders/pydantic.py:5`, `pyproject.toml` `[project] dependencies`) - -## Closed by the v0.2 thin-wrapper pivot (2026-06-03) - -The pivot retired Request/Response/Httpx2Transport/RecordedTransport. The following deferred items are no longer applicable because their host code has been removed or because the responsibility shifted to `httpx2`: - -- `extensions=dict(request.extensions)` opaque forwarding (host module removed). -- Unbounded error body size on `StatusError.body` (the `body` field no longer exists; callers reach into `exc.response.content` themselves). -- `httpx2.StreamError` family escape from the transport's `except httpx2.HTTPError` (mapping logic relocated to AsyncClient's terminal; revisit with Epic 4 streaming work). -- Header CRLF / log-injection at the transport seam (host module removed; httpx2 validates). -- Userinfo on `StatusError.request_url` raw field (the field no longer exists; `__repr__` and summary still sanitize). -- Concurrent `aclose()` ↔ `__call__` races on `Httpx2Transport` (host class removed; lifecycle is `httpx2`'s concern). -- URL CRLF / log-injection (httpx2 owns URL validation). -- `request.method` validation beyond uppercasing (host module removed; `httpx2` owns). -- Case-insensitive header type / multi-valued header collapse (host module removed; `httpx2.Headers` already provides case-insensitive multi-valued access). -- Multi-valued query params (host module removed; `httpx2` owns). -- Streaming / async-iterable request bodies (Epic 4 lands on `httpx2.Request` directly). -- `@final` to prevent subclassing of `Request`/`Response`/`ClientConfig` (host classes removed). -``` - -- [ ] **Step 2: Lint and commit** - -Run: -```bash -just lint 2>&1 | tail -5 -git add planning/deferred-work.md -git commit -m "docs(deferred): close items obsoleted by the v0.2 thin-wrapper pivot" -``` - ---- - -## Task 22: Version bump and release notes - -**Files:** -- Modify: `pyproject.toml` -- Create: `planning/specs/2026-06-03-release-notes-0.2.0.md` (draft of the GitHub Release body) - -- [ ] **Step 1: Bump the version** - -In `pyproject.toml`, change: - -```toml -version = "0" -``` - -to: - -```toml -version = "0.2.0" -``` - -- [ ] **Step 2: Draft release notes** - -Create `planning/specs/2026-06-03-release-notes-0.2.0.md`: - -```markdown -# httpware 0.2.0 — thin httpx2 wrapper - -**0.2.0 is a breaking rewrite.** The framework is now a thin opinionated wrapper around `httpx2`: it re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain, typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx. - -## Breaking changes from 0.1.0 - -- **Removed value types.** `httpware.Request`, `httpware.Response`, `httpware.StreamResponse`, `httpware.Limits`, `httpware.Timeout`, and `httpware.ClientConfig` are gone. Use `httpx2.Request`, `httpx2.Response`, `httpx2.Limits`, `httpx2.Timeout` directly. -- **Removed transport abstraction.** `httpware.Transport`, `httpware.Httpx2Transport`, and `httpware.RecordedTransport` are gone. Tests should inject `httpx2.MockTransport` via the new `httpx2_client=` kwarg. -- **Removed auth coercion.** Pass `httpx2.Auth` (e.g., `httpx2.BasicAuth`) directly to the client. -- **`with_options` removed.** Construct a separate `AsyncClient` wrapping a shared `httpx2.AsyncClient` instead. -- **`StatusError` simplified.** Subclasses no longer accept `status` / `body` / `headers` / `json` / `request_method` / `request_url` kwargs. Construct with a single `response: httpx2.Response` argument and read fields from `exc.response.*`. -- **CI invariant retired.** The "no `httpx2` imports outside `transports/httpx2.py`" rule is gone. `httpx2` is part of the public surface. - -## What still works - -- `AsyncClient.get/post/put/patch/delete/head/options/request` with `response_model=...` for typed decoding. -- `PydanticDecoder` (default) and `MsgspecDecoder` (opt-in via `pip install httpware[msgspec]`). -- Middleware protocol with `@before_request`, `@after_response`, `@on_error` decorators. -- Status-keyed exception tree (`NotFoundError`, `RateLimitedError`, etc.) raised automatically on 4xx/5xx. - -## Migration - -```python -# Before (0.1.0) -import httpware - -async with httpware.AsyncClient(base_url="https://api.example.com", auth="my-token") as client: - user = await client.get("/users/1", response_model=User) -``` - -```python -# After (0.2.0) -import httpx2 -import httpware - -async with httpware.AsyncClient( - base_url="https://api.example.com", - headers={"Authorization": "Bearer my-token"}, -) as client: - user = await client.get("/users/1", response_model=User) -``` - -## What's next - -Epic 3 (resilience middleware — retry, timeout, bulkhead) and Epic 5 (observability) ship in subsequent minor releases. See `docs/dev/engineering.md` section 8 for the post-pivot roadmap. -``` - -- [ ] **Step 3: Confirm `just lint-ci` and `just test` still pass** - -Run: -```bash -just lint-ci 2>&1 | tail -5 -just test 2>&1 | tail -10 -``` -Expected: clean. - -- [ ] **Step 4: Commit** - -Run: -```bash -git add pyproject.toml planning/specs/2026-06-03-release-notes-0.2.0.md -git commit -m "chore(release): bump version to 0.2.0 and draft release notes" -``` - ---- - -## Task 23: Final integration sweep - -**Files:** -- All. - -- [ ] **Step 1: Re-read the diff against `main`** - -Run: -```bash -git diff main --stat -``` -Expected: roughly matches the file-map at the top of this plan — deletions in `transports/`, `_internal/auth.py`, `request.py`, `response.py`, `config.py`, the corresponding tests; rewrites in `client.py`, `errors.py`, `middleware/`, `__init__.py`, the corresponding tests; updates to `CLAUDE.md`, `docs/dev/engineering.md`, `planning/deferred-work.md`, `pyproject.toml`; new release-notes file. - -- [ ] **Step 2: Run the perf-marked tests opt-in to confirm no regressions** - -Run: -```bash -uv run --no-sync pytest -m perf 2>&1 | tail -10 -``` -Expected: pass (or skip cleanly). - -- [ ] **Step 3: Confirm `import httpware` works in a fresh interpreter** - -Run: -```bash -uv run python -c "import httpware; print(sorted(httpware.__all__))" -``` -Expected: the v0.2 export list prints, no ImportError, no warnings. - -- [ ] **Step 4: Push branch and open PR (manual step)** - -Run: -```bash -git push -u origin feat/v0.2-thin-httpx2-wrapper -gh pr create --title "v0.2: thin httpx2 wrapper rewrite" --body "$(cat <<'EOF' -## Summary -- Single structural PR for the v0.2 thin-wrapper pivot per `planning/specs/2026-06-03-thin-httpx2-wrapper-design.md`. -- Removes `Request`/`Response`/`Transport`/`RecordedTransport`/auth coercion/`with_options`. -- Re-exports `httpx2.Request`/`httpx2.Response`; adds `httpx2_client=` injection point. -- Middleware retyped on `httpx2.Request`/`httpx2.Response`; chain composition moved to `middleware/chain.py`. -- `StatusError` subclasses now hold a single `response: httpx2.Response`. -- Five protocol seams collapse to three. -- Closes 12 deferred-work items obsoleted by the pivot. -- Bumps version to `0.2.0`; release notes draft included. - -## Test plan -- [ ] `just test` is green at 100% coverage. -- [ ] `just lint-ci` is green. -- [ ] Optional-extras isolation test still passes (`import httpware` doesn't pull `msgspec`). -- [ ] Public-API test asserts no 0.1 names leak. -- [ ] Migration example in release notes verified by inspection. -EOF -)" -``` -This is the final manual step. The agent should NOT push or open the PR without explicit user instruction. - ---- - -## Self-review notes (writer to writer) - -- **Spec coverage:** Each spec section maps to at least one task. Section 1 → Task 19 + 20 (docs); 3 (invariants) → Task 19 + 20; 4 (seams) → Tasks 5-10 + 19 + 20; 5 (layout) → Task 2 + 19 + 20; 6 (public API) → Tasks 7-16; 7 (middleware) → Tasks 5-6 + 14; 8 (errors) → Tasks 3-4; 9 (data flow) → Tasks 9-14; 10 (error mapping table) → Task 9-10; 11 (testing pattern) → every test task; 12 (roadmap) → Task 20 + 21; 13 (cutover plan) → entire plan; 14 (open questions) → none currently. -- **Placeholder scan:** none of the forbidden phrases ("TBD", "TODO", "implement later", "fill in details", "appropriate error handling") appear in any step. -- **Type consistency:** `AsyncClient._terminal`, `AsyncClient.send`, `AsyncClient.build_request`, `AsyncClient._request_with_body`, `AsyncClient._dispatch`, `AsyncClient._user_middleware`, `AsyncClient._decoder`, `AsyncClient._httpx2_client`, `AsyncClient._owns_client` are all referenced consistently across tasks. `Next` is a `TypeAlias` defined in `middleware/__init__.py`; `_Next` is the private alias in `chain.py` (matching, but not re-exported, to avoid the circular import at `chain.py` import time). -- **Coverage gate:** Task 18 step 4 calls out the spot-fix pattern if line coverage drops below 100%. -- **Decoder default:** the spec footnote about `pydantic` being not-truly-opt-in is reflected in the "Open" section of `deferred-work.md` (Task 21). diff --git a/planning/changes/2026-06-04.01-pydantic-optional-extra/design.md b/planning/changes/2026-06-04.01-pydantic-optional-extra.md similarity index 100% rename from planning/changes/2026-06-04.01-pydantic-optional-extra/design.md rename to planning/changes/2026-06-04.01-pydantic-optional-extra.md diff --git a/planning/changes/2026-06-04.01-pydantic-optional-extra/plan.md b/planning/changes/2026-06-04.01-pydantic-optional-extra/plan.md deleted file mode 100644 index 0cfd6d8..0000000 --- a/planning/changes/2026-06-04.01-pydantic-optional-extra/plan.md +++ /dev/null @@ -1,1058 +0,0 @@ -# Pydantic-as-optional-extra (0.3.0) 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:** Move `pydantic` from a hard dependency to an opt-in extra (matching how `msgspec` already works), drop the top-level `httpware.PydanticDecoder` re-export, fail fast at `AsyncClient.__init__` when the default decoder cannot be constructed, add malformed-payload tests for `PydanticDecoder`, and bring `README.md` and `planning/engineering.md` up to date for the 0.3.0 release. - -**Architecture:** Mirror the `msgspec` pattern exactly. `_internal/import_checker.py` exposes `is_pydantic_installed`. `decoders/pydantic.py` guards the `from pydantic import TypeAdapter` import behind that flag and raises `ImportError` from `PydanticDecoder.__init__` when the extra is missing. `client.py` removes the unconditional top-level `PydanticDecoder` import and uses a lazy module-level helper that fails fast at `AsyncClient.__init__` when `decoder=None` and pydantic is unavailable. `httpware/__init__.py` no longer re-exports `PydanticDecoder`. - -**Tech Stack:** Python 3.11+, `httpx2`, `pydantic` (now optional), `pytest`/`pytest-asyncio`/`hypothesis`, `uv`, `just`, `ruff`, `ty`. - -**Target branch:** `feat/v0.3-pydantic-optional`. - -**Source spec:** [`planning/specs/2026-06-04-pydantic-optional-extra-design.md`](../specs/2026-06-04-pydantic-optional-extra-design.md). Read it before starting — the *why* for each decision lives there. - ---- - -## File structure - -**Modified files:** -- `pyproject.toml` — move `pydantic` to `[project.optional-dependencies]`, bump version to `0.3.0`, update `all` extra. -- `src/httpware/_internal/import_checker.py` — add `is_pydantic_installed`. -- `src/httpware/decoders/pydantic.py` — guard the pydantic import; raise `ImportError` in `__init__`; drop the submodule-level `__all__`. -- `src/httpware/client.py` — remove top-level `PydanticDecoder` import; add `_default_pydantic_decoder` helper that fails fast at `__init__`. -- `src/httpware/__init__.py` — drop the `PydanticDecoder` re-export from imports and `__all__`. -- `tests/test_optional_extras_isolation.py` — add a pydantic-isolation subprocess test. -- `tests/test_decoders_pydantic.py` — update `PydanticDecoder` import path; add 7 parametrized malformed-payload tests. -- `tests/test_public_api.py` — move `"PydanticDecoder"` from the `expected` set to the `removed` set. -- `README.md` — full freshness pass. -- `planning/engineering.md` — §1, §3 Seam C, §7 updates. - -**New files:** -- `tests/test_optional_extras_pydantic_missing.py` — fail-fast tests gated by patched `is_pydantic_installed`. -- `planning/releases/0.3.0.md` — release notes. - -**Branch setup:** the worktree skill (`superpowers:using-git-worktrees`) may have already created a worktree on `feat/v0.3-pydantic-optional`. If not, before Task 1 run `git checkout -b feat/v0.3-pydantic-optional` from `main`. The plan assumes commits go to that branch. - ---- - -## Task 1: Add `is_pydantic_installed` to `import_checker` - -**Files:** -- Modify: `src/httpware/_internal/import_checker.py` - -- [ ] **Step 1: Read the current file** - -Run: `cat src/httpware/_internal/import_checker.py` -Expected current contents: - -```python -"""Detect optional extras without importing them. Used by adapter modules to gate hard imports.""" - -from importlib.util import find_spec - - -is_msgspec_installed = find_spec("msgspec") is not None -``` - -- [ ] **Step 2: Add the new constant** - -Replace the file with: - -```python -"""Detect optional extras without importing them. Used by adapter modules to gate hard imports.""" - -from importlib.util import find_spec - - -is_msgspec_installed = find_spec("msgspec") is not None -is_pydantic_installed = find_spec("pydantic") is not None -``` - -- [ ] **Step 3: Lint check** - -Run: `uv run ruff check src/httpware/_internal/import_checker.py && uv run ty check src/httpware/_internal/import_checker.py` -Expected: clean exit (`All checks passed!` or no output). - -- [ ] **Step 4: Stage the change (hold the commit)** - -```bash -git add src/httpware/_internal/import_checker.py -``` - -(Commits are batched per the spec's §Execution order; this change joins Task 4's commit.) - ---- - -## Task 2: Failing test — `PydanticDecoder.__init__` raises `ImportError` when pydantic missing - -**Files:** -- Create: `tests/test_optional_extras_pydantic_missing.py` - -- [ ] **Step 1: Create the new test file** - -```python -"""Fail-fast tests for the pydantic optional-extra (0.3.0). - -Pydantic IS installed in the CI test environment via `--all-extras`. To -simulate the "extra not installed" case, patch -`httpware._internal.import_checker.is_pydantic_installed = False` for the -duration of the test. -""" - -from unittest.mock import patch - -import pytest - -from httpware import AsyncClient -from httpware.decoders.pydantic import PydanticDecoder - - -def test_pydantic_decoder_init_raises_when_pydantic_missing() -> None: - with patch("httpware._internal.import_checker.is_pydantic_installed", False): - with pytest.raises(ImportError, match=r"httpware\[pydantic\]"): - PydanticDecoder() - - -def test_async_client_default_decoder_raises_when_pydantic_missing() -> None: - with patch("httpware._internal.import_checker.is_pydantic_installed", False): - with pytest.raises(ImportError, match=r"httpware\[pydantic\]"): - AsyncClient() - - -def test_async_client_accepts_explicit_decoder_without_pydantic() -> None: - """An explicit decoder= escapes the fail-fast even when pydantic is 'missing'.""" - - class _FakeDecoder: - def decode(self, content: bytes, model: type) -> object: - return model() - - with patch("httpware._internal.import_checker.is_pydantic_installed", False): - client = AsyncClient(decoder=_FakeDecoder()) - assert client is not None -``` - -- [ ] **Step 2: Run the test, verify the first two fail** - -Run: `uv run pytest tests/test_optional_extras_pydantic_missing.py -v` -Expected: -- `test_pydantic_decoder_init_raises_when_pydantic_missing` — **FAIL** (`PydanticDecoder()` currently does not check `is_pydantic_installed`). -- `test_async_client_default_decoder_raises_when_pydantic_missing` — **FAIL** (`AsyncClient.__init__` currently does not check either). -- `test_async_client_accepts_explicit_decoder_without_pydantic` — **PASS** (no fail-fast yet, so explicit decoder definitely works). - -Tasks 3 and 5 make the failing tests pass. - -- [ ] **Step 3: Stage the test file** - -```bash -git add tests/test_optional_extras_pydantic_missing.py -``` - ---- - -## Task 3: Guard `decoders/pydantic.py` + `PydanticDecoder.__init__` raises - -**Files:** -- Modify: `src/httpware/decoders/pydantic.py` - -- [ ] **Step 1: Read the current file** - -Run: `cat src/httpware/decoders/pydantic.py` -Expected current contents (29 lines including the `__all__`): - -```python -"""PydanticDecoder — module-level cached TypeAdapter adapter for ResponseDecoder.""" - -import functools -from typing import TypeVar - -from pydantic import TypeAdapter - - -T = TypeVar("T") - - -@functools.lru_cache(maxsize=1024) -def _get_adapter(model: type[T]) -> TypeAdapter[T]: - return TypeAdapter(model) - - -class PydanticDecoder: - """Decode raw response bytes into `model` via a cached `pydantic.TypeAdapter`.""" - - def decode(self, content: bytes, model: type[T]) -> T: - """Validate `content` as JSON against `model` in a single parse pass.""" - try: - adapter = _get_adapter(model) - except TypeError: - adapter = TypeAdapter(model) - return adapter.validate_json(content) - - -__all__ = ["PydanticDecoder"] -``` - -- [ ] **Step 2: Replace with the guarded version** - -```python -"""PydanticDecoder — module-level cached TypeAdapter adapter for ResponseDecoder. - -Requires the `pydantic` extra: `pip install httpware[pydantic]`. Importing this -module without the extra works (the `pydantic` import is guarded by a -`find_spec` check), but instantiating the decoder raises `ImportError` with the -install hint. -""" - -import functools -from typing import TypeVar - -from httpware._internal import import_checker - - -if import_checker.is_pydantic_installed: - from pydantic import TypeAdapter - - -MISSING_DEPENDENCY_MESSAGE = ( - "PydanticDecoder requires the 'pydantic' extra. Install with: pip install httpware[pydantic]" -) - -T = TypeVar("T") - - -@functools.lru_cache(maxsize=1024) -def _get_adapter(model: type[T]) -> "TypeAdapter[T]": - return TypeAdapter(model) - - -class PydanticDecoder: - """Decode raw response bytes into `model` via a cached `pydantic.TypeAdapter`.""" - - def __init__(self) -> None: - if not import_checker.is_pydantic_installed: - raise ImportError(MISSING_DEPENDENCY_MESSAGE) - - def decode(self, content: bytes, model: type[T]) -> T: - """Validate `content` as JSON against `model` in a single parse pass.""" - try: - adapter = _get_adapter(model) - except TypeError: - adapter = TypeAdapter(model) - return adapter.validate_json(content) -``` - -Notes on the changes (read these — they prevent surprise): -- `from pydantic import TypeAdapter` is now inside `if import_checker.is_pydantic_installed:`. The module imports cleanly even without pydantic. -- `_get_adapter`'s return-type annotation became the **string** `"TypeAdapter[T]"`. Without the string, the annotation evaluates at function-definition time and would raise `NameError` when pydantic is absent. The string defers evaluation to anyone explicitly calling `typing.get_type_hints()`, which never happens in our hot paths. -- `_get_adapter`'s body still references `TypeAdapter` directly — that's fine because the body only runs when `_get_adapter()` is called, which only happens after `PydanticDecoder.__init__` succeeds, which only succeeds with pydantic installed. -- `MISSING_DEPENDENCY_MESSAGE` is a module-level UPPER_CASE constant (matches the existing pattern in `decoders/msgspec.py`). -- The submodule-level `__all__ = ["PydanticDecoder"]` is **removed** — submodules don't need `__all__`; that lives only in `httpware/__init__.py`. - -- [ ] **Step 3: Run the failing test, verify it now passes** - -Run: `uv run pytest tests/test_optional_extras_pydantic_missing.py::test_pydantic_decoder_init_raises_when_pydantic_missing -v` -Expected: **PASS**. - -- [ ] **Step 4: Run the full pydantic-decoder test file, verify no regression** - -Run: `uv run pytest tests/test_decoders_pydantic.py -v` -Expected: all existing tests still pass (pydantic IS installed in the test env, so the guard is true, the `from pydantic import TypeAdapter` runs, and behavior is unchanged for the happy path). - -- [ ] **Step 5: Lint** - -Run: `uv run ruff check src/httpware/decoders/pydantic.py && uv run ty check src/httpware/decoders/pydantic.py` -Expected: clean. - -- [ ] **Step 6: Stage** - -```bash -git add src/httpware/decoders/pydantic.py -``` - ---- - -## Task 4: Commit Task 1 + Task 2 + Task 3 together - -The spec groups `is_pydantic_installed` + the `decoders/pydantic.py` guard + its tests into one logical change. - -- [ ] **Step 1: Verify staged contents** - -Run: `git diff --cached --stat` -Expected: three files changed — `src/httpware/_internal/import_checker.py`, `src/httpware/decoders/pydantic.py`, `tests/test_optional_extras_pydantic_missing.py`. - -- [ ] **Step 2: Commit** - -```bash -git commit -m "$(cat <<'EOF' -feat(extras): guard pydantic import + fail-fast in PydanticDecoder.__init__ - -Adds is_pydantic_installed to _internal/import_checker.py; guards the -pydantic import in decoders/pydantic.py the same way msgspec is guarded; -PydanticDecoder.__init__ raises ImportError with the install hint when -the extra is missing. New tests in tests/test_optional_extras_pydantic_missing.py -cover both the PydanticDecoder fail-fast and the explicit-decoder escape -hatch. Drops the submodule-level __all__ in decoders/pydantic.py. - -Part of the 0.3.0 pydantic-optional-extra work -(planning/specs/2026-06-04-pydantic-optional-extra-design.md). -EOF -)" -``` - ---- - -## Task 5: Lazy default decoder + fail-fast in `client.py` - -**Files:** -- Modify: `src/httpware/client.py` - -- [ ] **Step 1: Re-read the relevant lines** - -Run: `sed -n '1,20p;85,92p' src/httpware/client.py` -Expected: imports at lines 1–19 include `from httpware.decoders.pydantic import PydanticDecoder` on line 10; line 88 reads `self._decoder = decoder if decoder is not None else PydanticDecoder()`. - -- [ ] **Step 2: Remove the top-level `PydanticDecoder` import** - -Edit `src/httpware/client.py`: delete line 10 (`from httpware.decoders.pydantic import PydanticDecoder`). - -- [ ] **Step 3: Add the `import_checker` import** - -Add `from httpware._internal import import_checker` to the imports block (after the other `httpware.*` imports, before the blank line preceding the module-level definitions). - -- [ ] **Step 4: Add the `_default_pydantic_decoder` helper and the message constant** - -After the existing module-level constants (`_FORWARDED_KWARG_NAMES`, `_HTTPX2_CLIENT_CONFLICT_MESSAGE`) and before `class AsyncClient`, insert: - -```python -_DEFAULT_DECODER_MISSING_MESSAGE = ( - "AsyncClient(decoder=None) defaults to PydanticDecoder, which requires the " - "'pydantic' extra. Either install it (`pip install httpware[pydantic]`) or " - "pass an explicit decoder=..." -) - - -def _default_pydantic_decoder() -> ResponseDecoder: - if not import_checker.is_pydantic_installed: - raise ImportError(_DEFAULT_DECODER_MISSING_MESSAGE) - from httpware.decoders.pydantic import PydanticDecoder # noqa: PLC0415 — lazy by design - - return PydanticDecoder() -``` - -The `# noqa: PLC0415` justification is real (lazy default is the design); per the user's memory on lint suppression, per-line `# noqa` with a justification is preferred over project-wide ignore or hoisting. - -- [ ] **Step 5: Update `__init__` line 88** - -Replace: - -```python -self._decoder = decoder if decoder is not None else PydanticDecoder() -``` - -With: - -```python -self._decoder = decoder if decoder is not None else _default_pydantic_decoder() -``` - -- [ ] **Step 6: Run the failing fail-fast test, verify it now passes** - -Run: `uv run pytest tests/test_optional_extras_pydantic_missing.py -v` -Expected: all 3 tests pass. - -- [ ] **Step 7: Run the full client test suite** - -Run: `uv run pytest tests/test_client_construction.py tests/test_client_lifecycle.py tests/test_client_methods.py tests/test_client_middleware_wiring.py tests/test_client_response_model.py tests/test_client_typing.py tests/test_error_mapping_terminal.py -v` -Expected: all pass. `test_client_construction.py:53` (`isinstance(client._decoder, PydanticDecoder)`) still passes because pydantic IS installed and the lazy import succeeds. - -- [ ] **Step 8: Lint** - -Run: `uv run ruff check src/httpware/client.py && uv run ty check src/httpware/client.py` -Expected: clean. - -- [ ] **Step 9: Stage and commit** - -```bash -git add src/httpware/client.py -git commit -m "$(cat <<'EOF' -feat(client): lazy default decoder with fail-fast at __init__ - -Removes the unconditional top-level PydanticDecoder import. Adds -_default_pydantic_decoder() that checks is_pydantic_installed up front -and raises ImportError immediately when AsyncClient(decoder=None) is -constructed without the pydantic extra. Explicit decoder= arguments -bypass the check. - -Part of the 0.3.0 pydantic-optional-extra work -(planning/specs/2026-06-04-pydantic-optional-extra-design.md). -EOF -)" -``` - ---- - -## Task 6: Failing pydantic-isolation subprocess test - -**Files:** -- Modify: `tests/test_optional_extras_isolation.py` - -- [ ] **Step 1: Append the new test** - -Add to the end of the file: - -```python -def test_importing_httpware_does_not_import_pydantic() -> None: - """Fresh subprocess: pydantic must NOT appear in sys.modules after `import httpware`. - - pydantic IS installed in the test environment (via `--all-extras`), so this - test runs in a subprocess with a clean interpreter to verify that nothing - in the httpware import chain pulls pydantic in. - """ - result = subprocess.run( - [ - sys.executable, - "-c", - "import httpware; import sys; sys.exit(0 if 'pydantic' not in sys.modules else 1)", - ], - check=False, - capture_output=True, - ) - assert result.returncode == 0, ( - f"pydantic was loaded transitively by `import httpware`; " - f"stdout={result.stdout!r} stderr={result.stderr!r}" - ) -``` - -- [ ] **Step 2: Run the test, verify it fails** - -Run: `uv run pytest tests/test_optional_extras_isolation.py::test_importing_httpware_does_not_import_pydantic -v` -Expected: **FAIL**. After Task 5, `client.py` no longer imports `PydanticDecoder` directly, but `httpware/__init__.py:5` still does. So `import httpware` still loads pydantic transitively. Task 7 makes this pass. - -- [ ] **Step 3: Stage the test (hold the commit; pairs with Task 7)** - -```bash -git add tests/test_optional_extras_isolation.py -``` - ---- - -## Task 7: Drop `PydanticDecoder` from the top-level public API + tests - -**Files:** -- Modify: `src/httpware/__init__.py` -- Modify: `tests/test_public_api.py` -- Modify: `tests/test_decoders_pydantic.py` - -- [ ] **Step 1: Update `httpware/__init__.py`** - -Delete line 5 (`from httpware.decoders.pydantic import PydanticDecoder`). -Delete the `"PydanticDecoder",` entry in `__all__` (currently line 39). - -After edits, the imports look like: - -```python -"""httpware — thin async HTTP client wrapper over httpx2.""" - -from httpware.client import AsyncClient -from httpware.decoders import ResponseDecoder -from httpware.errors import ( - STATUS_TO_EXCEPTION, - BadRequestError, - ClientError, - ClientStatusError, - ConflictError, - ForbiddenError, - InternalServerError, - NotFoundError, - RateLimitedError, - ServerStatusError, - ServiceUnavailableError, - StatusError, - TimeoutError, # noqa: A004 - TransportError, - UnauthorizedError, - UnprocessableEntityError, -) -from httpware.middleware import Middleware, Next, after_response, before_request, on_error -``` - -And `__all__` is the same list minus `"PydanticDecoder"`. - -- [ ] **Step 2: Update `tests/test_public_api.py`** - -Move `"PydanticDecoder"` from the `expected` set in `test_expected_exports` to the `removed` set in `test_no_removed_symbols_leaked`. After edits: - -```python -def test_no_removed_symbols_leaked() -> None: - removed = { - "Request", - "Response", - "StreamResponse", - "Timeout", - "Limits", - "ClientConfig", - "Transport", - "Httpx2Transport", - "RecordedTransport", - "AuthValue", - "PydanticDecoder", - } - leaked = removed & set(dir(httpware)) - assert not leaked, f"removed 0.1 symbols still exposed: {leaked}" - - -def test_expected_exports() -> None: - expected = { - "AsyncClient", - "Middleware", - "Next", - "ResponseDecoder", - "ClientError", - "TransportError", - "TimeoutError", - "StatusError", - "ClientStatusError", - "ServerStatusError", - "BadRequestError", - "UnauthorizedError", - "ForbiddenError", - "NotFoundError", - "ConflictError", - "UnprocessableEntityError", - "RateLimitedError", - "InternalServerError", - "ServiceUnavailableError", - "STATUS_TO_EXCEPTION", - "before_request", - "after_response", - "on_error", - } - missing = expected - set(httpware.__all__) - assert not missing, f"expected exports missing from __all__: {missing}" -``` - -- [ ] **Step 3: Update `tests/test_decoders_pydantic.py` import path** - -Replace line 11: - -```python -from httpware import PydanticDecoder, ResponseDecoder -``` - -With: - -```python -from httpware import ResponseDecoder -from httpware.decoders.pydantic import PydanticDecoder -``` - -All 16 test bodies in the file already use `PydanticDecoder` as a bare name — no further changes needed. - -- [ ] **Step 4: Run the isolation test, verify it now passes** - -Run: `uv run pytest tests/test_optional_extras_isolation.py -v` -Expected: both `test_importing_httpware_does_not_import_msgspec` and `test_importing_httpware_does_not_import_pydantic` pass. - -- [ ] **Step 5: Run `test_public_api.py`** - -Run: `uv run pytest tests/test_public_api.py -v` -Expected: all 3 tests pass. - -- [ ] **Step 6: Run `test_decoders_pydantic.py`** - -Run: `uv run pytest tests/test_decoders_pydantic.py -v` -Expected: all 16 existing tests pass with the new import path. - -- [ ] **Step 7: Run the entire test suite to catch any other consumer of the top-level export** - -Run: `uv run pytest -v` -Expected: every test passes. If anything fails with `ImportError: cannot import name 'PydanticDecoder' from 'httpware'`, fix the consumer to use the submodule path. - -- [ ] **Step 8: Lint** - -Run: `uv run ruff check . && uv run ty check` -Expected: clean. - -- [ ] **Step 9: Stage and commit** - -```bash -git add src/httpware/__init__.py tests/test_public_api.py tests/test_decoders_pydantic.py tests/test_optional_extras_isolation.py -git commit -m "$(cat <<'EOF' -feat(api): drop top-level PydanticDecoder re-export - -PydanticDecoder is no longer re-exported from httpware/__init__.py. -Consumers import it from httpware.decoders.pydantic instead, mirroring -how MsgspecDecoder is already accessed. test_public_api.py moves the -symbol from expected to removed; tests/test_decoders_pydantic.py uses -the submodule import path; a new subprocess test in -tests/test_optional_extras_isolation.py guards against pydantic being -re-introduced as a transitive load. - -Breaking change for callers using `from httpware import PydanticDecoder`. - -Part of the 0.3.0 pydantic-optional-extra work -(planning/specs/2026-06-04-pydantic-optional-extra-design.md). -EOF -)" -``` - ---- - -## Task 8: Malformed-payload tests for `PydanticDecoder` - -**Files:** -- Modify: `tests/test_decoders_pydantic.py` - -- [ ] **Step 1: Append parametrized test cases to the end of the file** - -```python -@pytest.mark.parametrize( - ("payload", "model"), - [ - (b"", int), - (b"", User), - (b"null", int), - (b"null", User), - (b"{}", User), - (b"{not-json}", User), - (b"\xff\xfe\x00\x00", User), - ], -) -def test_malformed_payload_raises_validation_error(payload: bytes, model: type) -> None: - """Pin current pydantic-core behavior for malformed payloads. - - A future pydantic upgrade that changes which error type surfaces will fail - this test, surfacing the change for explicit acceptance or workaround. - """ - with pytest.raises(pydantic.ValidationError): - PydanticDecoder().decode(payload, model) -``` - -The `User` model is already defined at the top of the file (`class User(pydantic.BaseModel): id: int; name: str`). No other changes needed. - -- [ ] **Step 2: Run the new tests, verify they pass** - -Run: `uv run pytest tests/test_decoders_pydantic.py::test_malformed_payload_raises_validation_error -v` -Expected: 7 parametrized cases, all **PASS**. The tests pin current pydantic-core behavior; they do not change the implementation. - -- [ ] **Step 3: Run the full pydantic-decoder test file to ensure no regression** - -Run: `uv run pytest tests/test_decoders_pydantic.py -v` -Expected: 16 original + 7 new = 23 tests, all pass. - -- [ ] **Step 4: Lint** - -Run: `uv run ruff check tests/test_decoders_pydantic.py` -Expected: clean. - -- [ ] **Step 5: Stage and commit** - -```bash -git add tests/test_decoders_pydantic.py -git commit -m "$(cat <<'EOF' -test: pin pydantic-core behavior for malformed payloads - -Parametrized tests for empty bytes, null literal, empty object, -malformed JSON, and invalid UTF-8 against both a primitive int and a -BaseModel subclass. Pins current pydantic.ValidationError surface so a -future pydantic upgrade that changes the error type fails visibly -instead of silently changing semantics. - -Closes the "empty/malformed payload tests" item from -planning/deferred-work.md. Part of the 0.3.0 pydantic-optional-extra -work. -EOF -)" -``` - ---- - -## Task 9: Move `pydantic` to `[project.optional-dependencies]` + version bump - -**Files:** -- Modify: `pyproject.toml` - -- [ ] **Step 1: Read the current `[project]` and `[project.optional-dependencies]` sections** - -Run: `sed -n '1,50p' pyproject.toml` -Expected: `version = "0.2.0"`, `dependencies = ["httpx2>=2.0.0,<3.0", "pydantic>=2.0,<3.0"]`, `[project.optional-dependencies]` has `msgspec`, `otel`, `all = ["httpware[msgspec,otel]"]`. - -- [ ] **Step 2: Update `dependencies`** - -Remove `"pydantic>=2.0,<3.0"` from the `dependencies` list. The remaining list is just `["httpx2>=2.0.0,<3.0"]`. - -- [ ] **Step 3: Add `pydantic` extra** - -Inside `[project.optional-dependencies]`, add `pydantic = ["pydantic>=2.0,<3.0"]` as the first entry (alphabetical pairing is not enforced; this just keeps related extras grouped logically). - -- [ ] **Step 4: Update the `all` extra** - -Change `all = ["httpware[msgspec,otel]"]` to `all = ["httpware[pydantic,msgspec,otel]"]`. - -- [ ] **Step 5: Bump the version** - -Change `version = "0.2.0"` to `version = "0.3.0"`. - -- [ ] **Step 6: Refresh the lockfile and reinstall** - -Run: `just install` -Expected: `uv lock --upgrade && uv sync --all-extras --frozen --group lint` completes without error. `uv.lock` should now list `pydantic` as an extra-gated dependency. - -- [ ] **Step 7: Re-run the full test suite under the new install** - -Run: `just test` -Expected: every test passes. The isolation subprocess test (`test_importing_httpware_does_not_import_pydantic`) is the load-bearing one here — if it fails, something still imports pydantic at the package root. - -- [ ] **Step 8: Lint (full project)** - -Run: `just lint-ci` -Expected: clean. - -- [ ] **Step 9: Stage and commit** - -```bash -git add pyproject.toml uv.lock -git commit -m "$(cat <<'EOF' -feat(extras): move pydantic to optional-dependencies + bump to 0.3.0 - -pydantic moves from [project] dependencies to -[project.optional-dependencies]. Install httpware[pydantic] to keep -the default-decoder UX; the all extra now bundles pydantic, msgspec, -and otel. - -Breaking change. See planning/releases/0.3.0.md for the full migration -story. Part of the 0.3.0 pydantic-optional-extra work. -EOF -)" -``` - ---- - -## Task 10: `README.md` freshness pass - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Read the current README** - -Run: `cat README.md` -Expected: 55 lines starting with `# httpware`, mentions "0.1.0 alpha" status, `RecordedTransport`, and a Quickstart that uses `from pydantic import BaseModel`. - -- [ ] **Step 2: Rewrite the top blurb (line 10)** - -Replace the existing blurb line with: - -```markdown -`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. -``` - -- [ ] **Step 3: Rewrite the status note (line 12)** - -Replace with: - -```markdown -> **Status:** Pre-1.0 (0.3.0). Public API is subject to change between minor releases until v1.0. Resilience middleware (retry / timeout / bulkhead), streaming, and observability are not yet shipped. -``` - -- [ ] **Step 4: Rewrite the Install section** - -Replace the existing Install block (lines 14–26) with: - -```markdown -## Install - -```bash -pip install httpware # core only — no decoder -pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path) -pip install httpware[msgspec] # + MsgspecDecoder -pip install httpware[all] # everything declared above (pydantic, msgspec, otel) -``` - -`AsyncClient()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing. The `otel` extra is declared but the OpenTelemetry middleware (Epic 5) has not shipped yet. -``` - -- [ ] **Step 5: Update the Quickstart** - -Prepend a one-line note before the code block, and verify the example still parses: - -```markdown -## Quickstart - -> Requires: `pip install httpware[pydantic]` - -```python -from httpware import AsyncClient -from pydantic import BaseModel - - -class User(BaseModel): - id: int - name: str - - -async def main() -> None: - async with AsyncClient(base_url="https://api.example.com") as client: - user = await client.get("/users/1", response_model=User) - print(user.name) -``` -``` - -- [ ] **Step 6: Confirm no stale references remain** - -Run: `grep -nE 'RecordedTransport|0\.1\.0|respx|niquests' README.md` -Expected: zero matches. If any survive, remove them. - -- [ ] **Step 7: Stage (hold the commit until Task 11)** - -```bash -git add README.md -``` - ---- - -## Task 11: `planning/engineering.md` §1, §3, §7 updates - -**Files:** -- Modify: `planning/engineering.md` - -- [ ] **Step 1: Update §1 ("Project intent")** - -In `planning/engineering.md`, find §1's sentence: - -``` -`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; Pydantic ships as the default, msgspec as an opt-in extra), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx. -``` - -Replace with: - -``` -`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request` and `httpx2.Response` as the public request/response surface and adds three things on top: typed response decoding (via a `ResponseDecoder` protocol; pydantic and msgspec are both opt-in extras as of 0.3.0), a middleware chain composed at client construction, and a status-keyed exception tree raised automatically on 4xx and 5xx. `AsyncClient(decoder=None)` defaults to constructing a `PydanticDecoder` and so requires the `pydantic` extra; callers can supply an explicit `decoder=` argument to escape the default. -``` - -- [ ] **Step 2: Update §3 Seam C ("`httpware ↔ optional extras`")** - -Find the Seam C "Where:" bullet: - -``` -- **Where:** `pyproject.toml` extras (`[project.optional-dependencies]`) ↔ the adapter modules that import them. -``` - -Leave that as-is. After the existing rule line, append a new line: - -``` -- **Verification:** `tests/test_optional_extras_isolation.py` runs a fresh-subprocess `import httpware` and asserts that neither pydantic nor msgspec ends up in `sys.modules`. New extras must add the same isolation test. -``` - -- [ ] **Step 3: Update §7 ("Optional-extras pattern")** - -The §7 code block already shows pydantic in `[project.optional-dependencies]`, so it is now accurate. After the code block, replace the prose paragraph that ends with "grep for `import pydantic` should return exactly one file" with: - -``` -Each extra's code lives in a single dedicated module (`decoders/pydantic.py`, `decoders/msgspec.py`, `middleware/observability/otel.py` when Epic 5 lands). The `import` of the extra happens **inside** that module behind an `is__installed` guard from `_internal/import_checker.py` — never at package top level. This way, `import httpware` works cleanly without the extras installed, and the seam stays observable: `grep -rE '^from pydantic|^import pydantic' src/httpware/` returns exactly one file (the guarded import in `decoders/pydantic.py`), and the same is true for `msgspec`. -``` - -- [ ] **Step 4: Verify nothing else in engineering.md references "Pydantic ships as the default"** - -Run: `grep -nE 'Pydantic ships as the default|pydantic.*required|required dependency' planning/engineering.md` -Expected: zero matches. If any survive, update them to match the new framing. - -- [ ] **Step 5: Stage and commit (bundles README + engineering.md)** - -```bash -git add planning/engineering.md -git commit -m "$(cat <<'EOF' -docs: README freshness pass + engineering.md §1/§3/§7 for 0.3.0 - -README rewrites the post-pivot blurb, replaces the stale "0.1.0 alpha" -status with 0.3.0, drops the RecordedTransport reference, and adds the -[pydantic] extra to install instructions. engineering.md §1 retracts -"Pydantic ships as the default"; §3 Seam C adds the isolation-test -verification rule; §7 spells out the guarded-import pattern explicitly. - -Part of the 0.3.0 pydantic-optional-extra work. -EOF -)" -``` - ---- - -## Task 12: Draft `planning/releases/0.3.0.md` - -**Files:** -- Create: `planning/releases/0.3.0.md` - -- [ ] **Step 1: Write the release notes** - -Copy this content verbatim into `planning/releases/0.3.0.md`: - -```markdown -# httpware 0.3.0 — pydantic as an optional extra - -## Breaking changes - -- **`pydantic` is no longer a required dependency.** It moved from `[project] dependencies` to `[project.optional-dependencies]`. Install it explicitly: `pip install httpware[pydantic]`. The `httpware[all]` extra continues to include it. -- **`httpware.PydanticDecoder` is no longer re-exported from the top-level package.** Import directly from the submodule: `from httpware.decoders.pydantic import PydanticDecoder`. This mirrors the existing `MsgspecDecoder` import path. -- **`AsyncClient()` with `decoder=None` and no pydantic extra raises `ImportError` at `__init__`.** Pass `decoder=MsgspecDecoder()` or install `httpware[pydantic]` to keep the default behavior. - -## Other changes - -- `tests/test_decoders_pydantic.py` adds parametrized payload-edge tests that pin current pydantic-core behavior for `b""`, `b"null"`, `b"{}"`, malformed JSON, and invalid UTF-8. -- `tests/test_optional_extras_isolation.py` now covers both pydantic and msgspec via fresh-subprocess `import httpware` checks. -- README freshness pass: status line corrected from "0.1.0 alpha" to "0.3.0"; post-pivot framing replaces the pre-pivot description; `RecordedTransport` reference removed. - -## Migration - -```python -# 0.2.0 -from httpware import AsyncClient, PydanticDecoder - -async with AsyncClient(base_url="https://api.example.com") as client: - user = await client.get("/users/1", response_model=User) -``` - -```python -# 0.3.0 — option 1: install the extra, code unchanged -# pip install httpware[pydantic] -from httpware import AsyncClient - -async with AsyncClient(base_url="https://api.example.com") as client: - user = await client.get("/users/1", response_model=User) - -# 0.3.0 — option 2: import PydanticDecoder from the submodule -from httpware import AsyncClient -from httpware.decoders.pydantic import PydanticDecoder - -async with AsyncClient(decoder=PydanticDecoder()) as client: - user = await client.get("/users/1", response_model=User) -``` - -## What's next - -Epic 3 (resilience middleware — retry, timeout, bulkhead) and Epic 5 (observability) ship in subsequent releases. See `planning/engineering.md` §8. -``` - -- [ ] **Step 2: Stage and commit** - -```bash -git add planning/releases/0.3.0.md -git commit -m "$(cat <<'EOF' -chore(release): draft 0.3.0 release notes - -Documents the pydantic-as-optional-extra breaking change with two -migration paths (install the extra, or import PydanticDecoder from the -submodule). Notes the malformed-payload tests, isolation-test addition, -and README freshness pass. - -Part of the 0.3.0 pydantic-optional-extra work. -EOF -)" -``` - ---- - -## Task 13: Final verification - -**Files:** none modified. - -- [ ] **Step 1: Verify the full test suite still passes** - -Run: `just test` -Expected: every test green. - -- [ ] **Step 2: Verify the lint and type checks are clean** - -Run: `just lint-ci` -Expected: ruff format/check pass, ty check passes, no errors. - -- [ ] **Step 3: Verify CI invariants enforced by the project** - -Run: `grep -rE 'httpx2\._' src/httpware/` -Expected: zero matches (the no-`httpx2._` invariant from `CLAUDE.md`). - -Run: `grep -rE '^from pydantic|^import pydantic' src/httpware/` -Expected: exactly one match — `src/httpware/decoders/pydantic.py`'s guarded import. - -Run: `grep -rE '^from msgspec|^import msgspec' src/httpware/` -Expected: exactly one match — `src/httpware/decoders/msgspec.py`'s guarded import. - -Run: `grep -rE 'from __future__ import annotations' src/httpware/` -Expected: zero matches (project rule). - -Run: `grep -rE 'print\(' src/httpware/` -Expected: zero matches (project rule). - -- [ ] **Step 4: Verify the spec acceptance criteria** - -Open `planning/specs/2026-06-04-pydantic-optional-extra-design.md` § Acceptance criteria. Walk each bullet and confirm it is true. Twelve bullets, all should pass. - -- [ ] **Step 5: Verify branch state** - -Run: `git log --oneline main..HEAD` -Expected: 7 commits on the feature branch, in order: - 1. `feat(extras): guard pydantic import + fail-fast in PydanticDecoder.__init__` (Task 4) - 2. `feat(client): lazy default decoder with fail-fast at __init__` (Task 5) - 3. `feat(api): drop top-level PydanticDecoder re-export` (Task 7) - 4. `test: pin pydantic-core behavior for malformed payloads` (Task 8) - 5. `feat(extras): move pydantic to optional-dependencies + bump to 0.3.0` (Task 9) - 6. `docs: README freshness pass + engineering.md §1/§3/§7 for 0.3.0` (Task 11) - 7. `chore(release): draft 0.3.0 release notes` (Task 12) - -- [ ] **Step 6: Push and open the PR** - -```bash -git push -u origin feat/v0.3-pydantic-optional -gh pr create --title "feat: pydantic as an optional extra (0.3.0)" --body "$(cat <<'EOF' -## Summary - -- Moves `pydantic` from a required dependency to an opt-in extra (`pip install httpware[pydantic]`). -- Drops the top-level `httpware.PydanticDecoder` re-export; consumers import from `httpware.decoders.pydantic` instead, matching the `MsgspecDecoder` pattern. -- `AsyncClient(decoder=None)` without the `pydantic` extra installed now raises `ImportError` at `__init__` with a clear install hint. -- Pins pydantic-core behavior on malformed payloads via parametrized tests. -- Brings `README.md` and `planning/engineering.md` up to date for 0.3.0. - -## Spec - -[`planning/specs/2026-06-04-pydantic-optional-extra-design.md`](planning/specs/2026-06-04-pydantic-optional-extra-design.md) - -## Test plan - -- [ ] `just lint-ci` clean -- [ ] `just test` green -- [ ] `grep -rE '^from pydantic|^import pydantic' src/httpware/` returns exactly one file -- [ ] `pip install httpware` (core only, no extra) — `from httpware import AsyncClient` works; `AsyncClient()` raises `ImportError` with the install hint -- [ ] `pip install httpware[pydantic]` — `AsyncClient()` works, default `PydanticDecoder` is constructed -- [ ] `from httpware import PydanticDecoder` fails with `ImportError: cannot import name 'PydanticDecoder'` (breaking change is visible) - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -Expected: PR URL printed. - ---- - -## Self-review - -After completing all tasks, walk this checklist before declaring done: - -**Spec coverage** — every requirement in `planning/specs/2026-06-04-pydantic-optional-extra-design.md` has a task: -- Deliverable 1 (pyproject.toml): Task 9 ✓ -- Deliverable 2 (import_checker): Task 1 ✓ -- Deliverable 3 (decoders/pydantic.py): Task 3 ✓ -- Deliverable 4 (client.py): Task 5 ✓ -- Deliverable 5 (httpware/__init__.py): Task 7 ✓ -- Deliverable 6.1 (test updates): Task 7 ✓ -- Deliverable 6.2 (pydantic-isolation test): Task 6 + Task 7 ✓ -- Deliverable 6.3 (fail-fast tests): Task 2 + Task 5 ✓ -- Deliverable 6.4 (malformed-payload tests): Task 8 ✓ -- Deliverable 7.1 (README): Task 10 ✓ -- Deliverable 7.2/7.3/7.4 (engineering.md): Task 11 ✓ -- Deliverable 8 (release notes): Task 12 ✓ -- Deliverable 9 (deferred-work updates after merge): out of band — addressed in the post-merge memory/deferred-work update conversation, not in this plan. - -**Type consistency** — the same names used everywhere: `is_pydantic_installed`, `_default_pydantic_decoder`, `MISSING_DEPENDENCY_MESSAGE`, `_DEFAULT_DECODER_MISSING_MESSAGE`, `PydanticDecoder`. No drift. - -**Placeholder scan** — no TBDs, no "implement later", no "similar to Task N" hand-waves. Every code change has full code; every test has full code. - -**Commit ordering** — 7 logical commits: Task 4 (foundation + decoders guard + tests), Task 5 (client lazy default), Task 7 (top-level drop + test updates), Task 8 (payload tests), Task 9 (pyproject + lock), Task 11 (README + engineering.md), Task 12 (release notes). Matches the spec's §Execution order. diff --git a/planning/changes/2026-06-04.02-v0.2-retro-and-housekeeping/design.md b/planning/changes/2026-06-04.02-v0.2-retro-and-housekeeping.md similarity index 100% rename from planning/changes/2026-06-04.02-v0.2-retro-and-housekeeping/design.md rename to planning/changes/2026-06-04.02-v0.2-retro-and-housekeeping.md diff --git a/planning/changes/2026-06-05.01-retry-and-retry-budget/design.md b/planning/changes/2026-06-05.01-retry-and-retry-budget.md similarity index 100% rename from planning/changes/2026-06-05.01-retry-and-retry-budget/design.md rename to planning/changes/2026-06-05.01-retry-and-retry-budget.md diff --git a/planning/changes/2026-06-05.01-retry-and-retry-budget/plan.md b/planning/changes/2026-06-05.01-retry-and-retry-budget/plan.md deleted file mode 100644 index 120c3f8..0000000 --- a/planning/changes/2026-06-05.01-retry-and-retry-budget/plan.md +++ /dev/null @@ -1,1897 +0,0 @@ -# Retry middleware + RetryBudget (0.4.0 slice 1) 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 the `Retry` middleware and Finagle-style `RetryBudget` token bucket. Retry automatically retries transient failures (network errors, timeouts, and selected 5xx/429/408 status codes) on idempotent methods, with exponential-with-full-jitter backoff, `Retry-After` honoring, an optional per-attempt wall-clock cap, and a budget that prevents retry storms when downstream services degrade. - -**Architecture:** New `src/httpware/middleware/resilience/` subpackage holds three small, independently-testable units: `RetryBudget` (pure token-bucket math), a private `_backoff.py` helper (pure full-jitter math), and the `Retry` middleware that orchestrates them. A new `NetworkError(TransportError)` exception isolates transient-network failures from non-retryable transport failures (`InvalidURL`, `CookieConflict`), so Retry can confidently retry network blips without retrying typos. Retry sees `StatusError` subclasses as exceptions raised by the `AsyncClient` terminal (the terminal already raises on 4xx/5xx), so the retry decision for status codes lives in an `except StatusError as exc:` branch that inspects `exc.response.status_code`. - -**Tech Stack:** Python 3.11+ (`asyncio.timeout()` requires 3.11), `httpx2`, `pytest` / `pytest-asyncio` (auto mode), `hypothesis`, `uv`, `just`, `ruff`, `ty`. - -**Target branch:** `feat/v0.4-retry-and-budget`. Create from `main` before Task 1: `git checkout main && git pull && git checkout -b feat/v0.4-retry-and-budget`. - -**Source spec:** [`planning/specs/2026-06-05-retry-and-retry-budget-design.md`](../specs/2026-06-05-retry-and-retry-budget-design.md). Read it before starting — the *why* for each decision lives there. - ---- - -## File structure - -**New files:** -- `src/httpware/middleware/resilience/__init__.py` — re-exports `Retry`, `RetryBudget`. -- `src/httpware/middleware/resilience/budget.py` — `RetryBudget` token bucket. -- `src/httpware/middleware/resilience/_backoff.py` — private full-jitter exponential helper. -- `src/httpware/middleware/resilience/retry.py` — `Retry` middleware + module-level constants + private `_parse_retry_after`. -- `tests/test_budget.py` — `RetryBudget` unit tests (deterministic `_now`). -- `tests/test_budget_props.py` — Hypothesis property tests for `RetryBudget`. -- `tests/test_retry.py` — `Retry` middleware integration tests via injected `httpx2.MockTransport`. -- `tests/test_retry_props.py` — Hypothesis property tests for `Retry`. - -**Modified files:** -- `src/httpware/errors.py` — add `NetworkError(TransportError)` and `RetryBudgetExhaustedError(ClientError)`. -- `src/httpware/client.py` — refine terminal mapping to raise `NetworkError` from `httpx2`'s transient-network exception family. -- `src/httpware/__init__.py` — export `Retry`, `RetryBudget`, `RetryBudgetExhaustedError`, `NetworkError`. -- `tests/test_errors.py` — assert the two new exceptions exist and their inheritance is correct. -- `tests/test_error_mapping_terminal.py` — update existing connect-error test to expect `NetworkError` instead of bare `TransportError`; add a new test asserting `InvalidURL` still maps to bare `TransportError`. -- `tests/test_public_api.py` — add the four new symbols to the expected exports set. - -**Commit cadence:** each Task ends with a `git add` + `git commit`. Per-task commits keep history reviewable; the branch is squash-mergeable. - ---- - -## Task 1: Branch + scaffold `resilience/` subpackage - -**Files:** -- Create: `src/httpware/middleware/resilience/__init__.py` -- Create: `src/httpware/middleware/resilience/budget.py` (stub) -- Create: `src/httpware/middleware/resilience/retry.py` (stub) -- Create: `src/httpware/middleware/resilience/_backoff.py` (stub) - -- [ ] **Step 1: Create the branch** - -Run: -```bash -git checkout main && git pull && git checkout -b feat/v0.4-retry-and-budget -``` -Expected: switched to a new branch. - -- [ ] **Step 2: Create the package directory and four stub files** - -Run: -```bash -mkdir -p src/httpware/middleware/resilience -``` - -Then create each file with the contents below. Use the Write tool, not bash heredocs. - -`src/httpware/middleware/resilience/__init__.py` (docstring-only — re-exports defer to Task 7 so intermediate tasks can `import httpware.middleware.resilience.budget` without tripping an import-time `ImportError` from this `__init__.py`): -```python -"""Resilience primitives: Retry middleware and RetryBudget token bucket. - -Re-exports land in Task 7 once both classes exist; until then this file is -docstring-only so that importing ``httpware.middleware.resilience.budget`` -during the intermediate tasks does not trip an import-time ``ImportError``. -""" -``` - -`src/httpware/middleware/resilience/budget.py`: -```python -"""Finagle-style token-bucket retry budget. See planning/specs/2026-06-05-retry-and-retry-budget-design.md.""" -``` - -`src/httpware/middleware/resilience/retry.py`: -```python -"""Retry middleware. See planning/specs/2026-06-05-retry-and-retry-budget-design.md.""" -``` - -`src/httpware/middleware/resilience/_backoff.py`: -```python -"""Full-jitter exponential backoff helper (private).""" -``` - -- [ ] **Step 3: Verify imports load cleanly** - -The package `__init__.py` references `RetryBudget` and `Retry`, neither of which exists yet. Defer importing the package itself; for this step just check the files exist. - -Run: -```bash -ls src/httpware/middleware/resilience/ -``` -Expected: `__init__.py _backoff.py budget.py retry.py` - -- [ ] **Step 4: Stage and commit** - -Run: -```bash -git add src/httpware/middleware/resilience/ -git commit -m "scaffold(resilience): empty subpackage for Retry + RetryBudget" -``` - ---- - -## Task 2: Add `NetworkError(TransportError)` + refine terminal mapping - -**Files:** -- Modify: `src/httpware/errors.py` (add class) -- Modify: `src/httpware/client.py` (refine terminal mapping) -- Modify: `tests/test_errors.py` (assert inheritance) -- Modify: `tests/test_error_mapping_terminal.py` (existing `connect_error` test now expects `NetworkError`; add new `invalid_url` assertion) - -This unblocks Task 7's `Retry` middleware: without `NetworkError`, Retry can't distinguish transient network failures from non-retryable transport failures like `InvalidURL`. - -- [ ] **Step 1: Write failing inheritance test in `tests/test_errors.py`** - -Append to `tests/test_errors.py`: -```python -from httpware.errors import NetworkError - - -def test_network_error_is_transport_error() -> None: - exc = NetworkError("connection refused") - assert isinstance(exc, TransportError) - assert isinstance(exc, ClientError) -``` - -Run: `uv run pytest tests/test_errors.py::test_network_error_is_transport_error -v` -Expected: FAIL (`ImportError: cannot import name 'NetworkError'`). - -- [ ] **Step 2: Add `NetworkError` to `src/httpware/errors.py`** - -Edit `src/httpware/errors.py`. Add a new class immediately after the existing `class TransportError`: -```python -class NetworkError(TransportError): - """Transient network-layer failure (connect/read/write/close). Safe to retry. - -Pool-acquisition timeouts are NOT under this class; they raise ``TimeoutError`` -via ``httpx2.PoolTimeout`` (a ``TimeoutException`` subclass). -""" -``` - -Run: `uv run pytest tests/test_errors.py::test_network_error_is_transport_error -v` -Expected: PASS. - -- [ ] **Step 3: Add a `NetworkError` import in `tests/test_error_mapping_terminal.py`** - -`NetworkError` isn't exported from `httpware/__init__.py` until Task 13. Add a direct import from `httpware.errors` (a separate line from the existing `from httpware import (...)` block at the top of the file): - -```python -from httpware.errors import NetworkError -``` - -- [ ] **Step 4: Update `test_httpx2_connect_error_maps_to_transport_error` test** - -In `tests/test_error_mapping_terminal.py`, rename the test and change the assertion: -```python -async def test_httpx2_connect_error_maps_to_network_error() -> None: - def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - msg = "connect refused" - raise httpx2.ConnectError(msg) - - client = _client_with_handler(handler) - with pytest.raises(NetworkError, match="connect refused"): - await client.send(httpx2.Request("GET", "https://example.test/x")) -``` - -Run: `uv run pytest tests/test_error_mapping_terminal.py::test_httpx2_connect_error_maps_to_network_error -v` -Expected: FAIL (still maps to bare `TransportError`). - -- [ ] **Step 5: Refine the terminal mapping in `src/httpware/client.py`** - -Update the imports block at the top of `client.py` to add `NetworkError`: -```python -from httpware.errors import ( - STATUS_TO_EXCEPTION, - ClientStatusError, - NetworkError, - ServerStatusError, - TimeoutError, # noqa: A004 - TransportError, -) -``` - -Update the `_terminal` method's `except` chain. The current block: -```python - try: - response = await self._httpx2_client.send(request) - except httpx2.TimeoutException as exc: - raise TimeoutError(str(exc)) from exc - except (httpx2.InvalidURL, httpx2.CookieConflict) as exc: - raise TransportError(str(exc)) from exc - except httpx2.HTTPError as exc: - raise TransportError(str(exc)) from exc - except RuntimeError as exc: - if "closed" in str(exc): - raise TransportError(str(exc)) from exc - raise -``` - -Becomes: -```python - try: - response = await self._httpx2_client.send(request) - except httpx2.TimeoutException as exc: - raise TimeoutError(str(exc)) from exc - except (httpx2.InvalidURL, httpx2.CookieConflict) as exc: - raise TransportError(str(exc)) from exc - except httpx2.NetworkError as exc: - raise NetworkError(str(exc)) from exc - except httpx2.HTTPError as exc: - raise TransportError(str(exc)) from exc - except RuntimeError as exc: - if "closed" in str(exc): - raise TransportError(str(exc)) from exc - raise -``` - -The `httpx2.NetworkError` branch must come BEFORE `httpx2.HTTPError` (HTTPError is the broader base). `httpx2.NetworkError` is httpx's documented base for `ConnectError`, `ReadError`, `WriteError`, `PoolTimeout` — if `httpx2`'s symbol name differs (e.g., `httpx2.exceptions.NetworkError`), use whichever import path mirrors the existing `httpx2.ConnectError` import in `tests/test_error_mapping_terminal.py` (which works via top-level `httpx2`). - -If `httpx2.NetworkError` does not exist, fall back to enumerating the transient subset explicitly: `except (httpx2.ConnectError, httpx2.ReadError, httpx2.WriteError, httpx2.CloseError) as exc:`. (`PoolTimeout` is NOT in this list — it inherits from `httpx2.TimeoutException` and is already caught by the timeout branch above.) The plan author has confirmed `httpx2.ConnectError` and `httpx2.ReadTimeout` already work in the existing tests; the enumeration fallback is safe. - -- [ ] **Step 6: Run the new terminal-mapping test** - -Run: `uv run pytest tests/test_error_mapping_terminal.py::test_httpx2_connect_error_maps_to_network_error -v` -Expected: PASS. - -- [ ] **Step 7: Add a regression test that bare `TransportError` still applies to `InvalidURL`** - -This test already exists as `test_httpx2_invalid_url_maps_to_transport_error`. Verify it still passes: - -Run: `uv run pytest tests/test_error_mapping_terminal.py::test_httpx2_invalid_url_maps_to_transport_error -v` -Expected: PASS — `InvalidURL` continues to map to bare `TransportError`, NOT `NetworkError`. - -- [ ] **Step 8: Add explicit assertion that `InvalidURL` does NOT map to NetworkError** - -Append to `tests/test_error_mapping_terminal.py`: -```python -async def test_httpx2_invalid_url_does_not_map_to_network_error() -> None: - """Regression: only transient errors map to NetworkError; InvalidURL stays bare TransportError.""" - - def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - msg = "bad url" - raise httpx2.InvalidURL(msg) - - client = _client_with_handler(handler) - with pytest.raises(TransportError) as info: - await client.send(httpx2.Request("GET", "https://example.test/x")) - assert not isinstance(info.value, NetworkError) -``` - -Run: `uv run pytest tests/test_error_mapping_terminal.py::test_httpx2_invalid_url_does_not_map_to_network_error -v` -Expected: PASS. - -- [ ] **Step 9: Run the full error-mapping test file** - -Run: `uv run pytest tests/test_error_mapping_terminal.py tests/test_errors.py -v` -Expected: all PASS. - -- [ ] **Step 10: Stage and commit** - -Run: -```bash -git add src/httpware/errors.py src/httpware/client.py tests/test_errors.py tests/test_error_mapping_terminal.py -git commit -m "feat(errors): add NetworkError(TransportError) for transient httpx2 failures - -Refines _terminal so httpx2.NetworkError-family exceptions (ConnectError, ReadError, -WriteError, CloseError) map to httpware.NetworkError. InvalidURL and CookieConflict -stay bare TransportError. Prerequisite for the Retry middleware so it can retry -transient failures without retrying typos." -``` - ---- - -## Task 3: Add `RetryBudgetExhaustedError` to errors.py - -**Files:** -- Modify: `src/httpware/errors.py` -- Modify: `tests/test_errors.py` - -- [ ] **Step 1: Write failing test** - -Append to `tests/test_errors.py`: -```python -from httpware.errors import RetryBudgetExhaustedError - - -def test_retry_budget_exhausted_error_is_client_error() -> None: - exc = RetryBudgetExhaustedError(last_response=None, last_exception=None, attempts=3) - assert isinstance(exc, ClientError) - assert exc.last_response is None - assert exc.last_exception is None - assert exc.attempts == 3 - - -def test_retry_budget_exhausted_error_carries_last_response_and_exception() -> None: - response = _make_response(503, url="https://example.test/x") - inner = RuntimeError("boom") - exc = RetryBudgetExhaustedError(last_response=response, last_exception=inner, attempts=2) - assert exc.last_response is response - assert exc.last_exception is inner - assert exc.attempts == 2 - - -def test_retry_budget_exhausted_error_summary_mentions_attempts() -> None: - exc = RetryBudgetExhaustedError(last_response=None, last_exception=None, attempts=5) - assert "5" in str(exc) -``` - -Run: `uv run pytest tests/test_errors.py::test_retry_budget_exhausted_error_is_client_error -v` -Expected: FAIL (`ImportError`). - -- [ ] **Step 2: Add the class to `src/httpware/errors.py`** - -Append at the end of `src/httpware/errors.py` (after the `STATUS_TO_EXCEPTION` mapping): - -```python -class RetryBudgetExhaustedError(ClientError): - """Raised when a retry was needed but the RetryBudget refused to permit it. - - Carries the last response and/or exception observed before the budget refused, - plus the number of attempts already completed. - """ - - last_response: httpx2.Response | None - last_exception: BaseException | None - attempts: int - - def __init__( - self, - *, - last_response: httpx2.Response | None, - last_exception: BaseException | None, - attempts: int, - ) -> None: - self.last_response = last_response - self.last_exception = last_exception - self.attempts = attempts - super().__init__(f"retry budget exhausted after {attempts} attempt(s)") -``` - -Run: `uv run pytest tests/test_errors.py -v -k "retry_budget_exhausted"` -Expected: all three new tests PASS. - -- [ ] **Step 3: Stage and commit** - -Run: -```bash -git add src/httpware/errors.py tests/test_errors.py -git commit -m "feat(errors): add RetryBudgetExhaustedError - -Distinct exception raised by the Retry middleware when the RetryBudget -refuses to permit a retry. Carries last_response / last_exception / attempts. -Inherits ClientError so callers catching ClientError already handle it." -``` - ---- - -## Task 4: Implement `RetryBudget` with unit tests - -**Files:** -- Modify: `src/httpware/middleware/resilience/budget.py` -- Create: `tests/test_budget.py` - -- [ ] **Step 1: Write failing tests in `tests/test_budget.py`** - -Create `tests/test_budget.py`: -```python -"""Unit tests for RetryBudget token-bucket math. - -Tests inject a deterministic `_now` callable rather than monkeypatching `time.monotonic`, -so they cannot be perturbed by other tests sharing the same module. -""" - -from collections.abc import Callable - -import pytest - -from httpware.middleware.resilience.budget import RetryBudget - - -class _Clock: - """Mutable clock for deterministic tests. Pass `clock.now` as `_now`.""" - - def __init__(self, start: float = 0.0) -> None: - self._t = start - - def now(self) -> float: - return self._t - - def advance(self, seconds: float) -> None: - self._t += seconds - - -def _budget( - *, - ttl: float = 10.0, - min_retries_per_sec: float = 10.0, - percent_can_retry: float = 0.2, - now: Callable[[], float] | None = None, -) -> RetryBudget: - clock = _Clock() - return RetryBudget( - ttl=ttl, - min_retries_per_sec=min_retries_per_sec, - percent_can_retry=percent_can_retry, - _now=now if now is not None else clock.now, - ) - - -def test_defaults_match_spec() -> None: - budget = RetryBudget() - # Defaults: ttl=10.0, min_retries_per_sec=10.0, percent_can_retry=0.2 - assert budget._ttl == 10.0 # noqa: SLF001 - assert budget._min_retries_per_sec == 10.0 # noqa: SLF001 - assert budget._percent_can_retry == 0.2 # noqa: SLF001 - - -def test_floor_permits_min_retries_per_sec_times_ttl_with_zero_deposits() -> None: - # floor = min_retries_per_sec * ttl = 10 * 10 = 100 permitted withdrawals - clock = _Clock() - budget = RetryBudget(ttl=10.0, min_retries_per_sec=10.0, percent_can_retry=0.0, _now=clock.now) - permitted = sum(1 for _ in range(101) if budget.try_withdraw()) - assert permitted == 100 - - -def test_percent_can_retry_ceiling_with_deposits() -> None: - # 1000 deposits * 0.2 = 200 retries permitted (plus floor 100 = 300 total) - clock = _Clock() - budget = RetryBudget(ttl=10.0, min_retries_per_sec=10.0, percent_can_retry=0.2, _now=clock.now) - for _ in range(1000): - budget.deposit() - permitted = sum(1 for _ in range(500) if budget.try_withdraw()) - assert permitted == 300 - - -def test_ttl_expiry_purges_old_deposits() -> None: - clock = _Clock() - budget = RetryBudget(ttl=1.0, min_retries_per_sec=0.0, percent_can_retry=0.5, _now=clock.now) - for _ in range(10): - budget.deposit() - # 10 deposits * 0.5 = 5 retries available immediately - assert budget.try_withdraw() is True - # Advance past TTL; deposits expire - clock.advance(2.0) - # With min_retries_per_sec=0 and no live deposits, no retries permitted - assert budget.try_withdraw() is False - - -def test_try_withdraw_returns_false_when_exhausted() -> None: - clock = _Clock() - budget = RetryBudget(ttl=10.0, min_retries_per_sec=1.0, percent_can_retry=0.0, _now=clock.now) - # floor = 1 * 10 = 10 retries - for _ in range(10): - assert budget.try_withdraw() is True - assert budget.try_withdraw() is False - - -def test_deposit_after_exhaustion_does_not_immediately_unblock() -> None: - """A single deposit at 20% percent_can_retry contributes 0.2 → floor (int truncation) → 0 new retries.""" - clock = _Clock() - budget = RetryBudget(ttl=10.0, min_retries_per_sec=1.0, percent_can_retry=0.2, _now=clock.now) - # exhaust the floor (10) - for _ in range(10): - budget.try_withdraw() - assert budget.try_withdraw() is False - # one deposit: 1 * 0.2 = 0.2 → int() → 0 - budget.deposit() - assert budget.try_withdraw() is False - # 5 more deposits: 6 * 0.2 = 1.2 → int() → 1 new retry permitted - for _ in range(5): - budget.deposit() - assert budget.try_withdraw() is True - assert budget.try_withdraw() is False - - -def test_withdrawn_also_expires_after_ttl() -> None: - """After TTL passes, prior withdrawals no longer count against the budget.""" - clock = _Clock() - budget = RetryBudget(ttl=1.0, min_retries_per_sec=10.0, percent_can_retry=0.0, _now=clock.now) - for _ in range(10): - budget.try_withdraw() - assert budget.try_withdraw() is False - clock.advance(2.0) - assert budget.try_withdraw() is True - - -def test_default_now_is_time_monotonic() -> None: - """When _now is not passed, the budget uses time.monotonic by default.""" - import time - - budget = RetryBudget() - assert budget._now is time.monotonic # noqa: SLF001 -``` - -Run: `uv run pytest tests/test_budget.py -v` -Expected: all FAIL with `ImportError`. - -- [ ] **Step 2: Implement `RetryBudget` in `src/httpware/middleware/resilience/budget.py`** - -Replace the stub with: -```python -"""Finagle-style token-bucket retry budget. - -See planning/specs/2026-06-05-retry-and-retry-budget-design.md for the contract. -No locking: asyncio runs coroutines cooperatively on a single thread, so deque -mutations between await points are atomic with respect to other coroutines on -the same event loop. Cross-thread use is out of scope. -""" - -import time -from collections import deque -from collections.abc import Callable - - -class RetryBudget: - """Token-bucket budget bounding retry rate to prevent retry storms. - - Each request deposits a token; each retry attempts to withdraw one. - Available retries are bounded by `percent_can_retry` of recent deposits, - plus a `min_retries_per_sec * ttl` floor. - """ - - def __init__( - self, - *, - ttl: float = 10.0, - min_retries_per_sec: float = 10.0, - percent_can_retry: float = 0.2, - _now: Callable[[], float] = time.monotonic, - ) -> None: - self._ttl = ttl - self._min_retries_per_sec = min_retries_per_sec - self._percent_can_retry = percent_can_retry - self._now = _now - self._deposits: deque[float] = deque() - self._withdrawn: deque[float] = deque() - - def _purge(self, now: float) -> None: - cutoff = now - self._ttl - while self._deposits and self._deposits[0] < cutoff: - self._deposits.popleft() - while self._withdrawn and self._withdrawn[0] < cutoff: - self._withdrawn.popleft() - - def deposit(self) -> None: - """Record a request (success or failure attempt). Adds one token.""" - now = self._now() - self._purge(now) - self._deposits.append(now) - - def try_withdraw(self) -> bool: - """Atomically attempt to spend one retry token. - - Returns True if a retry is permitted, False if the budget is exhausted. - Never blocks. - """ - now = self._now() - self._purge(now) - floor = int(self._min_retries_per_sec * self._ttl) - ceiling = int(len(self._deposits) * self._percent_can_retry) + floor - if len(self._withdrawn) >= ceiling: - return False - self._withdrawn.append(now) - return True -``` - -- [ ] **Step 3: Run the budget tests** - -Run: `uv run pytest tests/test_budget.py -v` -Expected: all PASS. - -- [ ] **Step 4: Run the full lint** - -Run: `uv run ruff check src/httpware/middleware/resilience/budget.py tests/test_budget.py && uv run ty check src/httpware/middleware/resilience/budget.py` -Expected: clean. - -- [ ] **Step 5: Stage and commit** - -Run: -```bash -git add src/httpware/middleware/resilience/budget.py tests/test_budget.py -git commit -m "feat(resilience): RetryBudget token-bucket math + tests - -Finagle-style: ttl=10s, min_retries_per_sec=10, percent_can_retry=0.2. -Deterministic time via injected _now callable for tests." -``` - ---- - -## Task 5: Hypothesis property tests for `RetryBudget` - -**Files:** -- Create: `tests/test_budget_props.py` - -- [ ] **Step 1: Create the property-test file** - -```python -"""Hypothesis property tests for RetryBudget. - -Properties verified: -1. `try_withdraw()` never permits more than `floor + int(deposits * percent)` over any window. -2. After advancing the clock past `ttl`, all prior deposits expire (no retries permitted - beyond the floor). -3. `deposit()` is monotonically non-decreasing in permitted retries (more deposits cannot - reduce the budget). -""" - -from collections.abc import Callable - -from hypothesis import given, settings, strategies as st - -from httpware.middleware.resilience.budget import RetryBudget - - -class _Clock: - def __init__(self) -> None: - self._t = 0.0 - - def now(self) -> float: - return self._t - - def advance(self, seconds: float) -> None: - self._t += seconds - - -def _budget( - *, - ttl: float, - min_retries_per_sec: float, - percent_can_retry: float, - now: Callable[[], float], -) -> RetryBudget: - return RetryBudget( - ttl=ttl, - min_retries_per_sec=min_retries_per_sec, - percent_can_retry=percent_can_retry, - _now=now, - ) - - -@given( - ttl=st.floats(min_value=0.1, max_value=60.0, allow_nan=False, allow_infinity=False), - min_rps=st.floats(min_value=0.0, max_value=100.0, allow_nan=False, allow_infinity=False), - percent=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), - deposits=st.integers(min_value=0, max_value=10_000), -) -@settings(max_examples=200, deadline=None) -def test_try_withdraw_never_exceeds_theoretical_bound( - ttl: float, min_rps: float, percent: float, deposits: int, -) -> None: - clock = _Clock() - budget = _budget(ttl=ttl, min_retries_per_sec=min_rps, percent_can_retry=percent, now=clock.now) - for _ in range(deposits): - budget.deposit() - floor = int(min_rps * ttl) - ceiling = int(deposits * percent) + floor - permitted = 0 - # Try up to ceiling + 10 times to confirm the cap holds. - for _ in range(ceiling + 10): - if budget.try_withdraw(): - permitted += 1 - assert permitted <= ceiling - - -@given( - ttl=st.floats(min_value=0.1, max_value=10.0, allow_nan=False, allow_infinity=False), - deposits=st.integers(min_value=1, max_value=1000), - percent=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False), -) -@settings(max_examples=100, deadline=None) -def test_advancing_past_ttl_purges_deposits(ttl: float, deposits: int, percent: float) -> None: - clock = _Clock() - budget = _budget(ttl=ttl, min_retries_per_sec=0.0, percent_can_retry=percent, now=clock.now) - for _ in range(deposits): - budget.deposit() - clock.advance(ttl + 0.1) - # After purge, no deposits remain; floor is 0 → no retries permitted. - assert budget.try_withdraw() is False - - -@given( - extra_deposits=st.integers(min_value=0, max_value=100), -) -@settings(max_examples=50, deadline=None) -def test_more_deposits_never_decreases_budget(extra_deposits: int) -> None: - clock = _Clock() - budget = _budget(ttl=10.0, min_retries_per_sec=1.0, percent_can_retry=0.5, now=clock.now) - # Establish a baseline - for _ in range(10): - budget.deposit() - initial_permitted = sum(1 for _ in range(100) if budget.try_withdraw()) - # Reset by creating a fresh budget with the same starting deposits + extra - budget2 = _budget(ttl=10.0, min_retries_per_sec=1.0, percent_can_retry=0.5, now=clock.now) - for _ in range(10 + extra_deposits): - budget2.deposit() - new_permitted = sum(1 for _ in range(100 + extra_deposits) if budget2.try_withdraw()) - assert new_permitted >= initial_permitted -``` - -Run: `uv run pytest tests/test_budget_props.py -v` -Expected: all PASS. - -- [ ] **Step 2: Stage and commit** - -```bash -git add tests/test_budget_props.py -git commit -m "test(resilience): Hypothesis property tests for RetryBudget" -``` - ---- - -## Task 6: Implement `_backoff.py` full-jitter helper - -**Files:** -- Modify: `src/httpware/middleware/resilience/_backoff.py` - -The helper is so small (one function) that a dedicated test file is overkill; coverage comes from `test_retry.py` integration tests. We add one focused unit test inline at the bottom of `test_retry.py` once Task 7 lands. - -- [ ] **Step 1: Implement the helper** - -Replace the stub `_backoff.py` with: -```python -"""Full-jitter exponential backoff helper (private).""" - -import random -from collections.abc import Callable - - -def full_jitter_delay( - attempt_index: int, - *, - base_delay: float, - max_delay: float, - _random_uniform: Callable[[float, float], float] = random.uniform, -) -> float: - """Return a backoff delay using AWS's "full jitter" formulation. - - sleep = uniform(0, min(max_delay, base_delay * 2 ** attempt_index)) - - `attempt_index` is 0 for the first retry, 1 for the second, etc. - """ - ceiling = min(max_delay, base_delay * (2 ** attempt_index)) - return _random_uniform(0.0, ceiling) -``` - -- [ ] **Step 2: Quick smoke check via Python REPL** - -Run: -```bash -uv run python -c "from httpware.middleware.resilience._backoff import full_jitter_delay; print(full_jitter_delay(0, base_delay=0.1, max_delay=5.0))" -``` -Expected: prints a float between 0.0 and 0.1. - -- [ ] **Step 3: Lint** - -Run: `uv run ruff check src/httpware/middleware/resilience/_backoff.py && uv run ty check src/httpware/middleware/resilience/_backoff.py` -Expected: clean. - -- [ ] **Step 4: Stage and commit** - -```bash -git add src/httpware/middleware/resilience/_backoff.py -git commit -m "feat(resilience): full-jitter exponential backoff helper" -``` - ---- - -## Task 7: Implement `Retry` middleware — skeleton + status-code retry + exhaustion - -**Files:** -- Modify: `src/httpware/middleware/resilience/retry.py` -- Create: `tests/test_retry.py` - -This task implements the happy path (no retry needed), status-code retry on 503, idempotency gate (POST not retried by default), exhaustion (max_attempts reached → re-raise with PEP-678 note), and the module-level constants. Exception-based retry (NetworkError, TimeoutError, attempt_timeout, Retry-After, budget) come in Tasks 8-11. - -- [ ] **Step 1: Write the failing tests in `tests/test_retry.py`** - -Create `tests/test_retry.py`: -```python -"""Tests for the Retry middleware. - -Mocks the transport via httpx2.MockTransport; injects a recording `_sleep` -callable so the suite runs instantly without freezegun. -""" - -from collections.abc import Callable -from http import HTTPStatus - -import httpx2 -import pytest - -from httpware import AsyncClient, NotFoundError, ServiceUnavailableError -from httpware.middleware.resilience.budget import RetryBudget -from httpware.middleware.resilience.retry import ( - DEFAULT_IDEMPOTENT_METHODS, - DEFAULT_RETRY_STATUS_CODES, - Retry, -) - - -class _SleepRecorder: - def __init__(self) -> None: - self.calls: list[float] = [] - - async def __call__(self, delay: float) -> None: - self.calls.append(delay) - - -class _ResponseSequence: - """Mock-transport handler that returns a fixed sequence of responses.""" - - def __init__(self, statuses: list[int]) -> None: - self._statuses = list(statuses) - self.calls: int = 0 - - def __call__(self, request: httpx2.Request) -> httpx2.Response: - self.calls += 1 - status = self._statuses.pop(0) if self._statuses else HTTPStatus.OK - return httpx2.Response(status, request=request) - - -def _client(handler: Callable[[httpx2.Request], httpx2.Response], *, retry: Retry) -> AsyncClient: - transport = httpx2.MockTransport(handler) - return AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[retry], - ) - - -def test_default_retry_status_codes_match_spec() -> None: - assert DEFAULT_RETRY_STATUS_CODES == frozenset({408, 429, 502, 503, 504}) - - -def test_default_idempotent_methods_match_spec() -> None: - assert DEFAULT_IDEMPOTENT_METHODS == frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) - - -async def test_succeeds_first_try_no_sleep() -> None: - sleeper = _SleepRecorder() - handler = _ResponseSequence([HTTPStatus.OK]) - client = _client(handler, retry=Retry(_sleep=sleeper)) - response = await client.get("https://example.test/x") - assert response.status_code == HTTPStatus.OK - assert handler.calls == 1 - assert sleeper.calls == [] - - -async def test_retries_503_then_succeeds() -> None: - sleeper = _SleepRecorder() - handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.OK]) - client = _client(handler, retry=Retry(_sleep=sleeper, base_delay=0.01, max_delay=0.02)) - response = await client.get("https://example.test/x") - assert response.status_code == HTTPStatus.OK - assert handler.calls == 2 - assert len(sleeper.calls) == 1 - assert 0.0 <= sleeper.calls[0] <= 0.02 - - -async def test_gives_up_after_max_attempts_and_reraises_status_error() -> None: - sleeper = _SleepRecorder() - handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE] * 3) - client = _client(handler, retry=Retry(_sleep=sleeper, base_delay=0.01, max_delay=0.02, max_attempts=3)) - with pytest.raises(ServiceUnavailableError) as info: - await client.get("https://example.test/x") - assert handler.calls == 3 - assert len(sleeper.calls) == 2 # max_attempts=3 → 2 sleeps between 3 attempts - notes = getattr(info.value, "__notes__", []) - assert any("gave up after 3 attempts" in note for note in notes) - - -async def test_does_not_retry_non_retryable_status() -> None: - sleeper = _SleepRecorder() - handler = _ResponseSequence([HTTPStatus.NOT_FOUND]) - client = _client(handler, retry=Retry(_sleep=sleeper)) - with pytest.raises(NotFoundError): - await client.get("https://example.test/x") - assert handler.calls == 1 - assert sleeper.calls == [] - - -async def test_does_not_retry_non_idempotent_methods_by_default() -> None: - sleeper = _SleepRecorder() - handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE]) - client = _client(handler, retry=Retry(_sleep=sleeper)) - with pytest.raises(ServiceUnavailableError): - await client.post("https://example.test/x", json={"x": 1}) - assert handler.calls == 1 - assert sleeper.calls == [] - - -async def test_retries_post_when_method_explicitly_included() -> None: - sleeper = _SleepRecorder() - handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.OK]) - methods = frozenset(DEFAULT_IDEMPOTENT_METHODS | {"POST"}) - client = _client( - handler, - retry=Retry(_sleep=sleeper, retry_methods=methods, base_delay=0.01, max_delay=0.02), - ) - response = await client.post("https://example.test/x", json={"x": 1}) - assert response.status_code == HTTPStatus.OK - assert handler.calls == 2 - - -async def test_max_attempts_one_means_no_retries() -> None: - sleeper = _SleepRecorder() - handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE]) - client = _client(handler, retry=Retry(_sleep=sleeper, max_attempts=1)) - with pytest.raises(ServiceUnavailableError): - await client.get("https://example.test/x") - assert handler.calls == 1 - assert sleeper.calls == [] - - -def test_max_attempts_zero_rejected() -> None: - with pytest.raises(ValueError, match="max_attempts must be >= 1"): - Retry(max_attempts=0) -``` - -Run: `uv run pytest tests/test_retry.py -v` -Expected: all FAIL (`ImportError` for `Retry`, `DEFAULT_RETRY_STATUS_CODES`, etc.). - -- [ ] **Step 2: Implement the `Retry` middleware skeleton** - -Replace the `src/httpware/middleware/resilience/retry.py` stub with: -```python -"""Retry middleware — automatic retry of transient failures with budget control. - -See planning/specs/2026-06-05-retry-and-retry-budget-design.md for the full contract. - -Status-code retry: the AsyncClient terminal raises StatusError subclasses on 4xx/5xx, -so Retry catches StatusError and inspects exc.response.status_code. The original -StatusError subclass is re-raised unwrapped on exhaustion, with a PEP 678 note added. -""" - -import asyncio -from collections.abc import Awaitable, Callable -from http import HTTPStatus - -import httpx2 - -from httpware.errors import RetryBudgetExhaustedError, StatusError -from httpware.middleware import Next -from httpware.middleware.resilience._backoff import full_jitter_delay -from httpware.middleware.resilience.budget import RetryBudget - - -DEFAULT_RETRY_STATUS_CODES = frozenset({ - int(HTTPStatus.REQUEST_TIMEOUT), - int(HTTPStatus.TOO_MANY_REQUESTS), - int(HTTPStatus.BAD_GATEWAY), - int(HTTPStatus.SERVICE_UNAVAILABLE), - int(HTTPStatus.GATEWAY_TIMEOUT), -}) - -DEFAULT_IDEMPOTENT_METHODS = frozenset({ - "GET", "HEAD", "OPTIONS", "PUT", "DELETE", -}) - -_MAX_ATTEMPTS_INVALID = "max_attempts must be >= 1" - - -class Retry: - """Retry middleware. See module docstring for default policy.""" - - def __init__( - self, - *, - max_attempts: int = 3, - base_delay: float = 0.1, - max_delay: float = 5.0, - attempt_timeout: float | None = None, - retry_status_codes: frozenset[int] = DEFAULT_RETRY_STATUS_CODES, - retry_methods: frozenset[str] = DEFAULT_IDEMPOTENT_METHODS, - respect_retry_after: bool = True, - budget: RetryBudget | None = None, - _sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, - ) -> None: - if max_attempts < 1: - raise ValueError(_MAX_ATTEMPTS_INVALID) - self.max_attempts = max_attempts - self.base_delay = base_delay - self.max_delay = max_delay - self.attempt_timeout = attempt_timeout - self.retry_status_codes = retry_status_codes - self.retry_methods = retry_methods - self.respect_retry_after = respect_retry_after - self.budget = budget if budget is not None else RetryBudget() - self._sleep = _sleep - - async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 - """Process a request through the retry loop. See module docstring.""" - method_eligible = request.method.upper() in self.retry_methods - last_exc: BaseException | None = None - last_response: httpx2.Response | None = None - - for attempt in range(self.max_attempts): - is_last = attempt + 1 >= self.max_attempts - self.budget.deposit() - try: - return await next(request) - except StatusError as exc: - if not method_eligible or exc.response.status_code not in self.retry_status_codes: - raise - last_exc = exc - last_response = exc.response - - # ---- retryable failure path - if is_last: - assert last_exc is not None # noqa: S101 — invariant from the except branch - last_exc.add_note(f"httpware: gave up after {attempt + 1} attempts") - raise last_exc - - if not self.budget.try_withdraw(): - raise RetryBudgetExhaustedError( - last_response=last_response, - last_exception=last_exc, - attempts=attempt + 1, - ) from last_exc - - delay = full_jitter_delay(attempt, base_delay=self.base_delay, max_delay=self.max_delay) - await self._sleep(delay) - - raise AssertionError("unreachable") # pragma: no cover -``` - -- [ ] **Step 3: Run the Task 7 tests** - -Run: `uv run pytest tests/test_retry.py -v` -Expected: all PASS. - -- [ ] **Step 4: Wire `Retry` + `RetryBudget` into `resilience/__init__.py`** - -Now that both classes exist, replace `src/httpware/middleware/resilience/__init__.py` with: -```python -"""Resilience primitives: Retry middleware and RetryBudget token bucket.""" - -from httpware.middleware.resilience.budget import RetryBudget -from httpware.middleware.resilience.retry import Retry - - -__all__ = ["Retry", "RetryBudget"] -``` - -The `__all__` is required to silence ruff F401 ("imported but unused") and matches the pattern used by `httpware/__init__.py` and `httpware/decoders/__init__.py`. - -- [ ] **Step 5: Lint** - -Run: `uv run ruff check src/httpware/middleware/resilience/ tests/test_retry.py && uv run ty check src/httpware/middleware/resilience/` -Expected: clean. If ruff flags `Callable` / `Awaitable` import paths, adjust per existing project pattern (see `middleware/__init__.py` which uses `from collections.abc import Awaitable, Callable`). - -- [ ] **Step 6: Stage and commit** - -```bash -git add src/httpware/middleware/resilience/retry.py src/httpware/middleware/resilience/__init__.py tests/test_retry.py -git commit -m "feat(resilience): Retry middleware — status-code retry + exhaustion - -Covers: happy path, 503-then-200, max_attempts exhaustion with PEP-678 note, -idempotency gate (POST not retried by default, opt-in via retry_methods), -non-retryable status passthrough (404 raised immediately). -Exception-based retry, attempt_timeout, Retry-After, and budget integration -follow in subsequent commits." -``` - ---- - -## Task 8: `Retry` — exception-based retry (NetworkError, TimeoutError, bare TransportError passthrough) - -**Files:** -- Modify: `src/httpware/middleware/resilience/retry.py` -- Modify: `tests/test_retry.py` - -- [ ] **Step 1: Write failing tests** - -Append to `tests/test_retry.py` (NetworkError isn't on `httpware/__init__.py` until Task 13, so import from `httpware.errors` directly): - -```python -from httpware import TransportError -from httpware.errors import NetworkError, TimeoutError as HttpwareTimeoutError - - -async def test_retries_on_network_error() -> None: - sleeper = _SleepRecorder() - call_count = {"n": 0} - - def handler(request: httpx2.Request) -> httpx2.Response: - call_count["n"] += 1 - if call_count["n"] < 2: - msg = "transient" - raise httpx2.ConnectError(msg) - return httpx2.Response(HTTPStatus.OK, request=request) - - client = _client(handler, retry=Retry(_sleep=sleeper, base_delay=0.01, max_delay=0.02)) - response = await client.get("https://example.test/x") - assert response.status_code == HTTPStatus.OK - assert call_count["n"] == 2 - assert len(sleeper.calls) == 1 - - -async def test_retries_on_httpware_timeout_error() -> None: - sleeper = _SleepRecorder() - call_count = {"n": 0} - - def handler(request: httpx2.Request) -> httpx2.Response: - call_count["n"] += 1 - if call_count["n"] < 2: - msg = "read timeout" - raise httpx2.ReadTimeout(msg) - return httpx2.Response(HTTPStatus.OK, request=request) - - client = _client(handler, retry=Retry(_sleep=sleeper, base_delay=0.01, max_delay=0.02)) - response = await client.get("https://example.test/x") - assert response.status_code == HTTPStatus.OK - assert call_count["n"] == 2 - - -async def test_does_not_retry_on_bare_transport_error_like_invalid_url() -> None: - sleeper = _SleepRecorder() - - def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - msg = "bad url" - raise httpx2.InvalidURL(msg) - - client = _client(handler, retry=Retry(_sleep=sleeper)) - with pytest.raises(TransportError) as info: - await client.get("https://example.test/x") - assert not isinstance(info.value, NetworkError) - assert sleeper.calls == [] - - -async def test_network_error_exhaustion_reraises_with_note() -> None: - sleeper = _SleepRecorder() - - def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - msg = "never works" - raise httpx2.ConnectError(msg) - - client = _client(handler, retry=Retry(_sleep=sleeper, max_attempts=2, base_delay=0.01, max_delay=0.02)) - with pytest.raises(NetworkError) as info: - await client.get("https://example.test/x") - notes = getattr(info.value, "__notes__", []) - assert any("gave up after 2 attempts" in note for note in notes) - - -async def test_does_not_retry_network_error_on_non_idempotent_method() -> None: - sleeper = _SleepRecorder() - call_count = {"n": 0} - - def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - call_count["n"] += 1 - msg = "transient" - raise httpx2.ConnectError(msg) - - client = _client(handler, retry=Retry(_sleep=sleeper)) - with pytest.raises(NetworkError): - await client.post("https://example.test/x", json={"x": 1}) - assert call_count["n"] == 1 - assert sleeper.calls == [] -``` - -Run: `uv run pytest tests/test_retry.py -v -k "network_error or transport_error or timeout"` -Expected: tests FAIL — current Retry only catches `StatusError`, not network/timeout exceptions. - -- [ ] **Step 2: Extend the `except` chain in `Retry.__call__`** - -Update the `try`/`except` block. Add an additional import at the top of `retry.py`: -```python -from httpware.errors import NetworkError, RetryBudgetExhaustedError, StatusError, TimeoutError # noqa: A004 -``` - -Replace the `try`/`except` block inside the for-loop with: -```python - try: - return await next(request) - except StatusError as exc: - if not method_eligible or exc.response.status_code not in self.retry_status_codes: - raise - last_exc = exc - last_response = exc.response - except (NetworkError, TimeoutError) as exc: - if not method_eligible: - raise - last_exc = exc - last_response = None -``` - -- [ ] **Step 3: Run the tests** - -Run: `uv run pytest tests/test_retry.py -v` -Expected: all PASS, including the new exception-retry tests. - -- [ ] **Step 4: Stage and commit** - -```bash -git add src/httpware/middleware/resilience/retry.py tests/test_retry.py -git commit -m "feat(resilience): Retry — network/timeout exception retry - -Retries NetworkError and TimeoutError on idempotent methods. -Bare TransportError (e.g., InvalidURL) is NOT retried since it -escaped the NetworkError refinement in errors.py." -``` - ---- - -## Task 9: `Retry` — `attempt_timeout` (asyncio.timeout → httpware.TimeoutError) - -**Files:** -- Modify: `src/httpware/middleware/resilience/retry.py` -- Modify: `tests/test_retry.py` - -- [ ] **Step 1: Write failing tests** - -Append to `tests/test_retry.py`: -```python -async def test_attempt_timeout_fires_and_retries() -> None: - sleeper = _SleepRecorder() - call_count = {"n": 0} - - async def handler_async(request: httpx2.Request) -> httpx2.Response: - call_count["n"] += 1 - if call_count["n"] < 2: - await asyncio.sleep(1.0) # exceeds attempt_timeout - return httpx2.Response(HTTPStatus.OK, request=request) - - # MockTransport's handler can be async; route through httpx2.MockTransport. - transport = httpx2.MockTransport(handler_async) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[Retry(_sleep=sleeper, attempt_timeout=0.05, base_delay=0.01, max_delay=0.02)], - ) - response = await client.get("https://example.test/x") - assert response.status_code == HTTPStatus.OK - assert call_count["n"] == 2 - - -async def test_attempt_timeout_exhaustion_raises_httpware_timeout() -> None: - sleeper = _SleepRecorder() - - async def slow_handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - await asyncio.sleep(1.0) - msg = "should not reach" - raise AssertionError(msg) # pragma: no cover - - transport = httpx2.MockTransport(slow_handler) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[Retry(_sleep=sleeper, attempt_timeout=0.05, max_attempts=2, base_delay=0.01, max_delay=0.02)], - ) - with pytest.raises(HttpwareTimeoutError) as info: - await client.get("https://example.test/x") - notes = getattr(info.value, "__notes__", []) - assert any("gave up after 2 attempts" in note for note in notes) - - -async def test_attempt_timeout_does_not_retry_on_non_idempotent_method() -> None: - sleeper = _SleepRecorder() - - async def slow_handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - await asyncio.sleep(1.0) - msg = "should not reach" - raise AssertionError(msg) # pragma: no cover - - transport = httpx2.MockTransport(slow_handler) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[Retry(_sleep=sleeper, attempt_timeout=0.05)], - ) - with pytest.raises(HttpwareTimeoutError): - await client.post("https://example.test/x", json={"x": 1}) - assert sleeper.calls == [] # not retried -``` - -Add `import asyncio` to the test imports at the top of `tests/test_retry.py` if not already present. - -Run: `uv run pytest tests/test_retry.py -v -k "attempt_timeout"` -Expected: FAIL — Retry doesn't wrap `next(request)` in `asyncio.timeout()` yet. - -- [ ] **Step 2: Wrap the call in `asyncio.timeout()` and catch `asyncio.TimeoutError`** - -Inside `Retry.__call__`, replace the `try` block: -```python - try: - if self.attempt_timeout is not None: - async with asyncio.timeout(self.attempt_timeout): - return await next(request) - else: - return await next(request) - except StatusError as exc: - if not method_eligible or exc.response.status_code not in self.retry_status_codes: - raise - last_exc = exc - last_response = exc.response - except (NetworkError, TimeoutError) as exc: - if not method_eligible: - raise - last_exc = exc - last_response = None - except asyncio.TimeoutError as exc: - wrapped = TimeoutError("attempt timed out") - wrapped.__cause__ = exc - if not method_eligible: - raise wrapped from exc - last_exc = wrapped - last_response = None -``` - -NOTE: `asyncio.TimeoutError` is an alias for `builtins.TimeoutError` in Python 3.11+. `httpware.TimeoutError` ALSO inherits from `builtins.TimeoutError`. So `except asyncio.TimeoutError` would also catch the `httpware.TimeoutError` from the second except — but Python tries the except clauses in order, so the more-specific `(NetworkError, TimeoutError)` branch handles `httpware.TimeoutError` first. - -However, there's a subtlety: `asyncio.timeout()` raises `asyncio.TimeoutError`, which IS the same class as `builtins.TimeoutError` in 3.11+. `httpware.TimeoutError` is a SUBCLASS of `builtins.TimeoutError`. So `except (NetworkError, TimeoutError)` catches `httpware.TimeoutError` and any `httpware.TimeoutError` raised below (e.g., from the terminal mapping httpx2.ReadTimeout). The bare `asyncio.TimeoutError` raised by `asyncio.timeout()` is caught by the third clause `except asyncio.TimeoutError`. Order matters. - -If `httpx2.TimeoutException` from the terminal is mapped to `httpware.TimeoutError` (it is), and `httpware.TimeoutError` is a subclass of `builtins.TimeoutError`, then `except asyncio.TimeoutError` would catch it too if the second clause is removed. The second clause must stay so we don't wrap an already-`httpware.TimeoutError` again. - -- [ ] **Step 3: Run the tests** - -Run: `uv run pytest tests/test_retry.py -v` -Expected: all PASS. - -- [ ] **Step 4: Stage and commit** - -```bash -git add src/httpware/middleware/resilience/retry.py tests/test_retry.py -git commit -m "feat(resilience): Retry.attempt_timeout (wall-clock per-attempt cap) - -Wraps each attempt in asyncio.timeout(); maps asyncio.TimeoutError to -httpware.TimeoutError. Caught timeouts count as retryable failures -subject to the idempotency + attempt-count gates." -``` - ---- - -## Task 10: `Retry` — `Retry-After` honoring (int, HTTP-date, cap, malformed) - -**Files:** -- Modify: `src/httpware/middleware/resilience/retry.py` -- Modify: `tests/test_retry.py` - -- [ ] **Step 1: Add imports + failing tests** - -At the TOP of `tests/test_retry.py` (with the other top-level imports — ruff E402 will flag mid-file imports), add: -```python -import datetime -import email.utils -``` - -Then append to the bottom of `tests/test_retry.py`: -```python -async def test_retry_after_seconds_overrides_backoff() -> None: - sleeper = _SleepRecorder() - handler = _ResponseSequenceWithHeaders([ - (HTTPStatus.SERVICE_UNAVAILABLE, {"Retry-After": "2"}), - (HTTPStatus.OK, {}), - ]) - client = _client(handler, retry=Retry(_sleep=sleeper, base_delay=0.01, max_delay=5.0)) - response = await client.get("https://example.test/x") - assert response.status_code == HTTPStatus.OK - assert sleeper.calls == [2.0] # Retry-After overrode the backoff - - -async def test_retry_after_http_date_overrides_backoff() -> None: - sleeper = _SleepRecorder() - future = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=3) - http_date = email.utils.format_datetime(future, usegmt=True) - handler = _ResponseSequenceWithHeaders([ - (HTTPStatus.SERVICE_UNAVAILABLE, {"Retry-After": http_date}), - (HTTPStatus.OK, {}), - ]) - client = _client(handler, retry=Retry(_sleep=sleeper, base_delay=0.01, max_delay=10.0)) - response = await client.get("https://example.test/x") - assert response.status_code == HTTPStatus.OK - assert len(sleeper.calls) == 1 - assert 2.0 <= sleeper.calls[0] <= 4.0 # ~3 seconds, with clock-skew tolerance - - -async def test_retry_after_capped_at_max_delay() -> None: - sleeper = _SleepRecorder() - handler = _ResponseSequenceWithHeaders([ - (HTTPStatus.SERVICE_UNAVAILABLE, {"Retry-After": "9999"}), - (HTTPStatus.OK, {}), - ]) - client = _client(handler, retry=Retry(_sleep=sleeper, base_delay=0.01, max_delay=2.5)) - await client.get("https://example.test/x") - assert sleeper.calls == [2.5] # capped - - -async def test_malformed_retry_after_falls_back_to_backoff() -> None: - sleeper = _SleepRecorder() - handler = _ResponseSequenceWithHeaders([ - (HTTPStatus.SERVICE_UNAVAILABLE, {"Retry-After": "not-a-number"}), - (HTTPStatus.OK, {}), - ]) - client = _client(handler, retry=Retry(_sleep=sleeper, base_delay=0.01, max_delay=0.05)) - await client.get("https://example.test/x") - assert len(sleeper.calls) == 1 - assert 0.0 <= sleeper.calls[0] <= 0.05 - - -async def test_respect_retry_after_false_ignores_header() -> None: - sleeper = _SleepRecorder() - handler = _ResponseSequenceWithHeaders([ - (HTTPStatus.SERVICE_UNAVAILABLE, {"Retry-After": "5"}), - (HTTPStatus.OK, {}), - ]) - client = _client( - handler, - retry=Retry(_sleep=sleeper, respect_retry_after=False, base_delay=0.01, max_delay=0.02), - ) - await client.get("https://example.test/x") - assert 0.0 <= sleeper.calls[0] <= 0.02 # backoff range, not 5 -``` - -Add at the top of `tests/test_retry.py`: -```python -class _ResponseSequenceWithHeaders: - """Mock handler that returns (status, headers) tuples in sequence.""" - - def __init__(self, responses: list[tuple[int, dict[str, str]]]) -> None: - self._responses = list(responses) - self.calls = 0 - - def __call__(self, request: httpx2.Request) -> httpx2.Response: - self.calls += 1 - status, headers = self._responses.pop(0) - return httpx2.Response(status, request=request, headers=headers) -``` - -Run: `uv run pytest tests/test_retry.py -v -k "retry_after or respect_retry_after"` -Expected: FAIL. - -- [ ] **Step 2: Add the `_parse_retry_after` helper to `retry.py`** - -Add this module-level private function near the top of `retry.py` (after the constants): -```python -import datetime -import email.utils - - -def _parse_retry_after(value: str) -> float | None: - """Parse a Retry-After header value. Returns None on malformed input.""" - try: - return float(int(value)) - except ValueError: - pass - try: - parsed = email.utils.parsedate_to_datetime(value) - except (TypeError, ValueError): - return None - if parsed is None: - return None - now = datetime.datetime.now(datetime.timezone.utc) - delta = (parsed - now).total_seconds() - return max(0.0, delta) -``` - -- [ ] **Step 3: Use `_parse_retry_after` in the retry loop** - -Inside `Retry.__call__`, after the except chain, BEFORE the budget/sleep block, compute the effective delay: - -Replace the existing sleep computation: -```python - delay = full_jitter_delay(attempt, base_delay=self.base_delay, max_delay=self.max_delay) - await self._sleep(delay) -``` - -With: -```python - retry_after: float | None = None - if self.respect_retry_after and last_response is not None: - header = last_response.headers.get("Retry-After") - if header is not None: - retry_after = _parse_retry_after(header) - - if retry_after is not None: - delay = min(retry_after, self.max_delay) - else: - delay = full_jitter_delay( - attempt, base_delay=self.base_delay, max_delay=self.max_delay, - ) - await self._sleep(delay) -``` - -- [ ] **Step 4: Run the tests** - -Run: `uv run pytest tests/test_retry.py -v` -Expected: all PASS. - -- [ ] **Step 5: Stage and commit** - -```bash -git add src/httpware/middleware/resilience/retry.py tests/test_retry.py -git commit -m "feat(resilience): Retry honors Retry-After header (seconds + HTTP-date) - -Parsed delay overrides backoff and is capped at max_delay. Malformed values -fall back to backoff. respect_retry_after=False disables the override." -``` - ---- - -## Task 11: `Retry` — budget gate + sharing across instances - -**Files:** -- Modify: `src/httpware/middleware/resilience/retry.py` -- Modify: `tests/test_retry.py` - -- [ ] **Step 1: Write failing tests** - -Append to `tests/test_retry.py`: -```python -from httpware.errors import RetryBudgetExhaustedError - - -def _zero_budget() -> RetryBudget: - """A budget that always refuses withdrawal (floor=0, percent=0).""" - return RetryBudget(ttl=10.0, min_retries_per_sec=0.0, percent_can_retry=0.0) - - -async def test_budget_exhausted_raises_specific_exception() -> None: - sleeper = _SleepRecorder() - handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.OK]) - client = _client( - handler, - retry=Retry(_sleep=sleeper, budget=_zero_budget(), base_delay=0.01, max_delay=0.02), - ) - with pytest.raises(RetryBudgetExhaustedError) as info: - await client.get("https://example.test/x") - assert info.value.attempts == 1 # one attempt made, budget refused before retry - assert info.value.last_response is not None - assert info.value.last_response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - assert isinstance(info.value.last_exception, ServiceUnavailableError) - - -async def test_budget_exhausted_on_network_error_carries_exception_not_response() -> None: - sleeper = _SleepRecorder() - - def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - msg = "transient" - raise httpx2.ConnectError(msg) - - client = _client( - handler, - retry=Retry(_sleep=sleeper, budget=_zero_budget(), base_delay=0.01, max_delay=0.02), - ) - with pytest.raises(RetryBudgetExhaustedError) as info: - await client.get("https://example.test/x") - assert info.value.last_response is None - assert isinstance(info.value.last_exception, NetworkError) - - -async def test_default_budget_is_fresh_per_instance() -> None: - r1 = Retry() - r2 = Retry() - assert r1.budget is not r2.budget - - -async def test_explicit_budget_shared_across_retry_instances() -> None: - shared = RetryBudget(ttl=10.0, min_retries_per_sec=1.0, percent_can_retry=0.0) - r1 = Retry(budget=shared) - r2 = Retry(budget=shared) - assert r1.budget is r2.budget - # 10 retries total before exhaustion (floor=10) - for _ in range(10): - assert shared.try_withdraw() is True - assert shared.try_withdraw() is False -``` - -Run: `uv run pytest tests/test_retry.py -v -k "budget"` -Expected: tests reference behavior already in the impl from Task 7 — most should PASS. The fresh-per-instance and sharing tests pass trivially. The `budget_exhausted_*` tests should already pass since Task 7 wired `RetryBudgetExhaustedError`. If they fail, it's because the budget never refuses a withdrawal with default settings; the `_zero_budget()` fixture above ensures refusal. - -If `test_budget_exhausted_on_network_error_carries_exception_not_response` fails, check that the `(NetworkError, TimeoutError)` except branch in Task 8 correctly sets `last_exc = exc; last_response = None`. - -- [ ] **Step 2: Run the tests; fix anything that fails** - -Run: `uv run pytest tests/test_retry.py -v -k "budget"` -Expected: all PASS. - -- [ ] **Step 3: Stage and commit (only if changes were needed)** - -If tests pass without code changes, this commit just adds the budget tests: -```bash -git add tests/test_retry.py -git commit -m "test(resilience): Retry budget gate + sharing across instances" -``` - -If you needed to fix the impl, include `src/httpware/middleware/resilience/retry.py` in the add. - ---- - -## Task 12: Hypothesis property tests for `Retry` - -**Files:** -- Create: `tests/test_retry_props.py` - -- [ ] **Step 1: Create the property-test file** - -```python -"""Hypothesis property tests for Retry. - -Properties verified: -1. Total attempts never exceed max_attempts. -2. Total sleep time never exceeds max_attempts * max_delay. -3. Non-retryable statuses (NOT in retry_status_codes) cause exactly one attempt. -4. Non-idempotent methods (NOT in retry_methods) cause exactly one attempt, - regardless of response status. -""" - -from http import HTTPStatus - -import httpx2 -from hypothesis import given, settings, strategies as st - -from httpware import AsyncClient -from httpware.middleware.resilience.budget import RetryBudget -from httpware.middleware.resilience.retry import ( - DEFAULT_IDEMPOTENT_METHODS, - DEFAULT_RETRY_STATUS_CODES, - Retry, -) - - -class _SleepRecorder: - def __init__(self) -> None: - self.calls: list[float] = [] - - async def __call__(self, delay: float) -> None: - self.calls.append(delay) - - -def _always_status(status: int) -> httpx2.MockTransport: - return httpx2.MockTransport(lambda req: httpx2.Response(status, request=req)) - - -_RETRYABLE_STATUS_STRATEGY = st.sampled_from(sorted(DEFAULT_RETRY_STATUS_CODES)) -_NON_RETRYABLE_STATUS_STRATEGY = st.sampled_from([ - HTTPStatus.BAD_REQUEST, HTTPStatus.UNAUTHORIZED, HTTPStatus.NOT_FOUND, HTTPStatus.CONFLICT, -]) -_IDEMPOTENT_METHODS = st.sampled_from(sorted(DEFAULT_IDEMPOTENT_METHODS)) -_NON_IDEMPOTENT_METHODS = st.sampled_from(["POST", "PATCH"]) - - -@given( - max_attempts=st.integers(min_value=1, max_value=5), - status=_RETRYABLE_STATUS_STRATEGY, - method=_IDEMPOTENT_METHODS, -) -@settings(max_examples=50, deadline=None) -async def test_total_attempts_never_exceeds_max_attempts( - max_attempts: int, status: int, method: str, -) -> None: - sleeper = _SleepRecorder() - call_count = {"n": 0} - - def handler(request: httpx2.Request) -> httpx2.Response: - call_count["n"] += 1 - return httpx2.Response(status, request=request) - - transport = httpx2.MockTransport(handler) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[Retry( - _sleep=sleeper, - max_attempts=max_attempts, - base_delay=0.001, - max_delay=0.002, - budget=RetryBudget(ttl=60.0, min_retries_per_sec=1000.0), - )], - ) - try: - await client.request(method, "https://example.test/x") - except Exception: # noqa: BLE001 — we only care about call count - pass - assert call_count["n"] <= max_attempts - - -@given( - max_attempts=st.integers(min_value=1, max_value=5), - base_delay=st.floats(min_value=0.001, max_value=0.01), - max_delay=st.floats(min_value=0.001, max_value=0.05), -) -@settings(max_examples=30, deadline=None) -async def test_total_sleep_never_exceeds_max_attempts_times_max_delay( - max_attempts: int, base_delay: float, max_delay: float, -) -> None: - sleeper = _SleepRecorder() - transport = _always_status(HTTPStatus.SERVICE_UNAVAILABLE) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[Retry( - _sleep=sleeper, - max_attempts=max_attempts, - base_delay=base_delay, - max_delay=max_delay, - budget=RetryBudget(ttl=60.0, min_retries_per_sec=1000.0), - )], - ) - try: - await client.get("https://example.test/x") - except Exception: # noqa: BLE001 - pass - total = sum(sleeper.calls) - assert total <= max_attempts * max_delay + 1e-9 - - -@given( - status=_NON_RETRYABLE_STATUS_STRATEGY, - method=_IDEMPOTENT_METHODS, -) -@settings(max_examples=30, deadline=None) -async def test_non_retryable_status_causes_one_attempt(status: int, method: str) -> None: - sleeper = _SleepRecorder() - call_count = {"n": 0} - - def handler(request: httpx2.Request) -> httpx2.Response: - call_count["n"] += 1 - return httpx2.Response(status, request=request) - - transport = httpx2.MockTransport(handler) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[Retry(_sleep=sleeper, max_attempts=3, base_delay=0.001, max_delay=0.002)], - ) - try: - await client.request(method, "https://example.test/x") - except Exception: # noqa: BLE001 - pass - assert call_count["n"] == 1 - assert sleeper.calls == [] - - -@given( - status=_RETRYABLE_STATUS_STRATEGY, - method=_NON_IDEMPOTENT_METHODS, -) -@settings(max_examples=30, deadline=None) -async def test_non_idempotent_method_causes_one_attempt(status: int, method: str) -> None: - sleeper = _SleepRecorder() - call_count = {"n": 0} - - def handler(request: httpx2.Request) -> httpx2.Response: - call_count["n"] += 1 - return httpx2.Response(status, request=request) - - transport = httpx2.MockTransport(handler) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[Retry(_sleep=sleeper, max_attempts=3, base_delay=0.001, max_delay=0.002)], - ) - try: - await client.request(method, "https://example.test/x") - except Exception: # noqa: BLE001 - pass - assert call_count["n"] == 1 - assert sleeper.calls == [] -``` - -Run: `uv run pytest tests/test_retry_props.py -v` -Expected: all PASS. - -- [ ] **Step 2: Stage and commit** - -```bash -git add tests/test_retry_props.py -git commit -m "test(resilience): Hypothesis property tests for Retry" -``` - ---- - -## Task 13: Public API exports + final verification - -**Files:** -- Modify: `src/httpware/__init__.py` -- Modify: `tests/test_public_api.py` -- Modify: `tests/test_optional_extras_isolation.py` (only if it asserts a closed set) - -- [ ] **Step 1: Read the current public API** - -Run: `cat src/httpware/__init__.py` - -Note the existing pattern: imports grouped by source module, then `__all__` lists every exported name alphabetically. - -- [ ] **Step 2: Add the four new symbols to `httpware/__init__.py`** - -Update the imports block to add `NetworkError` and `RetryBudgetExhaustedError` to the `from httpware.errors import (...)` block (alphabetical order). - -Add a new import after the existing `from httpware.middleware import ...` line: -```python -from httpware.middleware.resilience import Retry, RetryBudget -``` - -Add the four new symbols to `__all__` in alphabetical order: -- `"NetworkError"` -- `"Retry"` -- `"RetryBudget"` -- `"RetryBudgetExhaustedError"` - -- [ ] **Step 3: Read and update the public-API test** - -Run: `cat tests/test_public_api.py` - -The test most likely asserts `set(httpware.__all__) == EXPECTED_SET`. Add the four new symbols to `EXPECTED_SET`. - -- [ ] **Step 4: Run the public-API test** - -Run: `uv run pytest tests/test_public_api.py -v` -Expected: PASS. - -- [ ] **Step 5: Run the full test suite with coverage gate** - -Run: `just test` (or `uv run pytest`) -Expected: ALL tests PASS, coverage ≥ 100%. - -If coverage falls below 100%, add targeted tests for the uncovered branches. Common offenders: -- `_parse_retry_after` malformed-date branch (parsedate_to_datetime returning None, raising TypeError, raising ValueError) — add explicit-value tests -- The `raise AssertionError("unreachable")` branch (covered by `# pragma: no cover`) -- The `assert last_exc is not None` invariant (covered structurally by the except branches) - -- [ ] **Step 6: Run the full lint** - -Run: `just lint-ci` (or `uv run ruff format . --check && uv run ruff check . && uv run ty check`) -Expected: clean. - -- [ ] **Step 7: Verify the architecture invariants from `CLAUDE.md`** - -Run each in turn: -```bash -grep -rE 'httpx2\._' src/httpware/ || echo "PASS: no httpx2 private API" -grep -rE 'from __future__ import annotations' src/httpware/ || echo "PASS: no __future__ annotations" -grep -rE '\bprint\(' src/httpware/ || echo "PASS: no print()" -grep -rE 'logging\.(basicConfig|getLogger)\(\)' src/httpware/ || echo "PASS: no global logging" -grep -rE '# (type|mypy): ignore' src/httpware/ || echo "PASS: no type/mypy ignore" -``` - -Each should print the `PASS` line (the grep returns no matches). - -- [ ] **Step 8: Verify the optional-extras isolation invariant** - -The `Retry` middleware lives in core (no extra). Verify importing httpware doesn't pull in pydantic/msgspec/otel: -```bash -uv run pytest tests/test_optional_extras_isolation.py -v -``` -Expected: PASS. - -- [ ] **Step 9: Stage and commit** - -```bash -git add src/httpware/__init__.py tests/test_public_api.py -git commit -m "feat(api): export Retry, RetryBudget, RetryBudgetExhaustedError, NetworkError - -Completes the 0.4.0 slice 1: retry middleware + Finagle-style budget -+ NetworkError refinement for transient httpx2 failures." -``` - -- [ ] **Step 10: Update `planning/engineering.md` §8 to reflect the slice landing** - -Open `planning/engineering.md`, find §8 "Remaining roadmap", under the "Surviving" subsection. Move stories 3-1 (dissolved), 3-2, 3-3, 3-4 out of the "land in subsequent PRs" list. Add a brief note that these landed in the v0.4 slice, similar to how the v0.2/v0.3 sections track shipped work. - -Also: under the architecture invariants table (CI-enforced, §2), no changes needed — `NetworkError` does not introduce a new invariant. - -Add the streaming-body deferred entry to `planning/deferred-work.md`: -```markdown -### Retry + streaming bodies (Epic 4 interaction) - -- **`Retry` re-invokes `next(request)` with the same `httpx2.Request` on each attempt.** Safe for in-memory bytes/JSON bodies; unsafe for streaming/async-iterable bodies (consumed iterator can't replay). When Epic 4 ships `AsyncClient.stream` (`4-3`), Retry needs to refuse to retry streamed-body requests (or document that callers supply a body factory). Spec: `planning/specs/2026-06-05-retry-and-retry-budget-design.md` §"Open questions". -``` - -- [ ] **Step 11: Final commit** - -```bash -git add planning/engineering.md planning/deferred-work.md -git commit -m "docs(planning): track retry+budget landing + streaming deferred follow-up" -``` - -- [ ] **Step 12: Push the branch and open the PR** - -```bash -git push -u origin feat/v0.4-retry-and-budget -``` - -Then create a PR per the project's normal cadence (gh pr create). The PR body should reference both the spec (`planning/specs/2026-06-05-retry-and-retry-budget-design.md`) and this plan. Do NOT bundle release-notes work or version bumps into this PR — those happen in a separate release-prep PR. - ---- - -## Out of scope for this plan (per the spec) - -These items are deliberately deferred. Do NOT implement them as part of this slice; if the implementation pulls toward them, stop and surface to the user instead. - -- Per-call retry override via `extensions["httpware_retry"]` — would be additive later. -- `Backoff` protocol abstraction — single hardcoded full-jitter exponential is the only strategy. -- `retry_on_exception=` parameter — retryable-exception set is hardcoded. -- `Bulkhead` middleware — slice 2 of Epic 3, separate spec. -- Standalone per-attempt-timeout middleware — folded into `Retry.attempt_timeout=`. -- Streaming request bodies — out of scope until Epic 4 lands `AsyncClient.stream`. -- Release notes / version bump for 0.4.0 — happens in a separate release-prep PR after subsequent Epic 3 slices land. diff --git a/planning/changes/2026-06-05.02-bulkhead/design.md b/planning/changes/2026-06-05.02-bulkhead.md similarity index 100% rename from planning/changes/2026-06-05.02-bulkhead/design.md rename to planning/changes/2026-06-05.02-bulkhead.md diff --git a/planning/changes/2026-06-05.02-bulkhead/plan.md b/planning/changes/2026-06-05.02-bulkhead/plan.md deleted file mode 100644 index ece683a..0000000 --- a/planning/changes/2026-06-05.02-bulkhead/plan.md +++ /dev/null @@ -1,955 +0,0 @@ -# Bulkhead middleware (0.4.0, Epic 3 slice 2) 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 the `Bulkhead` middleware — a concurrency limiter via `asyncio.Semaphore` — plus a `BulkheadFullError` exception. Bulkhead caps in-flight requests at the caller layer (distinct from `httpx2.Limits`, which caps the connection pool). A required `max_concurrent` parameter forces an explicit choice; `acquire_timeout` (default 1.0s, `None` = wait forever, `0` = fail fast) bounds the time spent waiting for a slot. - -**Architecture:** New `bulkhead.py` under `src/httpware/middleware/resilience/`, mirroring the slice-1 layout (`retry.py`, `budget.py`, `_backoff.py`). The middleware owns an `asyncio.Semaphore(max_concurrent)`, wraps `acquire()` in `asyncio.timeout(acquire_timeout)`, and uses an explicit `try/finally` around `next(request)` to guarantee release on every exit path (success, exception, cancellation). `BulkheadFullError(ClientError)` is picklable via the same `__reduce__` + module-level reconstructor pattern used by `StatusError` and `RetryBudgetExhaustedError`. - -**Tech Stack:** Python 3.11+ (`asyncio.timeout` requires 3.11), `httpx2`, `pytest` / `pytest-asyncio` (auto mode), `hypothesis`, `uv`, `just`, `ruff`, `ty`. - -**Target branch:** `feat/v0.4-bulkhead`. Create from `main` before Task 1: `git checkout main && git pull && git checkout -b feat/v0.4-bulkhead`. - -**Source spec:** [`planning/specs/2026-06-05-bulkhead-design.md`](../specs/2026-06-05-bulkhead-design.md). Read it before starting — the *why* for each decision lives there. - ---- - -## File structure - -**New files:** -- `src/httpware/middleware/resilience/bulkhead.py` — `Bulkhead` middleware class. -- `tests/test_bulkhead.py` — unit tests via `httpx2.MockTransport`. -- `tests/test_bulkhead_props.py` — Hypothesis property tests for the concurrency invariant. - -**Modified files:** -- `src/httpware/errors.py` — add `BulkheadFullError(ClientError)` + `_reconstruct_bulkhead_full` reconstructor. -- `src/httpware/middleware/resilience/__init__.py` — add `Bulkhead` to the re-exports. -- `src/httpware/__init__.py` — export `Bulkhead` and `BulkheadFullError`. -- `tests/test_errors.py` — add inheritance + pickle tests for `BulkheadFullError`. -- `tests/test_public_api.py` — add `Bulkhead` and `BulkheadFullError` to the expected exports set. -- `planning/engineering.md` — §8 mark `3-5` shipped; remaining Epic 3 is just `3-6` extension-slot docs. - -**Commit cadence:** each Task ends with a `git add` + `git commit`. Per-task commits keep history reviewable; the branch is squash-mergeable. - ---- - -## Task 1: Branch + scaffold `bulkhead.py` - -**Files:** -- Create: `src/httpware/middleware/resilience/bulkhead.py` (docstring-only stub) - -This task creates only the empty module. The class arrives in Task 3 and the `resilience/__init__.py` re-export wiring also lands in Task 3 (so we don't trip an `ImportError` during the intermediate Task 2 — same lesson as slice 1). - -- [ ] **Step 1: Create the branch** - -Run: -```bash -git checkout main && git pull && git checkout -b feat/v0.4-bulkhead -``` -Expected: switched to a new branch. - -- [ ] **Step 2: Create the stub file** - -Create `src/httpware/middleware/resilience/bulkhead.py` with: -```python -"""Bulkhead middleware — concurrency limiter via asyncio.Semaphore. - -See planning/specs/2026-06-05-bulkhead-design.md for the contract. -""" -``` - -- [ ] **Step 3: Verify file exists** - -Run: -```bash -ls src/httpware/middleware/resilience/ -``` -Expected: `__init__.py _backoff.py budget.py bulkhead.py retry.py` - -- [ ] **Step 4: Lint (sanity)** - -Run: `just lint` -Expected: clean (the docstring-only stub passes ruff/ty). - -- [ ] **Step 5: Stage and commit** - -```bash -git add src/httpware/middleware/resilience/bulkhead.py -git commit -m "scaffold(resilience): empty bulkhead.py stub" -``` - ---- - -## Task 2: Add `BulkheadFullError` to `errors.py` - -**Files:** -- Modify: `src/httpware/errors.py` -- Modify: `tests/test_errors.py` - -- [ ] **Step 1: Write failing tests in `tests/test_errors.py`** - -Append to `tests/test_errors.py`: -```python -from httpware.errors import BulkheadFullError - - -_MAX_CONCURRENT_5 = 5 -_ACQUIRE_TIMEOUT_1_0 = 1.0 - - -def test_bulkhead_full_error_is_client_error() -> None: - exc = BulkheadFullError(max_concurrent=_MAX_CONCURRENT_5, acquire_timeout=_ACQUIRE_TIMEOUT_1_0) - assert isinstance(exc, ClientError) - assert exc.max_concurrent == _MAX_CONCURRENT_5 - assert exc.acquire_timeout == _ACQUIRE_TIMEOUT_1_0 - - -def test_bulkhead_full_error_accepts_none_acquire_timeout() -> None: - exc = BulkheadFullError(max_concurrent=_MAX_CONCURRENT_5, acquire_timeout=None) - assert exc.acquire_timeout is None - - -def test_bulkhead_full_error_summary_mentions_caps() -> None: - exc = BulkheadFullError(max_concurrent=_MAX_CONCURRENT_5, acquire_timeout=_ACQUIRE_TIMEOUT_1_0) - assert str(exc) == "bulkhead full (max_concurrent=5, acquire_timeout=1.0)" - - -def test_bulkhead_full_error_pickleable() -> None: - exc = BulkheadFullError(max_concurrent=_MAX_CONCURRENT_5, acquire_timeout=_ACQUIRE_TIMEOUT_1_0) - restored = pickle.loads(pickle.dumps(exc)) # noqa: S301 - assert isinstance(restored, BulkheadFullError) - assert restored.max_concurrent == _MAX_CONCURRENT_5 - assert restored.acquire_timeout == _ACQUIRE_TIMEOUT_1_0 -``` - -Run: `uv run pytest tests/test_errors.py -v -k "bulkhead"` -Expected: FAIL (`ImportError: cannot import name 'BulkheadFullError'`). - -- [ ] **Step 2: Add `BulkheadFullError` to `src/httpware/errors.py`** - -Append at the end of `src/httpware/errors.py` (after the existing `RetryBudgetExhaustedError` block — `_reconstruct_bulkhead_full` goes above the class, mirroring the existing reconstructor pattern): - -```python -def _reconstruct_bulkhead_full( - cls: "type[BulkheadFullError]", - max_concurrent: int, - acquire_timeout: float | None, -) -> "BulkheadFullError": - return cls(max_concurrent=max_concurrent, acquire_timeout=acquire_timeout) - - -class BulkheadFullError(ClientError): - """Raised when ``acquire_timeout`` elapses before a Bulkhead slot becomes available. - - Carries the configured caps for caller logging/alerting. - """ - - max_concurrent: int - acquire_timeout: float | None - - def __init__(self, *, max_concurrent: int, acquire_timeout: float | None) -> None: - self.max_concurrent = max_concurrent - self.acquire_timeout = acquire_timeout - super().__init__( - f"bulkhead full (max_concurrent={max_concurrent}, acquire_timeout={acquire_timeout})" - ) - - def __reduce__(self) -> tuple[Any, ...]: - return ( - _reconstruct_bulkhead_full, - (type(self), self.max_concurrent, self.acquire_timeout), - ) -``` - -- [ ] **Step 3: Run the bulkhead tests** - -Run: `uv run pytest tests/test_errors.py -v -k "bulkhead"` -Expected: all 4 PASS. - -- [ ] **Step 4: Run the full suite + lint** - -Run: `just lint && just test` -Expected: clean lint, 100% coverage. - -- [ ] **Step 5: Stage and commit** - -```bash -git add src/httpware/errors.py tests/test_errors.py -git commit -m "feat(errors): add BulkheadFullError(ClientError) - -Distinct exception raised by the Bulkhead middleware when acquire_timeout -elapses without acquiring a slot. Carries max_concurrent + acquire_timeout -for caller logging. Picklable via _reconstruct_bulkhead_full + __reduce__, -mirroring the existing StatusError / RetryBudgetExhaustedError pattern. - -Inherits ClientError (not TimeoutError) because a bulkhead-full event is -a backpressure signal, not a network timeout." -``` - ---- - -## Task 3: Implement `Bulkhead` middleware — happy path + validation + capacity serialization - -**Files:** -- Modify: `src/httpware/middleware/resilience/bulkhead.py` -- Modify: `src/httpware/middleware/resilience/__init__.py` -- Create: `tests/test_bulkhead.py` - -This task implements the middleware skeleton with: constructor validation, the basic acquire→try/finally→release pattern, capacity serialization (second request waits for first to release), and re-export wiring. - -- [ ] **Step 1: Write failing tests in `tests/test_bulkhead.py`** - -Create `tests/test_bulkhead.py`: -```python -"""Tests for the Bulkhead middleware. - -Mocks the transport via httpx2.MockTransport. Concurrency tests use real -asyncio coroutines with sub-100ms timeouts so the suite stays fast. -""" - -import asyncio -from collections.abc import Callable -from http import HTTPStatus - -import httpx2 -import pytest - -from httpware import AsyncClient -from httpware.errors import BulkheadFullError -from httpware.middleware.resilience.bulkhead import Bulkhead - - -_MAX_CONCURRENT_1 = 1 -_MAX_CONCURRENT_2 = 2 - - -class _SlowHandler: - """Mock handler that blocks for `delay` seconds before returning 200 OK.""" - - def __init__(self, delay: float) -> None: - self.delay = delay - self.in_flight = 0 - self.max_in_flight = 0 - self.calls = 0 - - async def __call__(self, request: httpx2.Request) -> httpx2.Response: - self.calls += 1 - self.in_flight += 1 - self.max_in_flight = max(self.max_in_flight, self.in_flight) - try: - await asyncio.sleep(self.delay) - return httpx2.Response(HTTPStatus.OK, request=request) - finally: - self.in_flight -= 1 - - -def _client(handler: Callable[[httpx2.Request], httpx2.Response], *, bulkhead: Bulkhead) -> AsyncClient: - transport = httpx2.MockTransport(handler) - return AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[bulkhead], - ) - - -def test_max_concurrent_zero_rejected() -> None: - with pytest.raises(ValueError, match="max_concurrent must be >= 1"): - Bulkhead(max_concurrent=0) - - -def test_max_concurrent_negative_rejected() -> None: - with pytest.raises(ValueError, match="max_concurrent must be >= 1"): - Bulkhead(max_concurrent=-1) - - -def test_negative_acquire_timeout_rejected() -> None: - with pytest.raises(ValueError, match="acquire_timeout must be >= 0"): - Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=-0.1) - - -def test_acquire_timeout_zero_accepted() -> None: - bulkhead = Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=0) - assert bulkhead._acquire_timeout == 0 # noqa: SLF001 - - -def test_acquire_timeout_none_accepted() -> None: - bulkhead = Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=None) - assert bulkhead._acquire_timeout is None # noqa: SLF001 - - -async def test_succeeds_when_slot_available() -> None: - handler = _SlowHandler(delay=0.0) - client = _client(handler, bulkhead=Bulkhead(max_concurrent=_MAX_CONCURRENT_2)) - response = await client.get("https://example.test/x") - assert response.status_code == HTTPStatus.OK - assert handler.calls == 1 - - -async def test_serializes_at_capacity() -> None: - """With max_concurrent=1 and 3 concurrent calls, in-flight count never exceeds 1.""" - handler = _SlowHandler(delay=0.02) - client = _client( - handler, - bulkhead=Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=None), - ) - await asyncio.gather( - client.get("https://example.test/a"), - client.get("https://example.test/b"), - client.get("https://example.test/c"), - ) - assert handler.calls == 3 # noqa: PLR2004 — three concurrent gets above - assert handler.max_in_flight == 1 # cap honored - - -async def test_max_concurrent_2_observes_at_most_2_in_flight() -> None: - handler = _SlowHandler(delay=0.02) - client = _client(handler, bulkhead=Bulkhead(max_concurrent=_MAX_CONCURRENT_2, acquire_timeout=None)) - await asyncio.gather( - client.get("https://example.test/a"), - client.get("https://example.test/b"), - client.get("https://example.test/c"), - client.get("https://example.test/d"), - ) - assert handler.calls == 4 # noqa: PLR2004 — four concurrent gets above - assert handler.max_in_flight <= _MAX_CONCURRENT_2 -``` - -Run: `uv run pytest tests/test_bulkhead.py -v` -Expected: all FAIL (`ImportError` for `Bulkhead`). - -- [ ] **Step 2: Implement the `Bulkhead` middleware** - -Replace `src/httpware/middleware/resilience/bulkhead.py` content with: -```python -"""Bulkhead middleware — concurrency limiter via asyncio.Semaphore. - -See planning/specs/2026-06-05-bulkhead-design.md for the contract. - -The middleware owns an asyncio.Semaphore(max_concurrent). On each request, -it acquires a slot (bounded by acquire_timeout via asyncio.timeout) and -releases the slot in a try/finally so success, exceptions, and cancellation -all release deterministically. - -Bulkhead is the sharable unit — pass the same instance to multiple -AsyncClient(middleware=[shared]) calls to enforce a joint cap across clients. -""" - -import asyncio - -import httpx2 - -from httpware.errors import BulkheadFullError -from httpware.middleware import Next - - -_MAX_CONCURRENT_INVALID = "max_concurrent must be >= 1" -_ACQUIRE_TIMEOUT_INVALID = "acquire_timeout must be >= 0" - - -class Bulkhead: - """Concurrency limiter middleware. See module docstring for behavior.""" - - def __init__( - self, - *, - max_concurrent: int, - acquire_timeout: float | None = 1.0, - ) -> None: - if max_concurrent < 1: - raise ValueError(_MAX_CONCURRENT_INVALID) - if acquire_timeout is not None and acquire_timeout < 0: - raise ValueError(_ACQUIRE_TIMEOUT_INVALID) - self._max_concurrent = max_concurrent - self._acquire_timeout = acquire_timeout - self._sem = asyncio.Semaphore(max_concurrent) - - async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 - """Acquire a slot (bounded by acquire_timeout), invoke next, release.""" - try: - if self._acquire_timeout is None: - await self._sem.acquire() - else: - async with asyncio.timeout(self._acquire_timeout): - await self._sem.acquire() - except asyncio.TimeoutError as exc: - raise BulkheadFullError( - max_concurrent=self._max_concurrent, - acquire_timeout=self._acquire_timeout, - ) from exc - - try: - return await next(request) - finally: - self._sem.release() -``` - -- [ ] **Step 3: Wire `Bulkhead` into `resilience/__init__.py`** - -Read `src/httpware/middleware/resilience/__init__.py`. Add `Bulkhead` to the re-exports and `__all__`. The full file becomes: -```python -"""Resilience primitives: Retry middleware and RetryBudget token bucket.""" - -from httpware.middleware.resilience.budget import RetryBudget -from httpware.middleware.resilience.bulkhead import Bulkhead -from httpware.middleware.resilience.retry import Retry - - -__all__ = ["Bulkhead", "Retry", "RetryBudget"] -``` - -- [ ] **Step 4: Run the Task 3 tests** - -Run: `uv run pytest tests/test_bulkhead.py -v` -Expected: all PASS. - -- [ ] **Step 5: Run lint + full suite** - -Run: `just lint && just test` -Expected: clean, 100% coverage. - -- [ ] **Step 6: Stage and commit** - -```bash -git add src/httpware/middleware/resilience/bulkhead.py src/httpware/middleware/resilience/__init__.py tests/test_bulkhead.py -git commit -m "feat(resilience): Bulkhead middleware — happy path + validation + capacity - -Constructor validates max_concurrent >= 1 and acquire_timeout >= 0 -(None and 0 both accepted). asyncio.Semaphore enforces the cap; the -explicit acquire + try/finally around next() guarantees release on -every exit path. Acquire failures map to BulkheadFullError. - -Subsequent tasks cover fail-fast / wait-forever modes, exception + -cancellation release semantics, cross-client sharing, and property tests." -``` - ---- - -## Task 4: `Bulkhead` — `acquire_timeout` behaviors (bounded wait, fail-fast, wait-forever) - -**Files:** -- Modify: `tests/test_bulkhead.py` - -The implementation already supports all three modes. This task adds explicit tests that pin each mode. - -- [ ] **Step 1: Append tests to `tests/test_bulkhead.py`** - -```python -_ACQUIRE_TIMEOUT_SHORT = 0.02 -_ACQUIRE_TIMEOUT_LONG = 0.1 - - -async def test_bounded_wait_raises_bulkhead_full_error() -> None: - """With max_concurrent=1 and acquire_timeout=0.02, the second call raises after ~20ms.""" - handler = _SlowHandler(delay=_ACQUIRE_TIMEOUT_LONG) # holds slot for 100ms - client = _client( - handler, - bulkhead=Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=_ACQUIRE_TIMEOUT_SHORT), - ) - - first = asyncio.create_task(client.get("https://example.test/a")) - await asyncio.sleep(0.005) # let first acquire the slot - with pytest.raises(BulkheadFullError) as info: - await client.get("https://example.test/b") - assert info.value.max_concurrent == _MAX_CONCURRENT_1 - assert info.value.acquire_timeout == _ACQUIRE_TIMEOUT_SHORT - await first # cleanup - - -async def test_acquire_timeout_zero_fails_fast() -> None: - """With acquire_timeout=0, the second call raises immediately without waiting.""" - handler = _SlowHandler(delay=_ACQUIRE_TIMEOUT_LONG) - client = _client( - handler, - bulkhead=Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=0), - ) - - first = asyncio.create_task(client.get("https://example.test/a")) - await asyncio.sleep(0.005) - with pytest.raises(BulkheadFullError) as info: - await client.get("https://example.test/b") - assert info.value.acquire_timeout == 0 - await first - - -async def test_acquire_timeout_none_waits_forever() -> None: - """With acquire_timeout=None, the second call waits until the first releases.""" - handler = _SlowHandler(delay=_ACQUIRE_TIMEOUT_SHORT) - client = _client( - handler, - bulkhead=Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=None), - ) - - first = asyncio.create_task(client.get("https://example.test/a")) - second = asyncio.create_task(client.get("https://example.test/b")) - responses = await asyncio.wait_for(asyncio.gather(first, second), timeout=1.0) - assert all(r.status_code == HTTPStatus.OK for r in responses) - assert handler.calls == 2 # noqa: PLR2004 — both eventually succeeded -``` - -- [ ] **Step 2: Run the tests** - -Run: `uv run pytest tests/test_bulkhead.py -v -k "bounded_wait or fails_fast or waits_forever"` -Expected: all PASS. - -- [ ] **Step 3: Run lint + full suite** - -Run: `just lint && just test` -Expected: clean, 100% coverage. - -- [ ] **Step 4: Stage and commit** - -```bash -git add tests/test_bulkhead.py -git commit -m "test(resilience): Bulkhead acquire_timeout modes — bounded / fail-fast / forever - -Pins the three acquire_timeout modes: bounded wait raises BulkheadFullError -after the configured timeout, =0 fails fast without waiting, =None waits -until a slot frees." -``` - ---- - -## Task 5: `Bulkhead` — release semantics (exception, cancellation) - -**Files:** -- Modify: `tests/test_bulkhead.py` - -The `try/finally` in `__call__` already releases on every exit. This task pins the behavior with explicit tests so a future refactor that drops the `finally` is caught immediately. - -- [ ] **Step 1: Append tests to `tests/test_bulkhead.py`** - -```python -async def test_slot_released_after_exception_in_next() -> None: - """If next() raises, the slot is released — subsequent calls succeed immediately.""" - call_count = {"n": 0} - - def handler(request: httpx2.Request) -> httpx2.Response: - call_count["n"] += 1 - if call_count["n"] == 1: - msg = "boom" - raise RuntimeError(msg) - return httpx2.Response(HTTPStatus.OK, request=request) - - client = _client(handler, bulkhead=Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=0)) - - # First call raises; slot must release. - with pytest.raises(RuntimeError, match="boom"): - await client.get("https://example.test/a") - - # Second call must succeed immediately — fail-fast=0 proves the slot is free. - response = await client.get("https://example.test/b") - assert response.status_code == HTTPStatus.OK - assert call_count["n"] == 2 # noqa: PLR2004 — second call reached handler - - -async def test_slot_released_on_cancellation() -> None: - """If the calling task is cancelled while next() runs, the slot is released.""" - handler = _SlowHandler(delay=0.5) # would block indefinitely - bulkhead = Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=0) - client = _client(handler, bulkhead=bulkhead) - - first = asyncio.create_task(client.get("https://example.test/a")) - await asyncio.sleep(0.01) # let first acquire and start sleeping in handler - first.cancel() - with pytest.raises(asyncio.CancelledError): - await first - - # Slot must now be released — fail-fast=0 next call proves it. - handler.delay = 0.0 # speed up the next request - response = await client.get("https://example.test/b") - assert response.status_code == HTTPStatus.OK - - -async def test_cancellation_before_acquire_does_not_hold_slot() -> None: - """Cancellation while waiting for a slot must not leak the slot to the cancelled task.""" - handler = _SlowHandler(delay=0.05) - bulkhead = Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=None) - client = _client(handler, bulkhead=bulkhead) - - first = asyncio.create_task(client.get("https://example.test/a")) - await asyncio.sleep(0.005) # first acquires - second = asyncio.create_task(client.get("https://example.test/b")) # waits for slot - await asyncio.sleep(0.005) # ensure second is parked on acquire - second.cancel() - with pytest.raises(asyncio.CancelledError): - await second - - # First should still complete normally. - response = await first - assert response.status_code == HTTPStatus.OK - assert handler.calls == 1 # second never reached the handler -``` - -- [ ] **Step 2: Run the tests** - -Run: `uv run pytest tests/test_bulkhead.py -v -k "released or cancellation"` -Expected: all PASS. - -- [ ] **Step 3: Run lint + full suite** - -Run: `just lint && just test` -Expected: clean, 100% coverage. - -- [ ] **Step 4: Stage and commit** - -```bash -git add tests/test_bulkhead.py -git commit -m "test(resilience): Bulkhead release semantics on exception + cancellation - -Pins the try/finally release: exception in next() releases the slot, -cancellation during next() releases the slot, cancellation while -parked on acquire() does not hold a slot." -``` - ---- - -## Task 6: `Bulkhead` — sharing across clients + construct-outside-loop sanity - -**Files:** -- Modify: `tests/test_bulkhead.py` - -- [ ] **Step 1: Append tests to `tests/test_bulkhead.py`** - -```python -# Constructed at module scope on purpose — pins the construct-outside-loop behavior. -_MODULE_SCOPE_BULKHEAD = Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=None) - - -async def test_construct_outside_event_loop_then_use_inside() -> None: - """Bulkhead constructed at module scope must work when used inside an event loop.""" - handler = _SlowHandler(delay=0.0) - client = _client(handler, bulkhead=_MODULE_SCOPE_BULKHEAD) - response = await client.get("https://example.test/x") - assert response.status_code == HTTPStatus.OK - - -async def test_shared_bulkhead_enforces_joint_cap() -> None: - """One Bulkhead shared across two AsyncClients enforces the joint cap.""" - # Both clients use ONE handler that tracks combined in-flight across all calls. - # asyncio is single-threaded so a plain dict counter is safe between awaits. - state = {"in_flight": 0, "max_in_flight": 0} - - async def shared_handler(request: httpx2.Request) -> httpx2.Response: - state["in_flight"] += 1 - state["max_in_flight"] = max(state["max_in_flight"], state["in_flight"]) - try: - await asyncio.sleep(0.02) - return httpx2.Response(HTTPStatus.OK, request=request) - finally: - state["in_flight"] -= 1 - - shared = Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=None) - client_a = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(shared_handler)), - middleware=[shared], - ) - client_b = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(shared_handler)), - middleware=[shared], - ) - - await asyncio.gather( - client_a.get("https://upstream-a.example.test/x"), - client_a.get("https://upstream-a.example.test/y"), - client_b.get("https://upstream-b.example.test/x"), - client_b.get("https://upstream-b.example.test/y"), - ) - - # The shared bulkhead enforces max=1 across BOTH clients combined. - assert state["max_in_flight"] <= _MAX_CONCURRENT_1 -``` - -- [ ] **Step 2: Run the tests** - -Run: `uv run pytest tests/test_bulkhead.py -v -k "construct_outside or shared_bulkhead"` -Expected: PASS. - -- [ ] **Step 3: Run lint + full suite** - -Run: `just lint && just test` -Expected: clean, 100% coverage. - -- [ ] **Step 4: Stage and commit** - -```bash -git add tests/test_bulkhead.py -git commit -m "test(resilience): Bulkhead sharing across clients + construct-outside-loop - -Pins two behaviors: a Bulkhead instantiated at module scope (outside any -event loop) works correctly when used inside one, and a single Bulkhead -instance passed to multiple AsyncClient instances enforces the joint cap -across all of them." -``` - ---- - -## Task 7: Hypothesis property tests for `Bulkhead` - -**Files:** -- Create: `tests/test_bulkhead_props.py` - -- [ ] **Step 1: Create the property-test file** - -```python -"""Hypothesis property tests for Bulkhead. - -Properties verified: -1. Observed in-flight count never exceeds max_concurrent under any interleaving. -2. With acquire_timeout=0 and a full bulkhead, the call raises BulkheadFullError. -3. Successful acquisitions are released — back-to-back calls eventually drain - without leaking slots. -""" - -import asyncio -from http import HTTPStatus - -import httpx2 -import pytest -from hypothesis import given, settings -from hypothesis import strategies as st - -from httpware import AsyncClient -from httpware.errors import BulkheadFullError -from httpware.middleware.resilience.bulkhead import Bulkhead - - -class _InFlightHandler: - """Tracks max simultaneous in-flight count across all calls.""" - - def __init__(self, delay: float) -> None: - self.delay = delay - self.in_flight = 0 - self.max_in_flight = 0 - self.calls = 0 - - async def __call__(self, request: httpx2.Request) -> httpx2.Response: - self.calls += 1 - self.in_flight += 1 - self.max_in_flight = max(self.max_in_flight, self.in_flight) - try: - await asyncio.sleep(self.delay) - return httpx2.Response(HTTPStatus.OK, request=request) - finally: - self.in_flight -= 1 - - -@given( - max_concurrent=st.integers(min_value=1, max_value=8), - n_requests=st.integers(min_value=1, max_value=32), - delay=st.floats(min_value=0.001, max_value=0.005), -) -@settings(max_examples=30, deadline=None) -async def test_in_flight_never_exceeds_max_concurrent( - max_concurrent: int, n_requests: int, delay: float, -) -> None: - handler = _InFlightHandler(delay=delay) - transport = httpx2.MockTransport(handler) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[Bulkhead(max_concurrent=max_concurrent, acquire_timeout=None)], - ) - await asyncio.gather( - *(client.get(f"https://example.test/{i}") for i in range(n_requests)) - ) - assert handler.calls == n_requests - assert handler.max_in_flight <= max_concurrent - - -@given( - max_concurrent=st.integers(min_value=1, max_value=4), - extra_requests=st.integers(min_value=1, max_value=8), -) -@settings(max_examples=20, deadline=None) -async def test_fail_fast_rejects_when_at_capacity( - max_concurrent: int, extra_requests: int, -) -> None: - handler = _InFlightHandler(delay=0.05) # hold slots long enough for fail-fast to fire - transport = httpx2.MockTransport(handler) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[Bulkhead(max_concurrent=max_concurrent, acquire_timeout=0)], - ) - - # Fill the bulkhead with max_concurrent long-running tasks. - holders = [ - asyncio.create_task(client.get(f"https://example.test/hold-{i}")) - for i in range(max_concurrent) - ] - await asyncio.sleep(0.005) # let the holders acquire their slots - - # Any extra requests should fail fast with BulkheadFullError. - for i in range(extra_requests): - with pytest.raises(BulkheadFullError): - await client.get(f"https://example.test/extra-{i}") - - # Cleanup the holders. - await asyncio.gather(*holders) - - -@given( - max_concurrent=st.integers(min_value=1, max_value=4), - n_requests=st.integers(min_value=4, max_value=16), -) -@settings(max_examples=20, deadline=None) -async def test_no_slot_leak_after_drain(max_concurrent: int, n_requests: int) -> None: - """After all calls complete, the bulkhead has its full capacity available.""" - handler = _InFlightHandler(delay=0.001) - bulkhead = Bulkhead(max_concurrent=max_concurrent, acquire_timeout=None) - transport = httpx2.MockTransport(handler) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[bulkhead], - ) - - await asyncio.gather( - *(client.get(f"https://example.test/{i}") for i in range(n_requests)) - ) - - # Bulkhead should be drained — _value equals max_concurrent again. - # asyncio.Semaphore._value is implementation detail but reliable across CPython 3.11+. - assert bulkhead._sem._value == max_concurrent # noqa: SLF001 -``` - -Add `import pytest` at the top alongside the existing imports. - -- [ ] **Step 2: Run the property tests** - -Run: `uv run pytest tests/test_bulkhead_props.py -v` -Expected: all PASS. - -- [ ] **Step 3: Run lint + full suite** - -Run: `just lint && just test` -Expected: clean, 100% coverage. - -- [ ] **Step 4: Stage and commit** - -```bash -git add tests/test_bulkhead_props.py -git commit -m "test(resilience): Hypothesis property tests for Bulkhead - -Three invariants: in-flight never exceeds max_concurrent under any -interleaving; fail-fast (acquire_timeout=0) raises BulkheadFullError -when at capacity; after all calls drain, the bulkhead has full capacity -available again (no slot leak)." -``` - ---- - -## Task 8: Public API exports + final verification - -**Files:** -- Modify: `src/httpware/__init__.py` -- Modify: `tests/test_public_api.py` -- Modify: `planning/engineering.md` - -- [ ] **Step 1: Add `Bulkhead` + `BulkheadFullError` to `src/httpware/__init__.py`** - -Read `src/httpware/__init__.py`. Add `BulkheadFullError` to the `from httpware.errors import (...)` block in alphabetical position (between `BadRequestError` and `ClientError`). - -Update the resilience import line from: -```python -from httpware.middleware.resilience import Retry, RetryBudget -``` -to: -```python -from httpware.middleware.resilience import Bulkhead, Retry, RetryBudget -``` - -Add both new symbols to `__all__` in alphabetical order: -- `"Bulkhead"` (between `"BadRequestError"` and `"ClientError"`) -- `"BulkheadFullError"` (immediately after `"Bulkhead"`) - -- [ ] **Step 2: Update `tests/test_public_api.py`** - -In `test_expected_exports`, add `"Bulkhead"` and `"BulkheadFullError"` to the `expected` set. Insertion point: alphabetical, between `"AsyncClient"` and `"ClientError"` (the file's `expected` is unordered as a set, but insert visually in the same alphabetic neighborhood). - -- [ ] **Step 3: Run the public-API test** - -Run: `uv run pytest tests/test_public_api.py -v` -Expected: PASS. - -- [ ] **Step 4: Update `planning/engineering.md` §8** - -Find §8 "Remaining roadmap" and the Epic 3 subsection. Current text: -``` -- **Epic 3 — Resilience:** - - **Shipped in v0.4 slice 1:** `Retry` middleware + Finagle-style `RetryBudget` token bucket + `attempt_timeout=` parameter (folded-in 3-1). ... - - **Remaining:** `3-5` `Bulkhead`, `3-6` extension-slot docs. -``` - -Update to: -``` -- **Epic 3 — Resilience:** - - **Shipped in v0.4 slice 1:** `Retry` middleware + Finagle-style `RetryBudget` token bucket + `attempt_timeout=` parameter (folded-in 3-1). See [`planning/specs/2026-06-05-retry-and-retry-budget-design.md`](specs/2026-06-05-retry-and-retry-budget-design.md) and [`planning/plans/2026-06-05-retry-and-retry-budget-plan.md`](plans/2026-06-05-retry-and-retry-budget-plan.md). - - **Shipped in v0.4 slice 2:** `Bulkhead` middleware (concurrency limiter via `asyncio.Semaphore` with bounded acquire wait). See [`planning/specs/2026-06-05-bulkhead-design.md`](specs/2026-06-05-bulkhead-design.md) and [`planning/plans/2026-06-05-bulkhead-plan.md`](plans/2026-06-05-bulkhead-plan.md). - - **Remaining:** `3-6` extension-slot docs. -``` - -- [ ] **Step 5: Run the full suite with coverage gate** - -Run: `just test` -Expected: ALL tests PASS, coverage = 100%. - -- [ ] **Step 6: Run the full lint** - -Run: `just lint-ci` -Expected: clean. - -- [ ] **Step 7: Verify the architecture invariants from `CLAUDE.md`** - -Run each in turn: -```bash -grep -rE 'httpx2\._' src/httpware/ || echo "PASS: no httpx2 private API" -grep -rE 'from __future__ import annotations' src/httpware/ || echo "PASS: no __future__ annotations" -grep -rE '\bprint\(' src/httpware/ || echo "PASS: no print()" -grep -rE 'logging\.(basicConfig|getLogger)\(\)' src/httpware/ || echo "PASS: no global logging" -grep -rE '# (type|mypy): ignore' src/httpware/ || echo "PASS: no type/mypy ignore" -``` - -Each should print the `PASS` line (the grep returns no matches). - -- [ ] **Step 8: Verify the optional-extras isolation invariant** - -Bulkhead is pure stdlib; importing httpware should not pull pydantic/msgspec/otel: -```bash -uv run pytest tests/test_optional_extras_isolation.py -v -``` -Expected: PASS. - -- [ ] **Step 9: Stage and commit** - -```bash -git add src/httpware/__init__.py tests/test_public_api.py -git commit -m "feat(api): export Bulkhead and BulkheadFullError - -Completes the v0.4 slice 2 slice: Bulkhead concurrency limiter middleware + its -backpressure exception. Pure-stdlib core, no new optional extra." -``` - -- [ ] **Step 10: Final commit for planning docs** - -```bash -git add planning/engineering.md -git commit -m "docs(planning): mark 3-5 Bulkhead shipped in v0.4 slice 2" -``` - -- [ ] **Step 11: Push the branch and open the PR** - -```bash -git push -u origin feat/v0.4-bulkhead -``` - -Then create a PR per the project's normal cadence (`gh pr create`). The PR body should reference both the spec (`planning/specs/2026-06-05-bulkhead-design.md`) and this plan. The PR includes an amendment to `planning/releases/0.4.0.md` describing Bulkhead alongside Retry (per the decision to ship Bulkhead in 0.4.0). The amendment commit lives at the start of the branch. - ---- - -## Out of scope for this plan (per the spec) - -These items are deliberately deferred. Do NOT implement them as part of this slice; if the implementation pulls toward them, stop and surface to the user instead. - -- Per-host / per-route partitioning of the cap. -- Separate `BulkheadLimit` public type. -- Queue-depth / in-flight metrics on the `Bulkhead` object. -- Fallback / shed-load callbacks. -- Connection-pool integration with `httpx2.Limits`. -- Version bump for 0.4.0 — happens in a separate release-prep PR. Release notes for 0.4.0 are amended in this branch to describe Bulkhead alongside Retry. diff --git a/planning/changes/2026-06-05.03-docs-sync-0.4/design.md b/planning/changes/2026-06-05.03-docs-sync-0.4.md similarity index 100% rename from planning/changes/2026-06-05.03-docs-sync-0.4/design.md rename to planning/changes/2026-06-05.03-docs-sync-0.4.md diff --git a/planning/changes/2026-06-05.03-docs-sync-0.4/plan.md b/planning/changes/2026-06-05.03-docs-sync-0.4/plan.md deleted file mode 100644 index 1a32c96..0000000 --- a/planning/changes/2026-06-05.03-docs-sync-0.4/plan.md +++ /dev/null @@ -1,637 +0,0 @@ -# Docs-sync 0.4 (Epic 3 story 3-6) 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:** Edit five files to bring the live user-facing docs in sync with what's actually shipped on `main` (Retry, RetryBudget, Bulkhead, NetworkError, BulkheadFullError, RetryBudgetExhaustedError). README + `docs/index.md` + `docs/dev/contributing.md` + `mkdocs.yml` + `planning/engineering.md`. - -**Architecture:** Docs-only PR. No source/test changes. Each task is one file, one commit, with concrete before/after blocks the implementer can apply mechanically. Verification: `just lint` (eof-fixer might touch markdown), `just test` (sanity that nothing broke), and `uv run --with mkdocs --with mkdocs-material mkdocs build --strict` (currently emits 2 nav warnings; must drop to 0 after this PR). - -**Tech Stack:** Markdown + YAML. No Python changes. `mkdocs` + `mkdocs-material` invoked via uv `--with` (they're in `docs/requirements.txt` but not the default dev group). - -**Target branch:** `docs/sync-0.4`. - -**Source spec:** [`planning/specs/2026-06-05-docs-sync-0.4-design.md`](../specs/2026-06-05-docs-sync-0.4-design.md). Read the spec for the "why" behind each change. - ---- - -## File structure - -**Modified files (5):** -- `README.md` -- `docs/index.md` -- `docs/dev/contributing.md` -- `mkdocs.yml` -- `planning/engineering.md` - -No new files. No deletions. - -**Commit cadence:** one commit per file (six commits total including the branch-setup verify). Per-task commits keep diff review per-file. - ---- - -## Task 1: Branch setup + README.md - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Ensure local `main` is fully pushed, then branch off** - -The spec commit was made directly on `main` earlier. Verify it's pushed before branching so the new PR diff only contains docs changes: - -```bash -git checkout main && git status -git push origin main 2>&1 | tail -3 -git checkout -b docs/sync-0.4 -``` - -If `git push origin main` reports "Everything up-to-date", continue. If it pushes one commit (the spec), that's expected. - -- [ ] **Step 2: Read the current README to confirm line targets** - -```bash -cat README.md -``` - -Confirm the file matches what the spec expected (status line at L12, dead link at L45, etc.). If line numbers have drifted, locate the target text instead. - -- [ ] **Step 3: Apply Status-line edit** - -Replace: -```markdown -> **Status:** Pre-1.0 (0.3.0). Public API is subject to change between minor releases until v1.0. Resilience middleware (retry / timeout / bulkhead), streaming, and observability are not yet shipped. -``` -With: -```markdown -> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. Streaming and observability are not yet shipped. -``` - -- [ ] **Step 4: Apply Project-description edit** - -Find this paragraph (currently around L9): -```markdown -`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. -``` - -Append (do not replace) one sentence so the paragraph becomes: -```markdown -`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — under `httpware.middleware.resilience`. -``` - -- [ ] **Step 5: Add Resilience snippet + Errors section to Quickstart** - -Find the existing Quickstart code block (ends with `print(user.name)`). Immediately after the closing triple-backticks of that block, insert this new content (preserving a blank line between the closing fence and the new H3): - -```markdown - -### With resilience middleware - -Compose resilience middleware at construction; `Bulkhead` goes outside `Retry` so one slot covers all retry attempts. - -```python -from httpware import AsyncClient, Bulkhead, Retry - - -async def main() -> None: - async with AsyncClient( - base_url="https://api.example.com", - middleware=[ - Bulkhead(max_concurrent=10), # cap total in-flight - Retry(), # default: 3 attempts, full-jitter backoff - ], - ) as client: - user = await client.get("/users/1", response_model=User) -``` - -## Errors - -All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `ServiceUnavailableError`, `RateLimitedError`, etc. — all subclasses of `httpware.StatusError`. Transport-layer transient failures raise `NetworkError`; the resilience middleware raise `RetryBudgetExhaustedError` and `BulkheadFullError`. Everything inherits `httpware.ClientError`. -``` - -- [ ] **Step 6: Replace the dead Documentation link** - -Find: -```markdown -## 📚 [Documentation](https://httpware.readthedocs.io) -``` - -Replace with: -```markdown -## 📝 [Release notes](https://github.com/modern-python/httpware/releases) -``` - -- [ ] **Step 7: Sanity-check the file** - -```bash -cat README.md -``` - -Read through end-to-end. Make sure: -- Status line is on a single line, no version number -- Project description ends with the new "It also ships..." sentence -- "Quickstart" section now has TWO code blocks (the original + the new "With resilience middleware" subsection) -- "Errors" section appears between the resilience snippet and the link section -- The Documentation link is gone; Release notes link replaces it - -- [ ] **Step 8: Lint** - -```bash -just lint -``` - -Expected: clean. (eof-fixer + ruff format may normalize trailing whitespace in the markdown; that's fine.) - -- [ ] **Step 9: Commit** - -```bash -git add README.md -git commit -m "docs(readme): sync with shipped 0.4 features - -- Drop the '0.3.0' version from the status line (pyproject is source of truth) -- Drop the now-false 'retry / timeout / bulkhead not yet shipped' claim -- Mention the resilience suite (Retry + RetryBudget, Bulkhead) in the - project description -- Add a 'With resilience middleware' Quickstart subsection showing the - recommended [Bulkhead, Retry] ordering -- Add an 'Errors' section so users know StatusError / NetworkError / - RetryBudgetExhaustedError / BulkheadFullError exist -- Replace the dead readthedocs.io link with the GH Releases page" -``` - ---- - -## Task 2: docs/index.md - -**Files:** -- Modify: `docs/index.md` - -- [ ] **Step 1: Read current state** - -```bash -cat docs/index.md -``` - -- [ ] **Step 2: Replace the Status line** - -Replace: -```markdown -> **Status:** Pre-1.0 (0.1.0 alpha). Public API is subject to change between minor releases until v1.0. -``` -With: -```markdown -> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. Streaming and observability are not yet shipped. -``` - -- [ ] **Step 3: Rewrite the project description paragraph** - -Replace this paragraph (currently L3): -```markdown -A Python async HTTP client framework for building resilient service clients. `httpware` owns the abstraction layer above the underlying HTTP client (`httpx2` by default); consumers never import the transport directly. -``` -With: -```markdown -A Python async HTTP client framework for building resilient service clients. `httpware` is a thin opinionated wrapper around `httpx2` — it re-exports `httpx2.Request`/`httpx2.Response` as the public request/response surface, adds a middleware chain (with a built-in resilience suite: `Retry` + `RetryBudget`, `Bulkhead`), opt-in typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx. -``` - -Rationale (for the implementer): the original wording dates from before the v0.2 thin-wrapper pivot. v0.2 explicitly walked back the "owns the abstraction layer" framing and made `httpx2.Request`/`httpx2.Response` part of the public surface. Continuing to claim "consumers never import the transport directly" is no longer true. - -- [ ] **Step 4: Expand the Install section's optional extras** - -Find: -```markdown -Optional extras: - -```bash -pip install httpware[msgspec] # MsgspecDecoder -``` -``` - -Replace with: -```markdown -Optional extras: - -```bash -pip install httpware[pydantic] # PydanticDecoder (the default decoder path) -pip install httpware[msgspec] # MsgspecDecoder -``` -``` - -(The `pydantic` extra was added in 0.3.0; the docs/index.md only mentioned `msgspec`.) - -- [ ] **Step 5: Add a "With resilience middleware" subsection + Errors section** - -Find the existing First-request code block (ends with `asyncio.run(main())`). Immediately after the closing triple-backticks, insert this new content (preserving a blank line between the closing fence and the new H3): - -```markdown - -### With resilience middleware - -Compose resilience middleware at construction; `Bulkhead` goes outside `Retry` so one slot covers all retry attempts. - -```python -from httpware import AsyncClient, Bulkhead, Retry - - -async def main() -> None: - async with AsyncClient( - base_url="https://api.example.com", - middleware=[ - Bulkhead(max_concurrent=10), # cap total in-flight - Retry(), # default: 3 attempts, full-jitter backoff - ], - ) as client: - user = await client.get("/users/1", response_model=User) -``` - -## Errors - -All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `ServiceUnavailableError`, `RateLimitedError`, etc. — all subclasses of `httpware.StatusError`. Transport-layer transient failures raise `NetworkError`; the resilience middleware raise `RetryBudgetExhaustedError` and `BulkheadFullError`. Everything inherits `httpware.ClientError`. -``` - -- [ ] **Step 6: Fix the "Where to go next" links** - -Replace the existing bulleted list: -```markdown -- **[Engineering Notes](dev/engineering.md)** — design invariants, the five protocol seams, exception contract, module layout, testing patterns, optional-extras pattern. -- **[Contributing](dev/contributing.md)** — setup, conventions, workflow. -``` -With: -```markdown -- **[Engineering Notes](https://github.com/modern-python/httpware/blob/main/planning/engineering.md)** — design invariants, the three protocol seams, exception contract, module layout, testing patterns, optional-extras pattern. Lives in the repo at `planning/engineering.md`. -- **[Contributing](dev/contributing.md)** — setup, conventions, workflow. -- **[Release notes](https://github.com/modern-python/httpware/releases)** — per-version changelogs. -``` - -Notes for the implementer: -- "five protocol seams" → "three protocol seams" — the engineering doc was updated in the v0.2 pivot (5 seams collapsed to 3). The index.md text predates that update. -- The link uses an absolute GH URL (not a relative `../planning/engineering.md`) because mkdocs only indexes files under `docs/`; a relative link out of `docs/` would 404 in the rendered site. - -- [ ] **Step 7: Sanity-check the file** - -```bash -cat docs/index.md -``` - -Read end-to-end. Make sure: -- Status line is single, version-number-free -- Project description reads as the new v0.2+ wording -- Install block shows both `[pydantic]` and `[msgspec]` -- "First request" code block is followed by the "With resilience middleware" subsection -- "Errors" section appears between the resilience snippet and "Where to go next" -- "Where to go next" has three items: Engineering Notes (GH URL), Contributing (relative), Release notes (GH URL) - -- [ ] **Step 8: Lint** - -```bash -just lint -``` - -Expected: clean. - -- [ ] **Step 9: Commit** - -```bash -git add docs/index.md -git commit -m "docs(index): sync with shipped 0.4 features - -- Drop '0.1.0 alpha' from the status line -- Rewrite the project description (pre-v0.2 wording claimed 'owns the - abstraction layer / consumers never import the transport' — both - walked back in v0.2) -- Add the pydantic extra to the install block (was added in 0.3.0) -- Add a 'With resilience middleware' subsection mirroring the README -- Add an 'Errors' section -- Fix the dead 'Engineering Notes' link (target file doesn't exist in - docs/ tree; point to GH URL of planning/engineering.md instead) -- Fix the now-incorrect 'five protocol seams' claim (v0.2 collapsed to three) -- Add a Release notes link" -``` - ---- - -## Task 3: docs/dev/contributing.md - -**Files:** -- Modify: `docs/dev/contributing.md` - -- [ ] **Step 1: Read current state** - -```bash -cat docs/dev/contributing.md -``` - -- [ ] **Step 2: Update the Architecture invariants list** - -Find the bulleted list under `## Architecture invariants` (currently lines 32-38): -```markdown -These are enforced by CI grep gates. Do not break them in pull requests: - -- No `import httpx2` outside `src/httpware/transports/httpx2.py`. -- No `httpx2._*` (private API) usage anywhere in the library. -- No `from __future__ import annotations`. -- No `print()` calls. -- No `logging.basicConfig()` or bare `logging.getLogger()`. -``` - -Replace with: -```markdown -These are enforced by CI grep gates. Do not break them in pull requests: - -- No `httpx2._*` (private API) usage anywhere in the library. -- No `from __future__ import annotations`. -- No `print()` calls. -- No `logging.basicConfig()` or bare `logging.getLogger()`. -- Type suppressions use `# ty: ignore[]`, never `# type: ignore` or `# mypy: ignore`. -``` - -Two changes: -- **Removed**: the first bullet (`No import httpx2 outside transports/httpx2.py`) — retired in v0.2 (per engineering.md §2: *"The 0.1.0 'no httpx2 leakage outside transports/httpx2.py' invariant is retired in v0.2."*). The `transports/` directory doesn't exist anymore. -- **Added** (last bullet): the `# ty: ignore[]` rule. Already in CLAUDE.md and engineering.md §2 as a current CI-enforced invariant; was missing from this contributor-facing list. - -- [ ] **Step 3: Sanity-check** - -```bash -cat docs/dev/contributing.md -``` - -Read the Architecture invariants list. Make sure: -- Bullet about `transports/httpx2.py` is gone -- New bullet about `# ty: ignore[]` is present -- Other bullets unchanged - -- [ ] **Step 4: Lint** - -```bash -just lint -``` - -- [ ] **Step 5: Commit** - -```bash -git add docs/dev/contributing.md -git commit -m "docs(contributing): drop retired invariant + add ty-suppression rule - -- Remove 'No import httpx2 outside transports/httpx2.py' — retired in - the v0.2 thin-wrapper pivot; transports/ directory no longer exists. - Continuing to list this rule misleads contributors. -- Add 'Type suppressions use # ty: ignore[]' — current - CI-enforced invariant that was missing from this list." -``` - ---- - -## Task 4: mkdocs.yml - -**Files:** -- Modify: `mkdocs.yml` - -- [ ] **Step 1: Read current state** - -```bash -cat mkdocs.yml -``` - -- [ ] **Step 2: Drop the dead nav entry** - -Find the `nav:` block: -```yaml -nav: - - Quick-Start: index.md - - Development: - - Engineering Notes: dev/engineering.md - - Contributing: dev/contributing.md -``` - -Replace with: -```yaml -nav: - - Quick-Start: index.md - - Development: - - Contributing: dev/contributing.md -``` - -Rationale: `dev/engineering.md` doesn't exist (the live file is `planning/engineering.md`). `mkdocs build --strict` reports "A reference to 'dev/engineering.md' is included in the 'nav' configuration, which is not found in the documentation files." This drops the broken entry. The Engineering Notes are reachable via the GH URL added in `docs/index.md` (Task 2). - -`site_url: https://httpware.readthedocs.io/` is **intentionally left untouched** — fixing it is project-tree-hygiene unrelated to this sync pass. - -- [ ] **Step 3: Verify mkdocs build is now clean** - -```bash -uv run --with mkdocs --with mkdocs-material mkdocs build --strict 2>&1 | tail -10 -``` - -Expected: build completes without warnings. (Before this task: 2 warnings about `dev/engineering.md`. After Tasks 2 + 4: 0 warnings. Task 2 fixed the inline link; Task 4 fixed the nav reference.) - -If the output still shows warnings, the most likely cause is Task 2 left an inline link to `dev/engineering.md` somewhere. Re-check `docs/index.md`. - -- [ ] **Step 4: Cleanup the generated site/ directory** - -`mkdocs build` creates a `site/` directory. It's gitignored, but remove it to avoid clutter: - -```bash -rm -rf site/ -``` - -- [ ] **Step 5: Lint** - -```bash -just lint -``` - -- [ ] **Step 6: Commit** - -```bash -git add mkdocs.yml -git commit -m "docs(mkdocs): drop dead nav entry for dev/engineering.md - -The file doesn't exist (the live engineering reference is -planning/engineering.md). mkdocs build --strict was emitting a -'file referenced in nav not found' warning. Drop the broken entry; -docs/index.md now links to the GH URL of planning/engineering.md. - -site_url left as-is (fictional RTD URL is unrelated project hygiene -to be addressed separately)." -``` - ---- - -## Task 5: planning/engineering.md - -**Files:** -- Modify: `planning/engineering.md` - -- [ ] **Step 1: Read current state** - -```bash -cat planning/engineering.md -``` - -- [ ] **Step 2: Append a resilience-suite sentence to §1 Project intent** - -Find the §1 first paragraph (currently around L7). Append one sentence so the paragraph ends with the new content: - -Current ending: `... callers can supply an explicit \`decoder=\` argument to escape the default.` - -New ending: `... callers can supply an explicit \`decoder=\` argument to escape the default. As of 0.4.0, the package ships a small resilience suite under \`httpware.middleware.resilience\` — a \`Retry\` middleware with a Finagle-style \`RetryBudget\`, plus a \`Bulkhead\` concurrency limiter — composed via the standard middleware chain.` - -- [ ] **Step 3: Refresh §5 Module layout** - -Find the existing tree block: -````markdown -```text -src/httpware/ -├── __init__.py # public exports -├── py.typed -├── client.py # AsyncClient -├── errors.py # status-keyed exception tree (response: httpx2.Response) -├── middleware/ -│ ├── __init__.py # Middleware protocol, Next type, @before_request/@after_response/@on_error -│ └── chain.py # compose(middleware, terminal) -> Next -├── decoders/ -│ ├── __init__.py # ResponseDecoder protocol -│ ├── pydantic.py # PydanticDecoder (extra: pydantic) -│ └── msgspec.py # MsgspecDecoder (extra: msgspec) -└── _internal/ - └── import_checker.py # is_msgspec_installed, is_pydantic_installed -``` -```` - -Replace with: -````markdown -```text -src/httpware/ -├── __init__.py # public exports -├── py.typed -├── client.py # AsyncClient -├── errors.py # status-keyed exception tree + NetworkError + RetryBudgetExhaustedError + BulkheadFullError -├── middleware/ -│ ├── __init__.py # Middleware protocol, Next type, @before_request/@after_response/@on_error -│ ├── chain.py # compose(middleware, terminal) -> Next -│ └── resilience/ -│ ├── __init__.py # re-exports Bulkhead, Retry, RetryBudget -│ ├── bulkhead.py # Bulkhead middleware (concurrency limiter) -│ ├── budget.py # RetryBudget (Finagle-style token bucket) -│ ├── retry.py # Retry middleware -│ └── _backoff.py # full-jitter exponential backoff helper (private) -├── decoders/ -│ ├── __init__.py # ResponseDecoder protocol -│ ├── pydantic.py # PydanticDecoder (extra: pydantic) -│ └── msgspec.py # MsgspecDecoder (extra: msgspec) -└── _internal/ - └── import_checker.py # is_msgspec_installed, is_pydantic_installed -``` -```` - -Two changes: -- `errors.py` comment extended to mention the new exception subclasses -- New `resilience/` block under `middleware/` showing all four modules - -- [ ] **Step 4: Sanity-check** - -```bash -cat planning/engineering.md -``` - -Read §1 and §5. Make sure: -- §1 paragraph ends with the new "As of 0.4.0..." sentence -- §5 tree includes the resilience/ block with bulkhead.py, budget.py, retry.py, _backoff.py -- §5 errors.py comment mentions NetworkError + RetryBudgetExhaustedError + BulkheadFullError - -- [ ] **Step 5: Lint** - -```bash -just lint -``` - -- [ ] **Step 6: Commit** - -```bash -git add planning/engineering.md -git commit -m "docs(engineering): §1 resilience suite + §5 module-layout refresh - -- §1: append a sentence noting the resilience suite ships in 0.4 -- §5: add middleware/resilience/ subpackage (bulkhead, budget, retry, - _backoff) to the module tree; extend errors.py comment to mention - NetworkError + RetryBudgetExhaustedError + BulkheadFullError" -``` - ---- - -## Task 6: Final verification + push - -**Files:** none modified; verification only. - -- [ ] **Step 1: Full lint** - -```bash -just lint-ci -``` - -Expected: clean. (`lint-ci` is the non-fixing variant; if it complains, run `just lint` once locally and re-stage any fixes.) - -- [ ] **Step 2: Full test suite** - -```bash -just test -``` - -Expected: 209 tests pass, 100% coverage. No source changed so nothing should regress. - -- [ ] **Step 3: mkdocs strict build** - -```bash -uv run --with mkdocs --with mkdocs-material mkdocs build --strict 2>&1 | tail -10 -``` - -Expected: build completes with **0 warnings**. Before this PR there were 2 warnings about `dev/engineering.md`; Tasks 2 + 4 should have resolved both. - -If warnings remain, investigate before pushing. - -- [ ] **Step 4: Cleanup** - -```bash -rm -rf site/ -git status -``` - -`site/` should be gone; `git status` should show "clean — nothing to commit". - -- [ ] **Step 5: Review the branch diff one last time** - -```bash -git log --oneline main..HEAD -git diff main..HEAD --stat -``` - -Expected: 5 commits, 5 files changed, all under the limits below: - -``` -README.md | ~25 insertions -docs/index.md | ~25 insertions, ~5 deletions -docs/dev/contributing.md | ~2 insertions, ~1 deletion -mkdocs.yml | ~1 deletion -planning/engineering.md | ~10 insertions, ~1 deletion -``` - -(Numbers are approximate — eof-fixer / ruff format may add/remove trailing whitespace.) - -- [ ] **Step 6: Push the branch** - -```bash -git push -u origin docs/sync-0.4 -``` - -DO NOT open the PR yet — leave that to the `finishing-a-development-branch` skill. - ---- - -## Out of scope for this plan (per the spec) - -These items are deliberately deferred. Do NOT do them in this PR: - -- **No tutorial / extension-slot walkthrough.** The original 3-6 framing was re-scoped during brainstorming. -- **No moving `planning/engineering.md` into `docs/dev/`.** Dead link is fixed by GH URL. -- **No `pyproject.toml` version bump.** Lives in the next PR (release prep). -- **No `mkdocs.yml` `site_url` fix.** Project-tree hygiene unrelated to this sync. -- **No CLAUDE.md changes.** Contributor-facing. -- **No module-docstring rewrites.** Already current. -- **No new `docs/` files.** Just edits to existing ones. diff --git a/planning/changes/2026-06-05.04-streaming/design.md b/planning/changes/2026-06-05.04-streaming.md similarity index 100% rename from planning/changes/2026-06-05.04-streaming/design.md rename to planning/changes/2026-06-05.04-streaming.md diff --git a/planning/changes/2026-06-05.04-streaming/plan.md b/planning/changes/2026-06-05.04-streaming/plan.md deleted file mode 100644 index eb055b6..0000000 --- a/planning/changes/2026-06-05.04-streaming/plan.md +++ /dev/null @@ -1,1089 +0,0 @@ -# AsyncClient.stream + Retry-refuses-streamed-body (0.5.0, Epic 4 story 4-3) 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 `AsyncClient.stream()` (async context-manager yielding `httpx2.Response`, with 4xx/5xx auto-raise + body pre-read, exception mapping via a new shared helper). Close two deferred-work items by also adding `Retry`'s refusal of streamed-body requests (detected via a `request.extensions["httpware.streaming_body"]` marker set in `_request_with_body` when `content`/`data`/`files` is async-iterable). - -**Architecture:** Two coordinated changes to `client.py` (helper extraction + `stream()` method + streaming-body marker) and one to `retry.py` (refusal). The `_httpx2_exception_mapper` helper and a `_raise_on_status_error` helper are extracted up front so `_terminal` and `stream()` share dispatch logic — no duplication. `stream()` bypasses the middleware chain (v1 decision); auto-raise on 4xx/5xx with body pre-read keeps consistency with `client.get()`/etc. - -**Tech Stack:** Python 3.11+ (`contextlib.asynccontextmanager`, `asyncio`), `httpx2`, `pytest` / `pytest-asyncio` (auto mode), `uv`, `just`, `ruff`, `ty`. - -**Target branch:** `feat/v0.5-streaming`. Create from `main` before Task 1: `git checkout main && git pull && git checkout -b feat/v0.5-streaming`. - -**Source spec:** [`planning/specs/2026-06-05-streaming-design.md`](../specs/2026-06-05-streaming-design.md). Read it before starting — the *why* for each decision lives there. - ---- - -## File structure - -**Modified files:** -- `src/httpware/client.py` — extract `_httpx2_exception_mapper` + `_raise_on_status_error` helpers; refactor `_terminal` to use both; add `_is_streaming_body` helper; add streaming-body marker step in `_request_with_body`; add `stream()` method -- `src/httpware/middleware/resilience/retry.py` — refuse retry when `request.extensions["httpware.streaming_body"]` is True -- `tests/test_retry.py` — add streaming-body refusal tests -- `tests/test_error_mapping_terminal.py` — no test changes; existing tests must still pass after the `_terminal` refactor -- `README.md` — add a streaming snippet to Quickstart -- `docs/index.md` — mirror the README addition -- `planning/engineering.md` — §1 append streaming sentence; §8 mark `4-3` shipped + close Epic 4 -- `planning/deferred-work.md` — close the two now-resolved items - -**New files:** -- `tests/test_client_stream.py` — unit tests for `AsyncClient.stream()` -- `planning/releases/0.5.0.md` — release notes - -**Commit cadence:** one commit per task. Per-task commits keep history reviewable. - ---- - -## Task 1: Branch + extract `_httpx2_exception_mapper` and `_raise_on_status_error` helpers - -**Files:** -- Modify: `src/httpware/client.py` - -This is a pure refactor of `_terminal`. The dispatch behavior is byte-for-byte identical to today; the existing terminal tests (`tests/test_error_mapping_terminal.py`) cover it and must keep passing. Extracting both helpers up front means `stream()` (Task 4) can use them directly without duplication. - -- [ ] **Step 1: Create the branch** - -Run: -```bash -git checkout main && git pull && git checkout -b feat/v0.5-streaming -``` -Expected: switched to a new branch. - -- [ ] **Step 2: Read the current `_terminal` body** - -```bash -sed -n '107,130p' src/httpware/client.py -``` -Confirm `_terminal` has the structure: -- try/except chain mapping `httpx2.TimeoutException` → `TimeoutError`, `(httpx2.InvalidURL, httpx2.CookieConflict)` → `TransportError`, `httpx2.NetworkError` → `NetworkError`, `httpx2.HTTPError` → `TransportError`, plus a `RuntimeError "closed"` check -- status-code block raising `STATUS_TO_EXCEPTION.get(status, ClientStatusError if status < 500 else ServerStatusError)(response)` for 4xx/5xx - -- [ ] **Step 3: Add `contextlib` import** - -Add to the existing import block (around line 3, alongside `typing`): -```python -import contextlib -``` - -Also add `AsyncIterator` to the imports from `collections.abc`. If `collections.abc` is not yet imported (current code uses `Sequence`), make sure both are present: - -```python -from collections.abc import AsyncIterator, Sequence -``` - -- [ ] **Step 4: Add the `_httpx2_exception_mapper` helper at module level** - -Insert this `@asynccontextmanager` function immediately before `class AsyncClient:` (i.e., between `_default_pydantic_decoder` and the class definition): - -```python -@contextlib.asynccontextmanager -async def _httpx2_exception_mapper() -> AsyncIterator[None]: - """Map httpx2 exceptions to httpware exceptions. Shared by AsyncClient._terminal and stream().""" - try: - yield - except httpx2.TimeoutException as exc: - raise TimeoutError(str(exc)) from exc - except (httpx2.InvalidURL, httpx2.CookieConflict) as exc: - raise TransportError(str(exc)) from exc - except httpx2.NetworkError as exc: - raise NetworkError(str(exc)) from exc - except httpx2.HTTPError as exc: - raise TransportError(str(exc)) from exc -``` - -Clause ordering MUST match the current `_terminal` exactly: TimeoutException → InvalidURL/CookieConflict → NetworkError → HTTPError. `RuntimeError "closed"` is NOT included here — it's not an httpx2 error and stays inline in `_terminal`. - -- [ ] **Step 5: Add the `_raise_on_status_error` helper at module level** - -Insert this function immediately after `_httpx2_exception_mapper`: - -```python -def _raise_on_status_error(response: httpx2.Response) -> None: - """Raise the appropriate StatusError subclass for a 4xx/5xx response. No-op for 2xx/3xx.""" - status = response.status_code - if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx - exc_class = STATUS_TO_EXCEPTION.get( - status, - ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError, - ) - raise exc_class(response) -``` - -- [ ] **Step 6: Refactor `_terminal` to use both helpers** - -Replace the current `_terminal` body (which is the `try`/`except` chain + the status-code block, ~24 lines) with: - -```python -async def _terminal(self, request: httpx2.Request) -> httpx2.Response: - try: - async with _httpx2_exception_mapper(): - response = await self._httpx2_client.send(request) - except RuntimeError as exc: - if "closed" in str(exc): - raise TransportError(str(exc)) from exc - raise - _raise_on_status_error(response) - return response -``` - -The `RuntimeError` check stays in `_terminal` (the mapper doesn't cover it — it's a non-httpx2 error specific to the closed-client edge case). - -- [ ] **Step 7: Run the existing terminal tests** - -```bash -uv run pytest tests/test_error_mapping_terminal.py -v -``` -Expected: all PASS. The refactor must not change observable behavior. - -- [ ] **Step 8: Run lint + full suite** - -```bash -just lint && just test -``` -Expected: clean, 100% coverage maintained (was 209 tests; still 209). - -- [ ] **Step 9: Stage and commit** - -```bash -git add src/httpware/client.py -git commit -m "refactor(client): extract _httpx2_exception_mapper + _raise_on_status_error - -Pure refactor of _terminal. Two module-level helpers (one @asynccontextmanager -for the httpx2 exception dispatch, one function for the 4xx/5xx StatusError -raise). _terminal now reads as: enter the mapper, send, raise on status. - -Sets up Task 4: AsyncClient.stream() will reuse both helpers verbatim -instead of duplicating the dispatch logic. Behavior is byte-for-byte -identical to today; the existing terminal tests cover it." -``` - ---- - -## Task 2: Add `_is_streaming_body` helper + `_request_with_body` streaming-body marker - -**Files:** -- Modify: `src/httpware/client.py` -- Modify: `tests/test_retry.py` (or `tests/test_client_construction.py` — pick whichever currently holds AsyncClient/marker-related tests; the spec puts them in test_retry.py since they're part of the Retry-refusal story) - -The marker is the detection mechanism Retry will use in Task 3. - -- [ ] **Step 1: Write failing tests in `tests/test_retry.py`** - -Append to `tests/test_retry.py`: - -```python -async def test_client_post_with_async_iterable_content_marks_extensions() -> None: - """Posting with an async-iterable body sets the httpware.streaming_body marker on request.extensions.""" - seen_extensions: list[dict[str, object]] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - seen_extensions.append(dict(request.extensions)) - return httpx2.Response(HTTPStatus.OK, request=request) - - async def streamed_body() -> typing.AsyncIterator[bytes]: - yield b"chunk1" - yield b"chunk2" - - transport = httpx2.MockTransport(handler) - client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - await client.post("https://example.test/upload", content=streamed_body()) - - assert len(seen_extensions) == 1 - assert seen_extensions[0].get("httpware.streaming_body") is True - - -async def test_client_post_with_bytes_content_does_not_mark_extensions() -> None: - seen_extensions: list[dict[str, object]] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - seen_extensions.append(dict(request.extensions)) - return httpx2.Response(HTTPStatus.OK, request=request) - - transport = httpx2.MockTransport(handler) - client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - await client.post("https://example.test/upload", content=b"hi") - - assert len(seen_extensions) == 1 - assert "httpware.streaming_body" not in seen_extensions[0] - - -async def test_client_post_with_dict_data_does_not_mark_extensions() -> None: - seen_extensions: list[dict[str, object]] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - seen_extensions.append(dict(request.extensions)) - return httpx2.Response(HTTPStatus.OK, request=request) - - transport = httpx2.MockTransport(handler) - client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - await client.post("https://example.test/upload", data={"k": "v"}) - - assert len(seen_extensions) == 1 - assert "httpware.streaming_body" not in seen_extensions[0] - - -async def test_client_post_with_async_iterable_data_marks_extensions() -> None: - seen_extensions: list[dict[str, object]] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - seen_extensions.append(dict(request.extensions)) - return httpx2.Response(HTTPStatus.OK, request=request) - - async def streamed_data() -> typing.AsyncIterator[bytes]: - yield b"x" - - transport = httpx2.MockTransport(handler) - client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - await client.post("https://example.test/upload", data=streamed_data()) - - assert len(seen_extensions) == 1 - assert seen_extensions[0].get("httpware.streaming_body") is True - - -async def test_client_post_with_async_iterable_files_marks_extensions() -> None: - seen_extensions: list[dict[str, object]] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - seen_extensions.append(dict(request.extensions)) - return httpx2.Response(HTTPStatus.OK, request=request) - - async def streamed_files() -> typing.AsyncIterator[bytes]: - yield b"x" - - transport = httpx2.MockTransport(handler) - client = AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - await client.post("https://example.test/upload", files=streamed_files()) - - assert len(seen_extensions) == 1 - assert seen_extensions[0].get("httpware.streaming_body") is True -``` - -`typing` is already imported at the top of `tests/test_retry.py`; if not, add `import typing` to the top. - -Run: `uv run pytest tests/test_retry.py -v -k "marks_extensions or does_not_mark_extensions"` -Expected: all 5 FAIL — the marker isn't set yet. - -- [ ] **Step 2: Add `_is_streaming_body` helper at module level** - -In `src/httpware/client.py`, immediately after `_raise_on_status_error` (added in Task 1), insert: - -```python -def _is_streaming_body(value: typing.Any) -> bool: - """True if value is an async-iterable that cannot be safely replayed for retry.""" - if value is None: - return False - if isinstance(value, (bytes, bytearray, memoryview, str, dict)): - return False - return hasattr(value, "__aiter__") -``` - -- [ ] **Step 3: Set the streaming-body marker in `_request_with_body`** - -Locate `_request_with_body` (around `client.py:153`). Find the line: -```python -request = self._httpx2_client.build_request(method, url, **kwargs) -return await self.send(request, response_model=response_model) -``` - -Replace with: -```python -request = self._httpx2_client.build_request(method, url, **kwargs) -if _is_streaming_body(content) or _is_streaming_body(data) or _is_streaming_body(files): - request.extensions["httpware.streaming_body"] = True -return await self.send(request, response_model=response_model) -``` - -NOTE: `httpx2.Request.extensions` is a `dict[str, Any]` per httpx convention. If during implementation you find it can be `None` on a freshly-built request, swap to: -```python -extensions_dict = dict(request.extensions or {}) -extensions_dict["httpware.streaming_body"] = True -request.extensions = extensions_dict -``` - -- [ ] **Step 4: Run the new tests** - -```bash -uv run pytest tests/test_retry.py -v -k "marks_extensions or does_not_mark_extensions" -``` -Expected: all 5 PASS. - -- [ ] **Step 5: Lint + full suite** - -```bash -just lint && just test -``` -Expected: clean, 100% coverage. - -- [ ] **Step 6: Stage and commit** - -```bash -git add src/httpware/client.py tests/test_retry.py -git commit -m "feat(client): mark requests with async-iterable bodies via extensions - -Adds a _is_streaming_body helper and a marker step in _request_with_body: -when content / data / files is an async-iterable, set -request.extensions['httpware.streaming_body'] = True before sending. - -Sets up Task 3: Retry will read the marker and refuse to retry streamed-body -requests (they can't replay across attempts). Today the marker has no -consumer; it's harmless metadata." -``` - ---- - -## Task 3: `Retry` refuses streamed-body requests - -**Files:** -- Modify: `src/httpware/middleware/resilience/retry.py` -- Modify: `tests/test_retry.py` - -- [ ] **Step 1: Write failing tests in `tests/test_retry.py`** - -Append: - -```python -async def test_retry_refuses_streamed_body_request() -> None: - """Retry must not replay a request with a streaming body — re-raise with a PEP-678 note.""" - sleeper = _SleepRecorder() - call_count = {"n": 0} - - def handler(request: httpx2.Request) -> httpx2.Response: - call_count["n"] += 1 - return httpx2.Response(HTTPStatus.SERVICE_UNAVAILABLE, request=request) - - async def streamed_body() -> typing.AsyncIterator[bytes]: - yield b"x" - - transport = httpx2.MockTransport(handler) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[Retry(_sleep=sleeper, base_delay=0.001, max_delay=0.002)], - ) - - with pytest.raises(ServiceUnavailableError) as info: - await client.post("https://example.test/upload", content=streamed_body()) - - assert call_count["n"] == 1 - assert sleeper.calls == [] # no retry attempted - notes = getattr(info.value, "__notes__", []) - assert any("not retrying" in note and "stream" in note for note in notes) - - -async def test_retry_refuses_streamed_body_does_not_consume_budget() -> None: - """When Retry refuses for streaming-body reasons, no budget token is withdrawn.""" - sleeper = _SleepRecorder() - budget = RetryBudget(ttl=10.0, min_retries_per_sec=10.0, percent_can_retry=0.2) - - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(HTTPStatus.SERVICE_UNAVAILABLE, request=request) - - async def streamed_body() -> typing.AsyncIterator[bytes]: - yield b"x" - - transport = httpx2.MockTransport(handler) - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[Retry(_sleep=sleeper, budget=budget, base_delay=0.001, max_delay=0.002)], - ) - - with pytest.raises(ServiceUnavailableError): - await client.post("https://example.test/upload", content=streamed_body()) - - # Budget should be untouched: deposits OK (every attempt deposits), but no withdrawals. - # Check via _withdrawn deque emptiness. - assert len(budget._withdrawn) == 0 # noqa: SLF001 — implementation-detail access for invariant -``` - -`Retry`, `RetryBudget`, `_SleepRecorder`, `ServiceUnavailableError`, and `pytest` should already be imported in `tests/test_retry.py`; verify and add any missing imports at the top of the file. - -Run: `uv run pytest tests/test_retry.py -v -k "refuses_streamed_body"` -Expected: FAIL — both attempts will retry (Retry doesn't yet check the marker). - -- [ ] **Step 2: Add the streamed-body check to `Retry.__call__`** - -Read `src/httpware/middleware/resilience/retry.py`. Find the retryable-failure path — specifically, the block just after the `except` clauses and before the `if not self.budget.try_withdraw():` check. - -Insert the refusal block immediately after `# ---- retryable failure path` and BEFORE the `if is_last:` check: - -```python - # ---- retryable failure path - if request.extensions.get("httpware.streaming_body"): - if last_exc is None: # pragma: no cover — invariant from except branch - msg = "Retry: streaming-body refusal reached with no last_exc" - raise AssertionError(msg) - last_exc.add_note( - "httpware: not retrying — request body is a stream that cannot replay across attempts" - ) - raise last_exc - - if is_last: - ... -``` - -The streaming-body check comes FIRST (before is_last + before budget.try_withdraw()) so we don't consume a budget token for a request we won't retry. The PEP-678 note follows the same `add_note` pattern as the max-attempts exhaustion path. - -- [ ] **Step 3: Run the new tests** - -```bash -uv run pytest tests/test_retry.py -v -k "refuses_streamed_body" -``` -Expected: both PASS. - -- [ ] **Step 4: Lint + full suite** - -```bash -just lint && just test -``` -Expected: clean, 100% coverage. - -- [ ] **Step 5: Stage and commit** - -```bash -git add src/httpware/middleware/resilience/retry.py tests/test_retry.py -git commit -m "feat(resilience): Retry refuses requests with streaming bodies - -Closes the deferred-work item 'Retry + streaming bodies'. When the -request was constructed with an async-iterable content/data/files, -_request_with_body marked request.extensions['httpware.streaming_body'] -= True. Retry now reads the marker and re-raises the original failure -with a PEP-678 note ('not retrying — request body is a stream that -cannot replay across attempts') instead of retrying with a consumed -iterator. - -Check happens BEFORE budget.try_withdraw() so a refused retry doesn't -consume a budget token." -``` - ---- - -## Task 4: Add `AsyncClient.stream()` context-manager method - -**Files:** -- Modify: `src/httpware/client.py` -- Create: `tests/test_client_stream.py` - -This is the largest task — the method body, its tests, the integration with the helpers from Task 1. - -- [ ] **Step 1: Write failing tests in `tests/test_client_stream.py`** - -Create `tests/test_client_stream.py`: - -```python -"""Tests for AsyncClient.stream() context manager.""" - -import asyncio -import typing -from http import HTTPStatus - -import httpx2 -import pytest - -from httpware import ( - AsyncClient, - ClientStatusError, - NetworkError, - NotFoundError, - ServerStatusError, - ServiceUnavailableError, - TimeoutError as HttpwareTimeoutError, # noqa: A004 - TransportError, -) -from httpware.middleware import Middleware, Next - - -_UNKNOWN_4XX = 418 # I'm a teapot -_UNKNOWN_5XX = 599 -_REDIRECT_3XX = 301 -_NOT_FOUND = 404 -_SERVICE_UNAVAILABLE = 503 - - -def _client(handler: typing.Callable[[httpx2.Request], httpx2.Response]) -> AsyncClient: - transport = httpx2.MockTransport(handler) - return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=transport)) - - -async def test_streams_response_body_successfully() -> None: - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(HTTPStatus.OK, request=request, content=b"chunk1chunk2chunk3") - - client = _client(handler) - chunks: list[bytes] = [] - async with client.stream("GET", "https://example.test/x") as response: - assert response.status_code == HTTPStatus.OK - async for chunk in response.aiter_bytes(): - chunks.append(chunk) - assert b"".join(chunks) == b"chunk1chunk2chunk3" - - -async def test_auto_raises_on_4xx_with_body_preread() -> None: - body = b'{"error": "not found"}' - - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(_NOT_FOUND, request=request, content=body) - - client = _client(handler) - with pytest.raises(NotFoundError) as info: - async with client.stream("GET", "https://example.test/missing"): - pytest.fail("should have raised before reaching block body") # pragma: no cover - assert info.value.response.status_code == _NOT_FOUND - assert info.value.response.content == body # body was pre-read; accessible - - -async def test_auto_raises_on_5xx_with_body_preread() -> None: - body = b"degraded" - - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(_SERVICE_UNAVAILABLE, request=request, content=body) - - client = _client(handler) - with pytest.raises(ServiceUnavailableError) as info: - async with client.stream("GET", "https://example.test/x"): - pytest.fail("unreachable") # pragma: no cover - assert info.value.response.content == body - - -async def test_auto_raises_unknown_4xx_falls_back_to_client_status_error() -> None: - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(_UNKNOWN_4XX, request=request) - - client = _client(handler) - with pytest.raises(ClientStatusError) as info: - async with client.stream("GET", "https://example.test/x"): - pytest.fail("unreachable") # pragma: no cover - assert type(info.value) is ClientStatusError - assert info.value.response.status_code == _UNKNOWN_4XX - - -async def test_auto_raises_unknown_5xx_falls_back_to_server_status_error() -> None: - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(_UNKNOWN_5XX, request=request) - - client = _client(handler) - with pytest.raises(ServerStatusError) as info: - async with client.stream("GET", "https://example.test/x"): - pytest.fail("unreachable") # pragma: no cover - assert type(info.value) is ServerStatusError - assert info.value.response.status_code == _UNKNOWN_5XX - - -async def test_3xx_does_not_raise() -> None: - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(_REDIRECT_3XX, request=request, headers={"location": "/y"}) - - client = _client(handler) - async with client.stream("GET", "https://example.test/x") as response: - assert response.status_code == _REDIRECT_3XX - - -async def test_network_error_during_request_maps_to_network_error() -> None: - def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - msg = "connect refused" - raise httpx2.ConnectError(msg) - - client = _client(handler) - with pytest.raises(NetworkError, match="connect refused"): - async with client.stream("GET", "https://example.test/x"): - pytest.fail("unreachable") # pragma: no cover - - -async def test_network_error_during_body_consumption_maps_to_network_error() -> None: - async def streaming_body() -> typing.AsyncIterator[bytes]: - yield b"first chunk" - msg = "read failed mid-stream" - raise httpx2.ReadError(msg) - - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(HTTPStatus.OK, request=request, content=streaming_body()) - - client = _client(handler) - with pytest.raises(NetworkError, match="read failed mid-stream"): - async with client.stream("GET", "https://example.test/x") as response: - async for _ in response.aiter_bytes(): - pass - - -async def test_timeout_during_stream_maps_to_httpware_timeout() -> None: - def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - msg = "read timeout" - raise httpx2.ReadTimeout(msg) - - client = _client(handler) - with pytest.raises(HttpwareTimeoutError, match="read timeout"): - async with client.stream("GET", "https://example.test/x"): - pytest.fail("unreachable") # pragma: no cover - - -async def test_invalid_url_maps_to_bare_transport_error() -> None: - def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 - msg = "bad url" - raise httpx2.InvalidURL(msg) - - client = _client(handler) - with pytest.raises(TransportError) as info: - async with client.stream("GET", "https://example.test/x"): - pytest.fail("unreachable") # pragma: no cover - assert not isinstance(info.value, NetworkError) - - -async def test_cancellation_propagates_cleanly() -> None: - async def slow_body() -> typing.AsyncIterator[bytes]: - yield b"first" - await asyncio.sleep(1.0) - yield b"second" # pragma: no cover - - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(HTTPStatus.OK, request=request, content=slow_body()) - - client = _client(handler) - - async def consume() -> None: - async with client.stream("GET", "https://example.test/x") as response: - async for _ in response.aiter_bytes(): - pass - - task = asyncio.create_task(consume()) - await asyncio.sleep(0.01) # let body consumption begin - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - - -async def test_user_exception_in_block_propagates_unchanged() -> None: - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(HTTPStatus.OK, request=request, content=b"data") - - client = _client(handler) - with pytest.raises(ValueError, match="user explosion"): - async with client.stream("GET", "https://example.test/x"): - msg = "user explosion" - raise ValueError(msg) - - -async def test_bypasses_middleware_chain() -> None: - """stream() must not invoke any middleware in the chain.""" - invocations = {"n": 0} - - class _RecordingMiddleware: - async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 - invocations["n"] += 1 - return await next(request) - - def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(HTTPStatus.OK, request=request, content=b"x") - - transport = httpx2.MockTransport(handler) - middleware: Middleware = _RecordingMiddleware() - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=transport), - middleware=[middleware], - ) - - async with client.stream("GET", "https://example.test/x") as response: - async for _ in response.aiter_bytes(): - pass - - assert invocations["n"] == 0 - - -async def test_forwards_kwargs_to_httpx2() -> None: - seen: list[httpx2.Request] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - seen.append(request) - return httpx2.Response(HTTPStatus.OK, request=request, content=b"") - - client = _client(handler) - async with client.stream( - "GET", - "https://example.test/x", - params={"q": "value"}, - headers={"X-Custom": "1"}, - cookies={"sid": "abc"}, - ) as response: - async for _ in response.aiter_bytes(): - pass - - request = seen[0] - assert request.url.params["q"] == "value" - assert request.headers["x-custom"] == "1" - assert request.headers["cookie"] == "sid=abc" - - -async def test_stream_with_content_kwarg() -> None: - seen: list[bytes] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - seen.append(request.content) - return httpx2.Response(HTTPStatus.OK, request=request, content=b"") - - client = _client(handler) - async with client.stream("POST", "https://example.test/upload", content=b"payload") as response: - async for _ in response.aiter_bytes(): - pass - - assert seen[0] == b"payload" - - -async def test_stream_with_async_iterable_content() -> None: - """stream() bypass means async-iterable bodies work without the streaming-body marker mechanism.""" - seen_calls: list[int] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - seen_calls.append(1) - return httpx2.Response(HTTPStatus.OK, request=request, content=b"") - - async def streamed_body() -> typing.AsyncIterator[bytes]: - yield b"chunk1" - yield b"chunk2" - - client = _client(handler) - async with client.stream("POST", "https://example.test/upload", content=streamed_body()) as response: - async for _ in response.aiter_bytes(): - pass - - assert seen_calls == [1] -``` - -Run: `uv run pytest tests/test_client_stream.py -v` -Expected: all FAIL with `AttributeError: 'AsyncClient' object has no attribute 'stream'`. - -- [ ] **Step 2: Implement `AsyncClient.stream()`** - -Add this method to `AsyncClient` in `src/httpware/client.py`. Place it AFTER the existing `request()` method (the last per-method definition) and BEFORE `__aenter__`: - -```python -@contextlib.asynccontextmanager -async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures - self, - method: str, - url: str, - *, - params: typing.Any | None = None, - headers: typing.Any | None = None, - cookies: typing.Any | None = None, - timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT, - extensions: typing.Any | None = None, - json: typing.Any | None = None, - content: typing.Any | None = None, - data: typing.Any | None = None, - files: typing.Any | None = None, -) -> AsyncIterator[httpx2.Response]: - """Stream an HTTP response. Bypasses the middleware chain. - - Yields an httpx2.Response; consume the body via response.aiter_bytes(), - response.aiter_text(), response.aiter_lines(), or response.aiter_raw(). - The body is NOT pre-read for 2xx/3xx (streaming preserved); the response - is closed when the context exits. - - Bypasses the middleware chain (no Retry, no Bulkhead, no user-installed - middleware) for v1 — see planning/specs/2026-06-05-streaming-design.md. - - Auto-raises StatusError subclasses on 4xx/5xx (NotFoundError, - ServiceUnavailableError, etc.) — consistent with client.get()/post()/etc. - On error the response body is pre-read so exc.response.content is - accessible. You lose the streaming property on errors; rare in practice. - - Maps httpx2 exceptions raised during the request OR body consumption to - httpware exceptions via _httpx2_exception_mapper. - """ - kwargs: dict[str, typing.Any] = {} - if params is not None: - kwargs["params"] = params - if headers is not None: - kwargs["headers"] = headers - if cookies is not None: - kwargs["cookies"] = cookies - if timeout is not httpx2.USE_CLIENT_DEFAULT: - kwargs["timeout"] = timeout - if extensions is not None: - kwargs["extensions"] = extensions - if json is not None: - kwargs["json"] = json - if content is not None: - kwargs["content"] = content - if data is not None: - kwargs["data"] = data - if files is not None: - kwargs["files"] = files - - async with _httpx2_exception_mapper(): - async with self._httpx2_client.stream(method, url, **kwargs) as response: - if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx - await response.aread() # pre-read body so exc.response.content works - _raise_on_status_error(response) - yield response -``` - -Note: `_raise_on_status_error` actually doesn't need the wrapping `if` check because it's a no-op for non-error status; you could simplify. But the explicit `if` here makes it obvious that `aread()` only runs on errors. Keep both. Alternatively if simpler: - -```python -if HTTPStatus.BAD_REQUEST <= response.status_code < 600: - await response.aread() -_raise_on_status_error(response) # raises iff 4xx/5xx -yield response -``` - -Either form is fine. The explicit version makes the "only pre-read on error" intent obvious; pick that one. - -- [ ] **Step 3: Run the stream tests** - -```bash -uv run pytest tests/test_client_stream.py -v -``` -Expected: all PASS. - -- [ ] **Step 4: Lint + full suite** - -```bash -just lint && just test -``` -Expected: clean, 100% coverage. - -- [ ] **Step 5: Stage and commit** - -```bash -git add src/httpware/client.py tests/test_client_stream.py -git commit -m "feat(client): AsyncClient.stream() context manager - -Adds AsyncClient.stream(method, url, **kwargs) as a -@contextlib.asynccontextmanager method on the client. Mirrors -httpx2.AsyncClient.stream() but auto-raises StatusError subclasses -on 4xx/5xx (consistent with client.get/post/etc.) with body -pre-read so exc.response.content is accessible. - -Bypasses the middleware chain (v1 design decision — revisit if user -feedback warrants). Uses the shared _httpx2_exception_mapper and -_raise_on_status_error helpers extracted in the earlier refactor -commit, so dispatch logic stays in lockstep with _terminal. - -Body consumption errors during 'async for chunk in response.aiter_bytes()' -propagate through the yield and get mapped to httpware exceptions -consistently." -``` - ---- - -## Task 5: Documentation + release notes - -**Files:** -- Modify: `README.md` -- Modify: `docs/index.md` -- Modify: `planning/engineering.md` -- Modify: `planning/deferred-work.md` -- Create: `planning/releases/0.5.0.md` - -- [ ] **Step 1: Add streaming snippet to README.md** - -After the existing "With resilience middleware" subsection and BEFORE the `## Errors` section, insert a new `### Streaming responses` subsection: - -```markdown - -### Streaming responses - -For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager: - -```python -from httpware import AsyncClient - - -async def main() -> None: - async with AsyncClient(base_url="https://api.example.com") as client: - async with client.stream("GET", "/big-file") as response: - async for chunk in response.aiter_bytes(): - process(chunk) -``` - -`stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception. - -It does NOT pass through the middleware chain: `Retry`, `Bulkhead`, and any custom middleware are bypassed. (Retry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.) -``` - -- [ ] **Step 2: Mirror the addition in `docs/index.md`** - -Same content added at the matching position (after the "With resilience middleware" subsection, before the `## Errors` section). Keep the wording verbatim so the README and docs/index.md stay in sync. - -- [ ] **Step 3: Update `planning/engineering.md`** - -In §1 (Project intent), append one sentence to the first paragraph (after the resilience-suite sentence added in the 0.4 docs sync): - -``` - As of 0.5.0, `AsyncClient.stream()` provides a context-manager API for chunked response bodies; it bypasses the middleware chain by design (see planning/specs/2026-06-05-streaming-design.md). -``` - -In §8 (Remaining roadmap), find the Epic 4 entry: -``` -- **Epic 4 — Streaming:** `4-3` `AsyncClient.stream` context manager (forwards to `httpx2.AsyncClient.stream`; no `StreamResponse` type). -``` -Replace with: -``` -- **Epic 4 — Streaming:** SHIPPED in v0.5 (PR #...): `AsyncClient.stream()` context manager + Retry refuses streamed-body requests. See [`planning/specs/2026-06-05-streaming-design.md`](specs/2026-06-05-streaming-design.md) and [`planning/plans/2026-06-05-streaming-plan.md`](plans/2026-06-05-streaming-plan.md). -``` - -(Use the actual PR number once the PR is opened — leave a `#…` placeholder if the PR doesn't exist yet; the implementer fills in during finishing-a-development-branch.) - -- [ ] **Step 4: Close the two deferred-work items** - -Edit `planning/deferred-work.md`. The two items to close: - -1. Under `## Open` → `### Retry + streaming bodies (Epic 4 interaction)`: remove this entire entry. - -2. Under `## Closed by the v0.2 thin-wrapper pivot (2026-06-03)`: the line `- httpx2.StreamError family escape from the transport's except httpx2.HTTPError (mapping logic relocated to AsyncClient's terminal; revisit with Epic 4 streaming work).` — replace the trailing parenthetical with `; closed by 0.5.0 streaming work — exception mapping in _httpx2_exception_mapper covers the StreamError family via httpx2.NetworkError).` - -Then add a NEW section above the "Closed by the v0.2 thin-wrapper pivot" one: - -```markdown -## Closed by the 0.5.0 streaming release (2026-06-05) - -- **`Retry` refuses streamed-body requests.** When `_request_with_body` is called with an async-iterable `content`/`data`/`files`, the request gets `extensions["httpware.streaming_body"] = True`. `Retry.__call__` reads the marker and re-raises with a PEP-678 note on retryable failures instead of retrying with a consumed iterator. Closes the prior Open entry. -- **`httpx2.StreamError` family escape closed.** The new shared `_httpx2_exception_mapper` catches `httpx2.NetworkError` (which is the parent of `ReadError` / `WriteError` / `CloseError`), so stream-specific exceptions raised during body consumption now map to `httpware.NetworkError` consistently. -``` - -- [ ] **Step 5: Create `planning/releases/0.5.0.md`** - -```markdown -# httpware 0.5.0 — Streaming responses - -**0.5.0 is additive. No breaking changes.** Code written against 0.4.0 continues to work unchanged. - -This release closes Epic 4 by adding `AsyncClient.stream()` for chunked response bodies, and closes two longstanding deferred-work items along the way. - -## New features - -- **`AsyncClient.stream(method, url, **kwargs)`** — async context manager that yields an `httpx2.Response` with a non-pre-read body. Consume via `response.aiter_bytes()`, `response.aiter_text()`, `response.aiter_lines()`, or `response.aiter_raw()`. Auto-raises `StatusError` subclasses on 4xx/5xx (with the body pre-read so `exc.response.content` works). Bypasses the middleware chain by design — `Retry`, `Bulkhead`, and user-installed middleware do not see `stream()` calls in v1. -- **`Retry` refuses streamed-body requests.** When you call `client.post(content=async_gen())` (or `data=`, `files=`), the request is marked via `request.extensions["httpware.streaming_body"]`. If `Retry` would otherwise retry on a failure, it re-raises the original exception with a PEP 678 note instead — preventing the "consumed iterator can't replay" footgun. - -## Backwards compatibility - -Subclassing/extensions preserve every existing catch-block: - -- All previously-shipping methods (`get`, `post`, etc.) behave identically. -- The internal refactor that extracted `_httpx2_exception_mapper` from `_terminal` is byte-for-byte equivalent in dispatch behavior. Tests prove this. -- The streaming-body marker (`request.extensions["httpware.streaming_body"]`) only affects requests that genuinely have async-iterable bodies. Existing code passing bytes / dict / files-as-bytes is unaffected. - -## Usage - -```python -from httpware import AsyncClient - - -async def main() -> None: - async with AsyncClient(base_url="https://api.example.com") as client: - async with client.stream("GET", "/big-file") as response: - async for chunk in response.aiter_bytes(): - process(chunk) -``` - -Catch typed status errors on streams the same way as on regular calls: - -```python -from httpware import NotFoundError - -try: - async with client.stream("GET", "/maybe-missing") as response: - ... -except NotFoundError as exc: - body_text = exc.response.text # pre-read; accessible -``` - -## What's still ahead - -- Epic 5 (observability hooks + OTel middleware) is unstarted; logging of retry / bulkhead / stream decisions plumbs through then. -- Whether `stream()` should compose with the middleware chain is deferred to real-user feedback. Adding it later is purely additive (`stream(..., apply_middleware: bool = False)` opt-in). - -## References - -- Spec: [`planning/specs/2026-06-05-streaming-design.md`](../specs/2026-06-05-streaming-design.md) -- Plan: [`planning/plans/2026-06-05-streaming-plan.md`](../plans/2026-06-05-streaming-plan.md) -- Roadmap: [`planning/engineering.md`](../engineering.md) §8 -``` - -- [ ] **Step 6: Lint** - -```bash -just lint -``` -Expected: clean. (eof-fixer + ruff format may normalize the markdown.) - -- [ ] **Step 7: Stage and commit** - -```bash -git add README.md docs/index.md planning/engineering.md planning/deferred-work.md planning/releases/0.5.0.md -git commit -m "docs: 0.5.0 release notes + sync user docs with streaming work - -- README + docs/index.md: add 'Streaming responses' subsection -- planning/engineering.md §1 + §8: mention stream() in project intent; - mark Epic 4 SHIPPED in roadmap -- planning/deferred-work.md: close the 'Retry + streaming bodies' open - item and update the v0.2-pivot StreamError-escape entry; add a new - 'Closed by the 0.5.0 streaming release' section -- planning/releases/0.5.0.md: new release notes" -``` - ---- - -## Task 6: Final verification + push - -**Files:** none modified; verification only. - -- [ ] **Step 1: Full lint** - -```bash -just lint-ci -``` -Expected: clean. - -- [ ] **Step 2: Full test suite** - -```bash -just test -``` -Expected: ALL tests PASS, coverage = 100%. Test count should be 209 (current) + ~5 marker tests + 2 retry-refuse tests + ~16 stream tests = ~232. - -- [ ] **Step 3: Architecture invariants from `CLAUDE.md`** - -```bash -grep -rE 'httpx2\._' src/httpware/ || echo "PASS: no httpx2 private API" -grep -rE 'from __future__ import annotations' src/httpware/ || echo "PASS: no __future__ annotations" -grep -rE '\bprint\(' src/httpware/ || echo "PASS: no print()" -grep -rE 'logging\.(basicConfig|getLogger)\(\)' src/httpware/ || echo "PASS: no global logging" -grep -rE '# (type|mypy): ignore' src/httpware/ || echo "PASS: no type/mypy ignore" -``` -Each should print PASS. - -- [ ] **Step 4: Optional-extras isolation** - -`stream()` is pure stdlib — no new optional deps. -```bash -uv run pytest tests/test_optional_extras_isolation.py -v -``` -Expected: PASS. - -- [ ] **Step 5: mkdocs strict build** - -```bash -uv run --with mkdocs --with mkdocs-material mkdocs build --strict 2>&1 | tail -10 -rm -rf site/ -``` -Expected: 0 warnings. (Previous PR closed all nav-warnings; this one only adds content under `index.md`.) - -- [ ] **Step 6: Push the branch** - -```bash -git push -u origin feat/v0.5-streaming -``` - -DO NOT open the PR yet — leave that to `finishing-a-development-branch`. - ---- - -## Out of scope for this plan (per the spec) - -These items are deliberately deferred. Do NOT do them in this PR: - -- **`stream()` going through the middleware chain.** Stays bypassed for v1. Adding `apply_middleware: bool = False` opt-in later is purely additive. -- **`StreamResponse` wrapper type.** Explicit non-goal from the original story wording. -- **`response_model=` decoding parameter for stream().** Doesn't apply. -- **Bulkhead-during-stream integration.** Bulkhead doesn't see stream() calls. -- **Detection of streaming bodies on manually-constructed `httpx2.Request` objects.** Marker only set in `_request_with_body`; manual constructors accept responsibility. -- **Version bump in `pyproject.toml`.** Tag-driven release; bump not required (see prior pattern: 0.4.0 release notes shipped without a pyproject bump). diff --git a/planning/changes/2026-06-05.05-observability/design.md b/planning/changes/2026-06-05.05-observability.md similarity index 100% rename from planning/changes/2026-06-05.05-observability/design.md rename to planning/changes/2026-06-05.05-observability.md diff --git a/planning/changes/2026-06-05.05-observability/plan.md b/planning/changes/2026-06-05.05-observability/plan.md deleted file mode 100644 index ae70348..0000000 --- a/planning/changes/2026-06-05.05-observability/plan.md +++ /dev/null @@ -1,1048 +0,0 @@ -# Resilience observability (0.6.0, Epic 5 re-scoped) 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:** `Retry` and `Bulkhead` emit four operational-significance events (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`, `bulkhead.rejected`) via two channels: stdlib `logging` records (always on) and `opentelemetry.trace.get_current_span().add_event(...)` when the new `otel` extra is installed. Logger names `httpware.retry` / `httpware.bulkhead` are the public contract. - -**Architecture:** New `_internal/observability.py` with a single `_emit_event(logger, event_name, *, level, message, attributes)` helper. Lazy `from opentelemetry import trace` happens inside an `if import_checker.is_otel_installed:` gate, preserving the optional-extras isolation invariant. `Retry` and `Bulkhead` acquire module-level loggers and call the helper at their event sites. Re-introduces the `otel` extra (PR #24 removed it as YAGNI; this PR brings it back paired with the code that uses it). - -**Tech Stack:** Python 3.11+, stdlib `logging`, optional `opentelemetry-api>=1.20` (just the API, not the SDK). - -**Target branch:** `feat/v0.6-observability`. Create from `main` before Task 1: `git checkout main && git pull && git checkout -b feat/v0.6-observability`. - -**Source spec:** [`planning/specs/2026-06-05-observability-design.md`](../specs/2026-06-05-observability-design.md). Read it before starting — the *why* for each decision lives there. - ---- - -## File structure - -**New files:** -- `src/httpware/_internal/observability.py` — `_emit_event` helper -- `tests/test_observability.py` — unit tests for the helper -- `tests/test_optional_extras_otel_missing.py` — fail-soft tests -- `planning/releases/0.6.0.md` — release notes - -**Modified files:** -- `pyproject.toml` — re-add `otel` extra; include in `all` -- `src/httpware/_internal/import_checker.py` — add `is_otel_installed` -- `src/httpware/middleware/resilience/retry.py` — add `_LOGGER` + 3 `_emit_event` calls -- `src/httpware/middleware/resilience/bulkhead.py` — add `_LOGGER` + 1 `_emit_event` call -- `tests/test_retry.py` — 3 new emission tests -- `tests/test_bulkhead.py` — 1 new emission test -- `tests/test_optional_extras_isolation.py` — add `opentelemetry` isolation check -- `README.md` — add "Observability" section -- `docs/index.md` — mirror the README addition -- `planning/engineering.md` — §1 (mention observability) + §7 (re-add otel extra description) + §8 (retire 5-1/5-4; mark 5-2 shipped) - -**Commit cadence:** one commit per task. Per-task commits keep history reviewable. - ---- - -## Task 1: Branch + `otel` extra + `is_otel_installed` + isolation test - -**Files:** -- Modify: `pyproject.toml` -- Modify: `src/httpware/_internal/import_checker.py` -- Modify: `tests/test_optional_extras_isolation.py` - -- [ ] **Step 1: Create the branch** - -```bash -git checkout main && git pull && git checkout -b feat/v0.6-observability -``` -Expected: switched to a new branch. - -- [ ] **Step 2: Re-add the `otel` extra in `pyproject.toml`** - -Read the current `[project.optional-dependencies]` block. It currently looks like: -```toml -[project.optional-dependencies] -pydantic = ["pydantic>=2.0,<3.0"] -msgspec = ["msgspec>=0.18"] -all = ["httpware[pydantic,msgspec]"] -``` - -Replace with: -```toml -[project.optional-dependencies] -pydantic = ["pydantic>=2.0,<3.0"] -msgspec = ["msgspec>=0.18"] -otel = ["opentelemetry-api>=1.20"] -all = ["httpware[pydantic,msgspec,otel]"] -``` - -Note: just `opentelemetry-api`, NOT `opentelemetry-sdk`. Users supply their own SDK. - -- [ ] **Step 3: Sync deps** - -```bash -just install -``` -Expected: uv installs `opentelemetry-api` (and its `opentelemetry-semantic-conventions` transitive dep). - -- [ ] **Step 4: Add `is_otel_installed` to `import_checker.py`** - -Replace the file content with: -```python -"""Detect optional extras without importing them. Used by adapter modules to gate hard imports.""" - -from importlib.util import find_spec - - -is_msgspec_installed = find_spec("msgspec") is not None -is_pydantic_installed = find_spec("pydantic") is not None -is_otel_installed = find_spec("opentelemetry") is not None -``` - -- [ ] **Step 5: Add isolation test in `tests/test_optional_extras_isolation.py`** - -Append: -```python -def test_importing_httpware_does_not_import_opentelemetry() -> None: - """Fresh subprocess: opentelemetry must NOT appear in sys.modules after `import httpware`. - - opentelemetry-api IS installed in the test environment (via `--all-extras`), so this - test runs in a subprocess with a clean interpreter to verify that nothing - in the httpware import chain pulls opentelemetry in. - """ - result = subprocess.run( - [ - sys.executable, - "-c", - "import httpware; import sys; sys.exit(0 if 'opentelemetry' not in sys.modules else 1)", - ], - check=False, - capture_output=True, - ) - assert result.returncode == 0, ( - f"opentelemetry was loaded transitively by `import httpware`; stdout={result.stdout!r} stderr={result.stderr!r}" - ) -``` - -- [ ] **Step 6: Run the isolation tests** - -```bash -uv run pytest tests/test_optional_extras_isolation.py -v -``` -Expected: all 3 PASS (pydantic, msgspec, opentelemetry). - -- [ ] **Step 7: Lint + full suite** - -```bash -just lint && just test -``` -Expected: clean, 100% coverage maintained. - -- [ ] **Step 8: Commit** - -```bash -git add pyproject.toml src/httpware/_internal/import_checker.py tests/test_optional_extras_isolation.py -git commit -m "chore(deps): re-add otel optional extra paired with the code that uses it - -PR #24 removed the otel extra as YAGNI (it advertised functionality that -didn't exist). 0.6.0 brings it back: structured-logging observability -in Retry / Bulkhead with opt-in OTel attribute enrichment lands in -the next commits. - -otel = ['opentelemetry-api>=1.20'] only — no SDK. Users supply their -own SDK (or use a no-op tracer in tests). Matches how -opentelemetry-instrumentation-httpx declares its dep. - -import_checker gains is_otel_installed alongside the existing flags. -Isolation test extended to verify import httpware does not pull -opentelemetry into sys.modules." -``` - ---- - -## Task 2: `_internal/observability.py` helper + unit tests - -**Files:** -- Create: `src/httpware/_internal/observability.py` -- Create: `tests/test_observability.py` - -- [ ] **Step 1: Write failing tests in `tests/test_observability.py`** - -Create `tests/test_observability.py`: -```python -"""Unit tests for the _emit_event observability helper.""" - -import logging -import sys -from unittest.mock import MagicMock, patch - -import pytest - -from httpware._internal.observability import _emit_event - - -_TEST_LOGGER = logging.getLogger("httpware.test.observability") - - -def test_emit_event_logs_at_warning_with_extra_fields(caplog: pytest.LogCaptureFixture) -> None: - """The helper emits one structured log record at WARNING with attributes accessible on the record.""" - with caplog.at_level(logging.WARNING, logger="httpware.test.observability"): - _emit_event( - _TEST_LOGGER, - "test.event", - level=logging.WARNING, - message="something interesting happened", - attributes={"foo": 1, "bar": "x"}, - ) - - assert len(caplog.records) == 1 - record = caplog.records[0] - assert record.levelno == logging.WARNING - assert record.message == "something interesting happened" - assert record.foo == 1 # ty: ignore[unresolved-attribute] - assert record.bar == "x" # ty: ignore[unresolved-attribute] - - -def test_emit_event_respects_level_parameter(caplog: pytest.LogCaptureFixture) -> None: - """When level=DEBUG is passed, the record is at DEBUG.""" - with caplog.at_level(logging.DEBUG, logger="httpware.test.observability"): - _emit_event( - _TEST_LOGGER, - "test.event", - level=logging.DEBUG, - message="quiet", - attributes={}, - ) - - assert len(caplog.records) == 1 - assert caplog.records[0].levelno == logging.DEBUG - - -def test_emit_event_does_not_import_opentelemetry_when_flag_false() -> None: - """With is_otel_installed=False the helper must not touch opentelemetry.""" - with patch("httpware._internal.import_checker.is_otel_installed", False): - # Confirm the lazy import path is skipped: snapshot sys.modules before/after. - modules_before = set(sys.modules) - _emit_event( - _TEST_LOGGER, - "test.event", - level=logging.WARNING, - message="nope", - attributes={"x": 1}, - ) - # opentelemetry may already be loaded by other tests; allow that, but no NEW load happened - # because the codepath was skipped. The point: no error and no required state change. - assert len(modules_before) >= 0 # noqa: PLR2004 — sanity assertion the with-block ran - - -def test_emit_event_calls_add_event_when_otel_installed() -> None: - """With is_otel_installed=True the helper calls trace.get_current_span().add_event(...).""" - mock_span = MagicMock(name="MockSpan") - with ( - patch("httpware._internal.import_checker.is_otel_installed", True), - patch("opentelemetry.trace.get_current_span", return_value=mock_span), - ): - _emit_event( - _TEST_LOGGER, - "test.event", - level=logging.WARNING, - message="hi", - attributes={"k": "v"}, - ) - - mock_span.add_event.assert_called_once_with("test.event", attributes={"k": "v"}) - - -def test_emit_event_works_when_otel_installed_but_no_active_span() -> None: - """With OTel installed but no tracer configured, get_current_span() returns NonRecordingSpan; - add_event is a documented no-op. No error. - """ - # Real OTel API call (no mocking) — opentelemetry-api is installed via the otel extra. - _emit_event( - _TEST_LOGGER, - "test.event", - level=logging.WARNING, - message="real-otel-but-no-tracer", - attributes={"a": 1}, - ) - # No assertion needed — the absence of an exception IS the assertion. -``` - -Run: `uv run pytest tests/test_observability.py -v` -Expected: FAIL with `ImportError: cannot import name '_emit_event' from 'httpware._internal.observability'`. - -- [ ] **Step 2: Implement the helper** - -Create `src/httpware/_internal/observability.py`: -```python -"""Observability emission helper — structured logging + opt-in OpenTelemetry span events. - -See planning/specs/2026-06-05-observability-design.md for the contract. - -Logger names (``httpware.retry``, ``httpware.bulkhead``) and event names -(``retry.giving_up``, ``bulkhead.rejected``, etc.) are the public observability -surface. They are stable: renames are breaking changes. -""" - -import logging -import typing - -from httpware._internal import import_checker - - -def _emit_event( - logger: logging.Logger, - event_name: str, - *, - level: int, - message: str, - attributes: dict[str, typing.Any], -) -> None: - """Emit one observability event to both channels. - - 1. Always emits a structured log record at ``level`` with ``extra=attributes`` - (so log aggregators that index ``extra`` see structured fields). - 2. If ``opentelemetry-api`` is installed, calls - ``trace.get_current_span().add_event(event_name, attributes=attributes)``. - When no tracer is active, ``get_current_span()`` returns a ``NonRecordingSpan`` - whose ``add_event`` is a documented no-op — so the call is unconditional - behind the install gate. - - The lazy ``from opentelemetry import trace`` inside the if-block preserves - the optional-extras isolation invariant: ``import httpware`` must not pull - ``opentelemetry`` into ``sys.modules`` when the extra is absent. - """ - logger.log(level, message, extra=attributes) - if import_checker.is_otel_installed: - from opentelemetry import trace # noqa: PLC0415 — lazy by design (optional-extras isolation) - trace.get_current_span().add_event(event_name, attributes=attributes) -``` - -- [ ] **Step 3: Run the observability tests** - -```bash -uv run pytest tests/test_observability.py -v -``` -Expected: all PASS. - -- [ ] **Step 4: Lint + full suite** - -```bash -just lint && just test -``` -Expected: clean, 100% coverage. - -- [ ] **Step 5: Stage and commit** - -```bash -git add src/httpware/_internal/observability.py tests/test_observability.py -git commit -m "feat(observability): _emit_event helper for resilience middleware events - -New _internal/observability.py with a single _emit_event helper. Always -emits a structured log record at the requested level; if opentelemetry-api -is installed, calls trace.get_current_span().add_event(name, attributes=...) -on the active span. - -The lazy 'from opentelemetry import trace' inside the if is_otel_installed -gate preserves the optional-extras isolation invariant (import httpware -does not pull opentelemetry when the extra is absent). - -Logger names and event names are the public observability surface; the -helper itself lives in _internal/ so users interact only with the -strings, not Python imports." -``` - ---- - -## Task 3: `Retry` emits 3 operational events - -**Files:** -- Modify: `src/httpware/middleware/resilience/retry.py` -- Modify: `tests/test_retry.py` - -- [ ] **Step 1: Write failing tests in `tests/test_retry.py`** - -Append to `tests/test_retry.py`: -```python -async def test_retry_giving_up_emits_observability_event(caplog: pytest.LogCaptureFixture) -> None: - """When max_attempts is exhausted, emit one WARNING record on httpware.retry.""" - sleeper = _SleepRecorder() - handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE] * 3) - client = _client(handler, retry=Retry(_sleep=sleeper, max_attempts=3, base_delay=0.001, max_delay=0.002)) - - with caplog.at_level(logging.WARNING, logger="httpware.retry"): - with pytest.raises(ServiceUnavailableError): - await client.get("https://example.test/x") - - retry_records = [r for r in caplog.records if r.name == "httpware.retry"] - giving_up_records = [r for r in retry_records if r.message.startswith("retry gave up")] - assert len(giving_up_records) == 1 - record = giving_up_records[0] - assert record.levelno == logging.WARNING - assert record.attempts == 3 # ty: ignore[unresolved-attribute] - assert record.method == "GET" # ty: ignore[unresolved-attribute] - assert record.last_status == HTTPStatus.SERVICE_UNAVAILABLE # ty: ignore[unresolved-attribute] - assert record.last_exception_type == "ServiceUnavailableError" # ty: ignore[unresolved-attribute] - - -async def test_retry_budget_refused_emits_observability_event(caplog: pytest.LogCaptureFixture) -> None: - """When the budget refuses a retry, emit one WARNING record on httpware.retry.""" - sleeper = _SleepRecorder() - stingy_budget = RetryBudget(percent_can_retry=0.0, min_retries_per_sec=0.0) - handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.SERVICE_UNAVAILABLE]) - client = _client( - handler, - retry=Retry(_sleep=sleeper, budget=stingy_budget, max_attempts=3, base_delay=0.001), - ) - - with caplog.at_level(logging.WARNING, logger="httpware.retry"): - with pytest.raises(RetryBudgetExhaustedError): - await client.get("https://example.test/x") - - retry_records = [r for r in caplog.records if r.name == "httpware.retry"] - budget_records = [r for r in retry_records if "budget" in r.message] - assert len(budget_records) == 1 - record = budget_records[0] - assert record.attempts == 1 # ty: ignore[unresolved-attribute] - assert record.method == "GET" # ty: ignore[unresolved-attribute] - assert record.last_status == HTTPStatus.SERVICE_UNAVAILABLE # ty: ignore[unresolved-attribute] - - -async def test_retry_streaming_refused_emits_observability_event(caplog: pytest.LogCaptureFixture) -> None: - """When the streaming-body marker prevents a retryable retry, emit one WARNING record on httpware.retry. - - Uses an idempotent method (PUT) so we hit the retryable-failure-path streaming-refusal site, - NOT the non-idempotent early-exit sites (which don't emit the event per the spec). - """ - sleeper = _SleepRecorder() - handler = _ResponseSequence([HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.SERVICE_UNAVAILABLE]) - client = _client(handler, retry=Retry(_sleep=sleeper, base_delay=0.001, max_delay=0.002)) - - async def streamed_body() -> typing.AsyncIterator[bytes]: - yield b"x" - - with caplog.at_level(logging.WARNING, logger="httpware.retry"): - with pytest.raises(ServiceUnavailableError): - await client.put("https://example.test/x", content=streamed_body()) - - retry_records = [r for r in caplog.records if r.name == "httpware.retry"] - streaming_records = [r for r in retry_records if "stream" in r.message] - assert len(streaming_records) == 1 - record = streaming_records[0] - assert record.method == "PUT" # ty: ignore[unresolved-attribute] - assert record.last_exception_type == "ServiceUnavailableError" # ty: ignore[unresolved-attribute] -``` - -`logging` should already be imported at the top of `tests/test_retry.py` if not, add it. - -Run: `uv run pytest tests/test_retry.py -v -k "emits_observability_event"` -Expected: all 3 FAIL — no events emitted yet. - -- [ ] **Step 2: Add `_LOGGER` constant to `retry.py`** - -In `src/httpware/middleware/resilience/retry.py`, after the existing module-level constants block (around `DEFAULT_IDEMPOTENT_METHODS` / `_MAX_ATTEMPTS_INVALID` / `_STREAMING_BODY_REFUSAL_NOTE`), add: -```python -import logging - -_LOGGER = logging.getLogger("httpware.retry") -``` - -Hoist the `import logging` to the top of the file alongside other stdlib imports if not already present (per project convention — no in-function imports). - -- [ ] **Step 3: Import `_emit_event`** - -Add to the imports block: -```python -from httpware._internal.observability import _emit_event -``` - -- [ ] **Step 4: Emit `retry.giving_up` event in `Retry.__call__`** - -Find the `if is_last:` block (around line 153). Currently: -```python -if is_last: - if last_exc is None: # pragma: no cover — structural invariant from except branch - msg = "Retry: last_exc unset on final attempt — unreachable" - raise AssertionError(msg) - last_exc.add_note(f"httpware: gave up after {attempt + 1} attempts") - raise last_exc -``` - -Insert the emit call after `add_note(...)` and before `raise last_exc`: -```python -if is_last: - if last_exc is None: # pragma: no cover — structural invariant from except branch - msg = "Retry: last_exc unset on final attempt — unreachable" - raise AssertionError(msg) - last_exc.add_note(f"httpware: gave up after {attempt + 1} attempts") - _emit_event( - _LOGGER, - "retry.giving_up", - level=logging.WARNING, - message=f"retry gave up after {attempt + 1} attempts", - attributes={ - "attempts": attempt + 1, - "method": request.method, - "url": str(request.url), - "last_status": last_response.status_code if last_response is not None else None, - "last_exception_type": type(last_exc).__qualname__, - }, - ) - raise last_exc -``` - -- [ ] **Step 5: Emit `retry.budget_refused` event** - -Find the budget exhaustion block (the `if not self.budget.try_withdraw():` site, around line 160). Currently: -```python -if not self.budget.try_withdraw(): - raise RetryBudgetExhaustedError( - last_response=last_response, - last_exception=last_exc, - attempts=attempt + 1, - ) from last_exc -``` - -Insert the emit call BEFORE the raise: -```python -if not self.budget.try_withdraw(): - _emit_event( - _LOGGER, - "retry.budget_refused", - level=logging.WARNING, - message=f"retry budget refused after {attempt + 1} attempts", - attributes={ - "attempts": attempt + 1, - "method": request.method, - "url": str(request.url), - "last_status": last_response.status_code if last_response is not None else None, - }, - ) - raise RetryBudgetExhaustedError( - last_response=last_response, - last_exception=last_exc, - attempts=attempt + 1, - ) from last_exc -``` - -- [ ] **Step 6: Emit `retry.streaming_refused` event at the retryable-failure-path site only** - -Find the streaming-body refusal block in the retryable-failure-path (NOT the early-exit sites). It's the one at around line 144: -```python -# ---- retryable failure path -if request.extensions.get(STREAMING_BODY_MARKER): - if last_exc is None: # pragma: no cover — invariant from except branch - msg = "Retry: streaming-body refusal reached with no last_exc" - raise AssertionError(msg) - last_exc.add_note(_STREAMING_BODY_REFUSAL_NOTE) - raise last_exc -``` - -Insert the emit call after `add_note(...)` and before `raise last_exc`: -```python -# ---- retryable failure path -if request.extensions.get(STREAMING_BODY_MARKER): - if last_exc is None: # pragma: no cover — invariant from except branch - msg = "Retry: streaming-body refusal reached with no last_exc" - raise AssertionError(msg) - last_exc.add_note(_STREAMING_BODY_REFUSAL_NOTE) - _emit_event( - _LOGGER, - "retry.streaming_refused", - level=logging.WARNING, - message="retry refused — request body is a stream that cannot replay", - attributes={ - "method": request.method, - "url": str(request.url), - "last_exception_type": type(last_exc).__qualname__, - }, - ) - raise last_exc -``` - -**IMPORTANT**: do NOT add the emit call at the 3 non-idempotent early-exit sites (lines 111-118, 120-127, 131-138). At those sites the primary reason for not retrying is method-eligibility; the `add_note` call already provides context. The EVENT only fires when streaming was the deciding factor. - -- [ ] **Step 7: Run the new tests** - -```bash -uv run pytest tests/test_retry.py -v -k "emits_observability_event" -``` -Expected: all 3 PASS. - -- [ ] **Step 8: Lint + full suite** - -```bash -just lint && just test -``` -Expected: clean, 100% coverage. - -- [ ] **Step 9: Stage and commit** - -```bash -git add src/httpware/middleware/resilience/retry.py tests/test_retry.py -git commit -m "feat(retry): emit operational events via httpware.retry logger + OTel - -Three event sites: -- retry.giving_up (WARNING): max_attempts exhausted -- retry.budget_refused (WARNING): budget.try_withdraw() refused -- retry.streaming_refused (WARNING): streaming-body marker prevented an - otherwise-retryable retry (retryable-failure-path site only — the 3 - non-idempotent early-exit sites still add the note but do NOT emit - this event, since at those sites method-eligibility is the primary - reason for not retrying). - -All four events have flat, scalar attributes (method, url, attempts, -last_status, last_exception_type) so they index cleanly in log -aggregators and serialize cleanly as OTel attributes." -``` - ---- - -## Task 4: `Bulkhead` emits `bulkhead.rejected` event - -**Files:** -- Modify: `src/httpware/middleware/resilience/bulkhead.py` -- Modify: `tests/test_bulkhead.py` - -- [ ] **Step 1: Write failing test in `tests/test_bulkhead.py`** - -Append: -```python -import logging # add to the top of the file if not present - - -async def test_bulkhead_rejected_emits_observability_event(caplog: pytest.LogCaptureFixture) -> None: - """When acquire_timeout elapses without acquisition, emit one WARNING record on httpware.bulkhead.""" - handler = _SlowHandler(delay=_ACQUIRE_TIMEOUT_LONG) - client = _client( - handler, - bulkhead=Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=_ACQUIRE_TIMEOUT_SHORT), - ) - - first = asyncio.create_task(client.get("https://example.test/a")) - await asyncio.sleep(0.005) # let first acquire - - with caplog.at_level(logging.WARNING, logger="httpware.bulkhead"): - with pytest.raises(BulkheadFullError): - await client.get("https://example.test/b") - - bulkhead_records = [r for r in caplog.records if r.name == "httpware.bulkhead"] - assert len(bulkhead_records) == 1 - record = bulkhead_records[0] - assert record.levelno == logging.WARNING - assert "rejected" in record.message - assert record.max_concurrent == _MAX_CONCURRENT_1 # ty: ignore[unresolved-attribute] - assert record.acquire_timeout == _ACQUIRE_TIMEOUT_SHORT # ty: ignore[unresolved-attribute] - assert record.method == "GET" # ty: ignore[unresolved-attribute] - - await first # cleanup -``` - -Run: `uv run pytest tests/test_bulkhead.py -v -k "rejected_emits_observability_event"` -Expected: FAIL — no event emitted yet. - -- [ ] **Step 2: Add `_LOGGER` constant + emit import to `bulkhead.py`** - -Add at the top of `src/httpware/middleware/resilience/bulkhead.py` (after the existing `import asyncio`): -```python -import logging -``` - -After the existing module-level constants (`_MAX_CONCURRENT_INVALID`, `_ACQUIRE_TIMEOUT_INVALID`): -```python -_LOGGER = logging.getLogger("httpware.bulkhead") -``` - -Add to the imports block: -```python -from httpware._internal.observability import _emit_event -``` - -- [ ] **Step 3: Emit `bulkhead.rejected` event in `Bulkhead.__call__`** - -Find the `except TimeoutError as exc:` block (where `BulkheadFullError` is raised): -```python -except TimeoutError as exc: - raise BulkheadFullError( - max_concurrent=self._max_concurrent, - acquire_timeout=self._acquire_timeout, - ) from exc -``` - -Insert the emit call BEFORE the raise: -```python -except TimeoutError as exc: - _emit_event( - _LOGGER, - "bulkhead.rejected", - level=logging.WARNING, - message=f"bulkhead rejected — full (max_concurrent={self._max_concurrent}, acquire_timeout={self._acquire_timeout})", - attributes={ - "max_concurrent": self._max_concurrent, - "acquire_timeout": self._acquire_timeout, - "method": request.method, - "url": str(request.url), - }, - ) - raise BulkheadFullError( - max_concurrent=self._max_concurrent, - acquire_timeout=self._acquire_timeout, - ) from exc -``` - -- [ ] **Step 4: Run the new test** - -```bash -uv run pytest tests/test_bulkhead.py -v -k "rejected_emits_observability_event" -``` -Expected: PASS. - -- [ ] **Step 5: Lint + full suite** - -```bash -just lint && just test -``` -Expected: clean, 100% coverage. - -- [ ] **Step 6: Stage and commit** - -```bash -git add src/httpware/middleware/resilience/bulkhead.py tests/test_bulkhead.py -git commit -m "feat(bulkhead): emit rejected event via httpware.bulkhead logger + OTel - -One event site: bulkhead.rejected (WARNING) fires immediately before -BulkheadFullError is raised. Attributes: max_concurrent, acquire_timeout, -method, url. - -acquire_timeout=0 (fail-fast) and acquire_timeout>0 (bounded wait) -both flow through this single emission — the attribute value -distinguishes them at consumer-side." -``` - ---- - -## Task 5: Fail-soft tests for OTel-missing - -**Files:** -- Create: `tests/test_optional_extras_otel_missing.py` - -- [ ] **Step 1: Create the file** - -```python -"""Fail-soft tests for the otel optional-extra (0.6.0). - -opentelemetry-api IS installed in the CI test environment via `--all-extras`. -To simulate the "extra not installed" case, patch -`httpware._internal.import_checker.is_otel_installed = False` for the -duration of the test. - -The contract: observability emission (the structured log record half) must -work regardless of whether opentelemetry-api is available. The OTel half is -silently skipped when the flag is False. -""" - -import logging -from unittest.mock import patch - -import pytest - -from httpware._internal.observability import _emit_event - - -_TEST_LOGGER = logging.getLogger("httpware.test.otel_missing") - - -def test_emit_event_logs_record_without_otel(caplog: pytest.LogCaptureFixture) -> None: - """The structured log record is emitted even when opentelemetry-api is 'missing'.""" - with patch("httpware._internal.import_checker.is_otel_installed", False): - with caplog.at_level(logging.WARNING, logger="httpware.test.otel_missing"): - _emit_event( - _TEST_LOGGER, - "test.event", - level=logging.WARNING, - message="works without otel", - attributes={"x": 1}, - ) - - assert len(caplog.records) == 1 - record = caplog.records[0] - assert record.message == "works without otel" - assert record.x == 1 # ty: ignore[unresolved-attribute] - - -def test_emit_event_does_not_call_opentelemetry_apis_when_flag_false() -> None: - """With is_otel_installed=False, no opentelemetry.trace call is made.""" - with ( - patch("httpware._internal.import_checker.is_otel_installed", False), - patch("opentelemetry.trace.get_current_span") as mock_get_span, - ): - _emit_event( - _TEST_LOGGER, - "test.event", - level=logging.WARNING, - message="silent on otel", - attributes={}, - ) - - mock_get_span.assert_not_called() -``` - -- [ ] **Step 2: Run the tests** - -```bash -uv run pytest tests/test_optional_extras_otel_missing.py -v -``` -Expected: both PASS. - -- [ ] **Step 3: Lint + full suite** - -```bash -just lint && just test -``` -Expected: clean, 100% coverage. - -- [ ] **Step 4: Commit** - -```bash -git add tests/test_optional_extras_otel_missing.py -git commit -m "test(optional): observability emission works without otel extra - -Mirrors the existing test_optional_extras_pydantic_missing.py pattern: -patches httpware._internal.import_checker.is_otel_installed to False -to simulate the 'extra not installed' case. Verifies that the -structured-log half of _emit_event still works and that no -opentelemetry.trace.get_current_span call is attempted." -``` - ---- - -## Task 6: Documentation + release notes - -**Files:** -- Modify: `README.md` -- Modify: `docs/index.md` -- Modify: `planning/engineering.md` -- Create: `planning/releases/0.6.0.md` - -- [ ] **Step 1: Add Observability section to README.md** - -After the existing `## Errors` section and BEFORE the link section (`## 🗒️ [Release notes]`), insert: - -```markdown - -## Observability - -`Retry` and `Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed). - -Logger names (`httpware.retry`, `httpware.bulkhead`) and event names (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`, `bulkhead.rejected`) are the stable public contract. - -```python -import logging - -# Enable visibility into retry / bulkhead operational events -logging.getLogger("httpware.retry").setLevel(logging.WARNING) -logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING) -``` - -For OTel attribute enrichment on the active span — install the extra: - -```bash -pip install httpware[otel] -``` - -When installed, `_emit_event` calls `trace.get_current_span().add_event(name, attributes=...)` automatically. We never create our own spans; for HTTP-level tracing install `opentelemetry-instrumentation-httpx` separately. -``` - -- [ ] **Step 2: Update the [all] install line in README.md** - -Find: -```markdown -pip install httpware[all] # everything declared above (pydantic, msgspec) -``` -Replace with: -```markdown -pip install httpware[all] # everything declared above (pydantic, msgspec, otel) -``` - -- [ ] **Step 3: Mirror both additions in `docs/index.md`** - -Same content added at the matching positions. Keep wording verbatim to stay in sync with README. - -- [ ] **Step 4: Update `planning/engineering.md`** - -In §1 (Project intent), append one sentence to the first paragraph (after the streaming sentence added in 0.5.0): - -``` - As of 0.6.0, `Retry` and `Bulkhead` emit operational events via stdlib `logging` records (`httpware.retry` / `httpware.bulkhead` loggers) and — when `opentelemetry-api` is installed — OpenTelemetry span events on the active span. -``` - -In §7 (optional-extras pattern), find the parenthetical: -``` -(An `otel` extra existed pre-0.4 but was removed once we noticed it was advertising functionality that didn't exist. Epic 5 will reintroduce it when the OpenTelemetry middleware actually lands.) -``` -Replace with: -``` -(An `otel` extra existed pre-0.4 but was removed once we noticed it was advertising functionality that didn't exist. 0.6.0 reintroduces it paired with the code that uses it — `Retry` and `Bulkhead` add events to the active OpenTelemetry span via `trace.get_current_span().add_event(...)`.) -``` - -In §8 (Remaining roadmap), find the Epic 5 entry: -``` -- **Epic 5 — Observability:** `5-1` Layer 1 middleware hooks, `5-2` wire into resilience middlewares, `5-4` OpenTelemetry middleware (will declare the `otel` extra at the same time the code lands), `5-5` logging policy CI grep. -``` -Replace with: -``` -- **Epic 5 — Observability:** SHIPPED in v0.6 (PR #...) — re-scoped from the original 4-story plan. `Retry` and `Bulkhead` emit operational events via stdlib `logging` + opt-in OpenTelemetry span events. Stories `5-1` (Layer 1 middleware hooks) and `5-4` (standalone OTel middleware) RETIRED — `opentelemetry-instrumentation-httpx` already covers transport-level tracing; a separate httpware middleware would duplicate it. See [`planning/specs/2026-06-05-observability-design.md`](specs/2026-06-05-observability-design.md) and [`planning/plans/2026-06-05-observability-plan.md`](plans/2026-06-05-observability-plan.md). -``` - -- [ ] **Step 5: Create `planning/releases/0.6.0.md`** - -```markdown -# httpware 0.6.0 — Resilience observability - -**0.6.0 is additive. No breaking changes.** Code written against 0.5.0 continues to work unchanged. - -This release adds operational-event emission to `Retry` and `Bulkhead` via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (opt-in via the `otel` extra). Re-introduces the `otel` extra (PR #24 removed it as YAGNI; this release brings it back paired with the code that uses it). - -## New features - -- **Structured logging on resilience operations.** Acquire `logging.getLogger("httpware.retry")` and `logging.getLogger("httpware.bulkhead")` to see four operational events: - - `retry.giving_up` (WARNING) — max_attempts exhausted; attributes include `attempts`, `method`, `url`, `last_status`, `last_exception_type` - - `retry.budget_refused` (WARNING) — `RetryBudget` refused to permit a retry - - `retry.streaming_refused` (WARNING) — streaming-body marker prevented an otherwise-retryable retry - - `bulkhead.rejected` (WARNING) — `acquire_timeout` elapsed without acquisition; attributes include `max_concurrent`, `acquire_timeout`, `method`, `url` -- **Optional OpenTelemetry attribute enrichment.** Install `httpware[otel]` (which pulls `opentelemetry-api>=1.20`, just the API — you supply the SDK). When installed, the same four events are added to the active span via `trace.get_current_span().add_event(name, attributes=...)`. We never create our own spans — for HTTP-level tracing install `opentelemetry-instrumentation-httpx` separately. - -## Backwards compatibility - -Purely additive: -- All previously-shipping methods behave identically. -- Successful retries and successful bulkhead acquisitions emit nothing — the four events fire only on operational concern. -- Per `engineering.md §2`, httpware never configures handlers, levels, or calls `logging.basicConfig()`. Consumers own their logging configuration. -- The `otel` extra is opt-in — `pip install httpware` continues to work without `opentelemetry-api`. - -## Usage - -```python -import logging -from httpware import AsyncClient, Bulkhead, Retry - -# Enable visibility into retry / bulkhead operational events -logging.getLogger("httpware.retry").setLevel(logging.WARNING) -logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING) - -# Your normal application logging config picks up the records -logging.basicConfig(level=logging.WARNING, format="%(asctime)s %(name)s %(message)s") - -async with AsyncClient( - base_url="https://api.example.com", - middleware=[Bulkhead(max_concurrent=10), Retry()], -) as client: - await client.get("/users/1") - # On a 503 + retry exhaustion you'll see: - # 2026-06-05 12:00:00 httpware.retry retry gave up after 3 attempts -``` - -For OTel span events: - -```bash -pip install httpware[otel] -# Plus your SDK + opentelemetry-instrumentation-httpx for HTTP-level spans -``` - -## What's still ahead - -Epic 5's original `5-1` (hook protocol) and `5-4` (standalone OTel middleware) stories are **retired**, not deferred. Rationale in the spec: `opentelemetry-instrumentation-httpx` already covers transport-level tracing, and a hook system without a built-in consumer is infrastructure for code that doesn't exist. The structured-emission contract we're shipping is already extensible — users plug into standard `logging` handlers without needing httpware-specific hooks. - -This effectively closes Epic 5. Remaining roadmap is Epic 6 (ship v1.0): docs site (mkdocs), benchmarks, Trusted Publishers + Sigstore release flow. - -## References - -- Spec: [`planning/specs/2026-06-05-observability-design.md`](../specs/2026-06-05-observability-design.md) -- Plan: [`planning/plans/2026-06-05-observability-plan.md`](../plans/2026-06-05-observability-plan.md) -- Roadmap: [`planning/engineering.md`](../engineering.md) §8 -``` - -- [ ] **Step 6: Lint** - -```bash -just lint -``` -Expected: clean. - -- [ ] **Step 7: Commit** - -```bash -git add README.md docs/index.md planning/engineering.md planning/releases/0.6.0.md -git commit -m "docs: 0.6.0 release notes + observability docs - -- README + docs/index.md: add 'Observability' section + update [all] - install line to include otel -- planning/engineering.md §1 + §7 + §8: mention observability in - project intent; update otel-extra parenthetical to reflect reintroduction; - mark Epic 5 SHIPPED in roadmap with rationale for retiring 5-1 / 5-4 -- planning/releases/0.6.0.md: new release notes" -``` - ---- - -## Task 7: Final verification + push - -**Files:** none modified; verification only. - -- [ ] **Step 1: Full lint** - -```bash -just lint-ci -``` -Expected: clean. - -- [ ] **Step 2: Full test suite** - -```bash -just test -``` -Expected: 100% coverage. Test count: was 239 (post-streaming). +5 observability tests + 3 retry emission tests + 1 bulkhead emission test + 2 fail-soft tests + 1 isolation test = ~251. - -- [ ] **Step 3: Architecture invariants from `CLAUDE.md`** - -```bash -grep -rE 'httpx2\._' src/httpware/ || echo "PASS: no httpx2 private API" -grep -rE 'from __future__ import annotations' src/httpware/ || echo "PASS: no __future__ annotations" -grep -rE '\bprint\(' src/httpware/ || echo "PASS: no print()" -grep -rE 'logging\.(basicConfig|getLogger)\(\)' src/httpware/ || echo "PASS: no global logging" -grep -rE '# (type|mypy): ignore' src/httpware/ || echo "PASS: no type/mypy ignore" -``` -Each should print PASS. Note: the new code uses `logging.getLogger("httpware.retry")` and `logging.getLogger("httpware.bulkhead")` (with arguments) — the grep checks for `getLogger()` with **no arguments**, so the named loggers don't trip it. - -- [ ] **Step 4: Optional-extras isolation** - -```bash -uv run pytest tests/test_optional_extras_isolation.py -v -``` -Expected: all 3 PASS (msgspec, pydantic, opentelemetry). - -- [ ] **Step 5: mkdocs strict build** - -```bash -uv run --with mkdocs --with mkdocs-material mkdocs build --strict 2>&1 | tail -10 -rm -rf site/ -``` -Expected: 0 warnings. - -- [ ] **Step 6: Push the branch** - -```bash -git push -u origin feat/v0.6-observability -``` - -DO NOT open the PR yet — leave that to `finishing-a-development-branch`. - ---- - -## Out of scope for this plan (per the spec) - -These items are deliberately deferred or retired. Do NOT do them in this PR: - -- **No new spans.** `add_event` augments the existing span; we never call `tracer.start_span()`. -- **No metric instruments** (`Counter`, `Histogram`). Only events/logs. -- **No URL/header redaction at the httpware layer.** `opentelemetry-instrumentation-httpx` and user `logging.Filter`s handle this. -- **No `LogPolicy` middleware or hook protocol** (was Epic 5 story `5-1`). Retired. -- **No public `httpware.observability` namespace.** Logger names + event names ARE the public contract. -- **No retry `attempt_starting` events.** Operational-only event set. -- **No standalone OTel middleware** (was Epic 5 story `5-4`). Retired in favor of `opentelemetry-instrumentation-httpx`. -- **Version bump in `pyproject.toml`.** Tag-driven release; bump not required. diff --git a/planning/changes/2026-06-05.06-extension-slot-docs/design.md b/planning/changes/2026-06-05.06-extension-slot-docs.md similarity index 100% rename from planning/changes/2026-06-05.06-extension-slot-docs/design.md rename to planning/changes/2026-06-05.06-extension-slot-docs.md diff --git a/planning/changes/2026-06-05.06-extension-slot-docs/plan.md b/planning/changes/2026-06-05.06-extension-slot-docs/plan.md deleted file mode 100644 index 5854561..0000000 --- a/planning/changes/2026-06-05.06-extension-slot-docs/plan.md +++ /dev/null @@ -1,532 +0,0 @@ -# Extension-slot docs (0.7.0, Epic 3 story 3-6) 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 `docs/middleware.md` — a user-facing guide to writing custom middleware against `httpware`'s Middleware protocol — plus the four small touchups that hang off it (mkdocs nav, README pointer, docs/index pointer, engineering.md §8 SHIPPED line) and 0.7.0 release notes. Closes Epic 3. - -**Architecture:** Docs-only PR. One new markdown page (~150 lines), four small textual edits to existing files, one new release-notes file. No source code changes. Verification is `mkdocs build --strict` + link resolution + the existing test/lint suites as no-op confirmation. - -**Tech Stack:** Markdown, mkdocs-material (strict build), no source code. - -**Target branch:** `feat/v0.7-middleware-docs`. Create from `main` before Task 1: `git checkout main && git pull && git checkout -b feat/v0.7-middleware-docs`. - -**Source spec:** [`planning/specs/2026-06-05-extension-slot-docs-design.md`](../specs/2026-06-05-extension-slot-docs-design.md). Read the spec's "Background" + "Deliverable" sections before starting — the *why* for non-resilience example choice and Seam-A-only scope lives there. - ---- - -## File structure - -**New files:** -- `docs/middleware.md` — the guide itself (~150 lines) -- `planning/releases/0.7.0.md` — release notes - -**Modified files:** -- `mkdocs.yml` — add nav entry between Quick-Start and Development -- `README.md` — one-sentence pointer in the existing "With resilience middleware" subsection -- `docs/index.md` — one bullet in the existing "Where to go next" section -- `planning/engineering.md` §8 — replace the "**Remaining:** `3-6` extension-slot docs." line under Epic 3 - -**Commit cadence:** one commit per task. Per-task commits keep history reviewable. - ---- - -## Task 1: Branch + create `docs/middleware.md` - -**Files:** -- Create: `docs/middleware.md` - -- [ ] **Step 1: Create the branch** - -```bash -git checkout main && git pull && git checkout -b feat/v0.7-middleware-docs -``` -Expected: switched to a new branch. - -- [ ] **Step 2: Create `docs/middleware.md` with the full content below** - -````markdown -# Writing custom middleware - -`httpware`'s primary extension point is the **Middleware protocol**. Middleware lets you add cross-cutting behavior — request-ID propagation, auth header injection, structured tracing, custom resilience policies, anything that wraps "send a request, get a response" — without subclassing `AsyncClient` or touching the transport. - -The built-in `Retry` and `Bulkhead` middleware are themselves implementations of this protocol; nothing about them is privileged. If you want a circuit breaker, a rate limiter, or a header-injecting auth layer, write a middleware. If your need is per-call (not cross-cutting), pass it through `request.extensions=` instead. - -## The protocol - -Two symbols, both exported from `httpware.middleware`: - -```python -from collections.abc import Awaitable, Callable -from typing import Protocol, TypeAlias, runtime_checkable -import httpx2 - -Next: TypeAlias = Callable[[httpx2.Request], Awaitable[httpx2.Response]] - - -@runtime_checkable -class Middleware(Protocol): - async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: ... -``` - -The chain is composed once at `AsyncClient.__init__` and frozen for the client's lifetime. The first entry in `middleware=[...]` is the outermost layer: when you write `middleware=[Bulkhead(...), Retry()]`, the bulkhead sees every request before the retry layer does, so one slot covers all retry attempts of the same call. - -Calling `await next(request)` forwards to the next layer (or, eventually, to the terminal that hits `httpx2`). You can: - -- **Forward unchanged:** `return await next(request)` -- **Modify the request first:** mutate `request.headers` (or build a replacement) before forwarding -- **Inspect or replace the response:** call `await next(...)`, then act on what comes back -- **Short-circuit:** return a synthesized `httpx2.Response` without calling `next` at all -- **Wrap the call in error handling:** `try: return await next(...) except ...` to translate failures - -Whatever you do, return an `httpx2.Response`. Raising an exception propagates up the chain (Retry catches retryable exceptions; everything else surfaces to the caller). - -## Phase decorators - -For the common cases where you don't need state-keeping on `self` and don't need to wrap the full `await next(...)` call, `httpware.middleware` exports three decorators that turn a single async function into a `Middleware`: - -```python -from httpware.middleware import before_request, after_response, on_error -``` - -| Decorator | Function signature | When to use | -|---|---|---| -| `@before_request` | `async (request) -> request` | Transform the outgoing request (add a header, rewrite a URL). | -| `@after_response` | `async (request, response) -> response` | Transform the incoming response (decode, log, attach metadata). | -| `@on_error` | `async (request, exc) -> response \| None` | Translate or absorb a failure. Return `None` to re-raise. Catches `Exception` (not `BaseException`), so `asyncio.CancelledError` propagates. | - -Brief example — adding an `Authorization` header before every request: - -```python -import httpx2 - -from httpware import AsyncClient -from httpware.middleware import before_request - - -@before_request -async def add_bearer(request: httpx2.Request) -> httpx2.Request: - request.headers["Authorization"] = "Bearer secret-token" - return request - - -async def main() -> None: - async with AsyncClient(base_url="https://api.example.com", middleware=[add_bearer]) as client: - await client.get("/me") -``` - -**Reach for the raw `Middleware` protocol when:** you need instance state (a counter, a CircuitBreaker's open/closed flag), you need to inspect both the request AND its response (e.g., timing), or you need to interleave behavior around the `await next(...)` call (e.g., emit one log line at the start and one at the end). The decorators are a convenience for the cases where a single function suffices. - -## Worked example: request-ID propagation - -A `RequestIdMiddleware` that assigns a per-call UUID, injects it as an outgoing header, and logs it alongside the response status. This is the canonical "trace every request through your distributed system" pattern. - -```python -import logging -import uuid - -import httpx2 - -from httpware import AsyncClient, Retry -from httpware.middleware import Next - - -_LOGGER = logging.getLogger("myapp.request_id") - - -class RequestIdMiddleware: - """Assign a per-call X-Request-Id; log it on response. - - Place OUTSIDE Retry so all attempts of the same call share one ID - (so a single call's retries all surface under the same correlation - key in your logs, and match the URL attribute on httpware.retry's - emitted events). - """ - - def __init__(self, *, header: str = "X-Request-Id") -> None: - self._header = header - - async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002 - request_id = str(uuid.uuid4()) - request.headers[self._header] = request_id - response = await next(request) - _LOGGER.info( - "request complete", - extra={"request_id": request_id, "status": response.status_code}, - ) - return response - - -async def main() -> None: - async with AsyncClient( - base_url="https://api.example.com", - middleware=[RequestIdMiddleware(), Retry()], # ID outside Retry - ) as client: - await client.get("/users/1") -``` - -A note on logger names: the example logs under `myapp.request_id`, NOT under `httpware.*`. The `httpware.*` namespace is reserved for events emitted by the library itself (see [Observability](index.md#observability) — `httpware.retry` and `httpware.bulkhead` are stable contracts). Consumer middleware should use your application's own logger namespace. - -The example pairs naturally with the 0.6.0 observability events: a `httpware.retry` `retry.giving_up` log record carries a `url` attribute, and your `RequestIdMiddleware` set an `X-Request-Id` for that same call. Correlate the two in your log aggregator and you have end-to-end visibility from "this user's request" to "we gave up after N retries." - -## When NOT to write a middleware - -- **Redaction:** Use a `logging.Filter` on the consumer side. `httpware` deliberately does no redaction in-library (per the 0.6.0 observability design). -- **URL or header validation:** `httpx2` owns it. Don't reimplement. -- **Per-call behavior that doesn't apply to other calls:** Pass through `request.extensions=` (or the `extensions=` kwarg at the call site) instead. Middleware exists for *cross-cutting* concerns. -- **HTTP-level span creation for tracing:** Install `opentelemetry-instrumentation-httpx` instead of writing an OTel middleware in httpware. We retired story `5-4` (standalone OTel middleware) for this reason — `opentelemetry-instrumentation-httpx` already covers transport-level tracing, and a separate httpware layer would duplicate it. See `planning/engineering.md` §8. - -## See also - -- **`planning/engineering.md` §3 (Seam A)** — the formal protocol contract and why the chain is frozen at construction. -- **`src/httpware/middleware/resilience/`** — `Retry`, `Bulkhead`, `RetryBudget` as real-world consumers of this exact protocol. -- **[Quick-Start composition example](index.md#with-resilience-middleware)** — composing built-in middleware. -```` - -- [ ] **Step 3: Commit** - -```bash -git add docs/middleware.md -git commit -m "docs(middleware): write custom-middleware guide (3-6) - -New docs/middleware.md covering: -- The Middleware Protocol + Next type, exported from httpware.middleware -- Phase decorators (@before_request, @after_response, @on_error) as - ergonomic shortcuts for the no-state-keeping cases -- Worked example: a RequestIdMiddleware that assigns a per-call UUID - via X-Request-Id and logs it alongside the response status. Placed - outside Retry on purpose so all attempts of the same call share one - ID and correlate with the 0.6.0 observability events' url attribute -- 'When NOT to write a middleware' section covering redaction (use a - logging.Filter), URL/header validation (httpx2 owns it), per-call - behavior (use request.extensions=), and HTTP-tracing (install - opentelemetry-instrumentation-httpx instead) - -Closes the deferred-tutorial half of story 3-6. See spec at -planning/specs/2026-06-05-extension-slot-docs-design.md." -``` - ---- - -## Task 2: Add nav entry to `mkdocs.yml` + verify strict build - -**Files:** -- Modify: `mkdocs.yml` - -- [ ] **Step 1: Add nav entry** - -The current `nav:` block reads: -```yaml -nav: - - Quick-Start: index.md - - Development: - - Contributing: dev/contributing.md -``` - -Change to: -```yaml -nav: - - Quick-Start: index.md - - Middleware: middleware.md - - Development: - - Contributing: dev/contributing.md -``` - -- [ ] **Step 2: Verify mkdocs strict build is clean** - -```bash -uv run --with mkdocs --with mkdocs-material mkdocs build --strict 2>&1 | tail -20 -``` -Expected: `Documentation built in