From 36c0484d2492b78af1d64e8b07ec02bb7fc7ed49 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:15:13 +0000 Subject: [PATCH 01/12] docs(net): hyper backend slice design (leaf + build + TokioTimer) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-07-05-net-http-hyper-backend-design.md | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-05-net-http-hyper-backend-design.md diff --git a/docs/superpowers/specs/2026-07-05-net-http-hyper-backend-design.md b/docs/superpowers/specs/2026-07-05-net-http-hyper-backend-design.md new file mode 100644 index 0000000..5f7c0c9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-net-http-hyper-backend-design.md @@ -0,0 +1,277 @@ +# net-http hyper backend — `hyper_leaf` + `build()` + `TokioTimer` — design + +**Status:** Approved design, pre-implementation. +**Date:** 2026-07-05. +**Crate:** `oath-adapter-net-http-hyper` (`crates/adapter/net/http/hyper`) — **new**. +**Slice:** hyper-backend slice. Delivered as **two PRs** (PR A: transport; PR B: +buffering) — see [Delivery](#delivery-two-prs). + +## Context + +The [`stack()` assembly slice](2026-07-05-net-http-stack-assembly-design.md) (#88) +composes the canonical resilience stack — `Tracing → CircuitBreaker → Retry → +RateLimit → Timeout → SetHeaders → Auth → leaf` (ADR-0031 §1) — over an **arbitrary +`HttpClient` leaf** and returns `Result`. Everything above the leaf, plus `TokioTimer`, was the last +runtime-free half; this slice supplies the runtime/TLS half it deferred: + +| Deferred by #88 | Delivered here | +| --- | --- | +| `build()`, `hyper_leaf(conn)`, `ConnConfig` | `build() = stack(hyper_leaf(conn), …)` over the #88 assembly | +| `TokioTimer`, rustls/HTTPS connector, `hyper::Error → HttpError` | the real `Timer` + the pooled HTTPS leaf + anti-corruption error map | +| `BufferOrStream` (leaf-side buffered-xor-streaming) | PR B — the `Buffered` arm on the leaf | + +This is the **first crate to own `hyper`/`tokio`/`rustls`** — ADR-0030 §7's +containment boundary. It adds no runtime dep to any existing crate; the leaf sits +behind the `HttpClient` seam (ADR-0030 §6), so a future `net-http-reqwest` is zero +churn to the stack. + +### Governing decisions (inherited, not re-litigated) + +- **Leaf = hyper-util pooled client over rustls, not reqwest** — [ADR-0030 §7]. +- **`HttpClient` is a blanket-impl'd `Service` sub-trait** — [ADR-0030 §6]: a backend + implements `Service` once and is `HttpClient` for free **once the body error is + normalized to `HttpError`**. +- **`build()`/`stack()` split + return bound** — construction-surface spec, Seam #3; + `build()` is a one-line delegation so the ordering invariants stay tested in #88. +- **`ResponseBody` (buffered *xor* streaming), `BufferMode`, `Guarded`** already + ship in `net-http-api`. `Guarded` (the concurrency permit) is attached **by the + `RateLimit` layer** (`Response = http::Response>`), *not* the leaf. + +### Resolved decisions (this slice) + +Four choices were open within §7's fixed frame; all resolved here: + +- **Crypto provider — `aws-lc-rs`** (the rustls 0.23 default; FIPS-capable). +- **Trust anchors — `webpki-roots`** (bundled Mozilla roots; reproducible, + container-friendly, no OS trust-store dependency). +- **`ConnConfig` — three knobs**: `pool_max_idle_per_host`, `pool_idle_timeout`, + `connect_timeout`. ALPN auto-negotiates `h2`+`http/1.1`; `TCP_NODELAY` on. +- **Leaf testing — loopback + TLS**: plain-HTTP loopback for round-trip/error paths, + plus a self-signed (`rcgen`) rustls loopback exercising the real aws-lc-rs/webpki + handshake in CI. + +## Goal + +Deliver `TokioTimer`, `hyper_leaf(conn)`/`ConnConfig`, and `build()` so a real, +pooled, TLS-terminating HTTP client can be assembled through #88's `stack()` — with +the leaf's socket round-trip, body streaming, and `hyper::Error → HttpError` mapping +regression-tested deterministically over a loopback server (plain-HTTP + self-signed +TLS), no external network. + +## Scope (in) + +- **New crate** `oath-adapter-net-http-hyper` + workspace-member/dep wiring. +- **`TokioTimer`** — the real `oath_adapter_net_api::Timer` impl. +- **`HyperLeaf`** — `Service>` over a `hyper_util` `legacy::Client` + on a rustls HTTPS connector; blanket-impls `HttpClient`. +- **`ConnConfig`**, **`hyper_leaf(conn) -> HyperLeaf`**. +- **`build(…) -> Result`** = `stack(hyper_leaf + (conn), …)`. +- **`map_hyper_err`** — the `hyper`/`legacy::Error → HttpError` anti-corruption map. +- **PR B**: `BufferMode`-driven `Buffered` arm on the leaf. +- Loopback tests (plain-HTTP + TLS), ADR-0030 §7 amendment, CHANGELOG. + +## Non-goals (deferred) + +| Deferred item | Why | Lands with | +| --- | --- | --- | +| `serde` on `HttpConfig`/`ConnConfig` | Config deserialisation is an adapter concern (ADR-0003) | IBKR adapter slice | +| HTTP-version override, TCP keepalive, proxy | YAGNI — a future field is a deliberate reviewed breaking change | when a caller needs one | +| `rustls-native-certs` / OS trust store | webpki-roots chosen for reproducibility | not planned | +| A concrete `RateKey`/endpoint set | Backend-agnostic; the leaf is scheme/host-agnostic | IBKR adapter slice | + +## Decisions + +### `TokioTimer` — the real `Timer` + +```rust +/// The tokio-backed [`Timer`]: `sleep` is `tokio::time::sleep`. `Clone + Send + Sync`, +/// zero-sized — the resilience layers hold it by value across attempts. +#[derive(Debug, Clone, Copy, Default)] +pub struct TokioTimer; + +impl Timer for TokioTimer { + fn sleep(&self, dur: Duration) -> impl Future + Send { + tokio::time::sleep(dur) + } +} +``` + +> **Name collision, deliberately managed.** `hyper_util::rt::TokioTimer` implements +> *hyper's* `hyper::rt::Timer` (used only for the connection pool's internal idle +> timers) and is a **different** type from this `TokioTimer`, which implements *oath's* +> `Timer` and feeds the resilience layers. hyper-util's is imported under an alias +> (`use hyper_util::rt::TokioTimer as HyperPoolTimer;`) at the one wiring site. + +### `HyperBody` — normalize `Incoming`'s error to `HttpError` + +ADR-0030 §6 requires the leaf body's `Error = HttpError`. `Incoming`'s native error is +`hyper::Error`, so the leaf wraps it with http-body-util's `MapErr` combinator — no +hand-rolled, pin-projected body, no `unsafe`: + +```rust +/// The leaf response body: hyper's `Incoming` with its `hyper::Error` normalized to +/// `HttpError` (ADR-0030 §6). `map_hyper_err` is a named `fn` so the type is nameable. +pub type HyperBody = MapErr HttpError>; +``` + +### `HyperLeaf` — the `Service` leaf + +```rust +/// The hyper backend leaf: a pooled hyper-util client over a rustls HTTPS connector. +/// `Clone` (the pool is `Arc`-shared) so the whole `stack()` is `Clone`. +#[derive(Clone)] +pub struct HyperLeaf { + client: Client, Full>, +} + +impl Service> for HyperLeaf { + type Response = http::Response>; + type Error = HttpError; + + fn call( + &self, + req: http::Request, + ) -> impl Future> + Send { + let client = self.client.clone(); + async move { + // Bytes → Full (a one-frame request body). + let (parts, body) = req.into_parts(); + let req = http::Request::from_parts(parts, Full::new(body)); + // Drive the pooled client; normalize the send error. + let resp = client.request(req).await.map_err(map_legacy_err)?; + // Wrap the Incoming body: normalize its error, then Streaming (PR A) — + // PR B adds the BufferMode::Buffer branch here. + let (parts, incoming) = resp.into_parts(); + let body = ResponseBody::streaming(incoming.map_err(map_hyper_err as fn(_) -> _)); + Ok(http::Response::from_parts(parts, body)) + } + } +} +``` + +`type Response = http::Response>` **already in PR A** (always +the `Streaming` arm) — so `RateLimit` wraps it into the canonical +`Guarded>` and PR B adds only the `Buffered` arm, changing no +types. The blanket impl (ADR-0030 §6) makes `HyperLeaf: HttpClient` for free. + +### `ConnConfig` + `hyper_leaf` + +```rust +/// Connection-pool + connector configuration for the hyper leaf. Plain data (no +/// `serde`, no type parameter) — like `HttpConfig`, adapters construct it directly. +#[derive(Debug, Clone)] +pub struct ConnConfig { + /// Max idle pooled connections retained per host. + pub pool_max_idle_per_host: usize, + /// How long an idle pooled connection is retained before eviction. + pub pool_idle_timeout: Duration, + /// Bound on TCP connect + TLS handshake. Fires fast on a dead host, independent of + /// (and tighter than) the per-attempt `Timeout` layer, which bounds the whole send. + pub connect_timeout: Duration, +} + +/// Construct the pooled HTTPS leaf: `HttpConnector` (connect timeout, nodelay) → rustls +/// `HttpsConnector` (aws-lc-rs provider, webpki-roots, ALPN h2+http/1.1) → pooled +/// `legacy::Client` on a `TokioExecutor`. +pub fn hyper_leaf(conn: ConnConfig) -> HyperLeaf { /* … */ } +``` + +Connector wiring: `HttpConnector::new()`, `enforce_http(false)` (so the HTTPS wrapper +handles `https://`), `set_connect_timeout(Some(conn.connect_timeout))`, +`set_nodelay(true)`; wrapped by `hyper_rustls::HttpsConnectorBuilder` with a rustls +`ClientConfig` (aws-lc-rs `default_provider`, `webpki_roots::TLS_SERVER_ROOTS`), +`.https_or_http()`, ALPN `h2`+`http/1.1`. Client: +`Client::builder(TokioExecutor::new()).timer(HyperPoolTimer::new()).pool_idle_timeout +(conn.pool_idle_timeout).pool_max_idle_per_host(conn.pool_max_idle_per_host).build +(connector)`. + +### Error mapping — anti-corruption + +The send `Result` and the body carry **distinct** error types (`hyper_util`'s +`legacy::Error` vs `hyper::Error`), so there are two thin mappers — `map_legacy_err` +(send) and `map_hyper_err` (body) — sharing the classification below. No panics +(CLAUDE.md); no `Timeout` mapping — semantic timeout is the `Timeout` *layer*; a +connect-timeout is a connection *failure*: + +| Source condition | `HttpError` | +| --- | --- | +| `legacy::Error::is_connect()` (DNS/TCP/TLS/handshake), incl. connect-timeout expiry | `Connection(e)` — matches the variant's documented "DNS, TCP, TLS, backend transport" | +| any other send error (protocol, canceled) | `Other(e)` — "network error" | +| mid-stream body `hyper::Error` | `Other(e)` | + +### `build()` — one-line delegation + +```rust +/// Assemble the canonical resilience stack over a fresh pooled hyper leaf. +/// +/// `build(cfg, timer, auth, rate_limits, conn) = stack(hyper_leaf(conn), cfg, timer, +/// auth, rate_limits)`. Bounds mirror `stack()` exactly. +/// +/// # Errors +/// [`BuildError`] from `stack()` if `rate_limits` is not total over `K::all()`, a +/// policy is out of range, or the concurrency-singleton invariant is breached. +pub fn build( + cfg: HttpConfig, + timer: T, + auth: A, + rate_limits: RateLimitConfig, + conn: ConnConfig, +) -> Result +where + T: Timer + 'static, + A: AuthSource + 'static, + K: RateKey + fmt::Debug, +{ + stack(hyper_leaf(conn), cfg, timer, auth, rate_limits) +} +``` + +`HyperBody: Send` (`Incoming` is `Send`), satisfying `stack()`'s `S::Body: Send`. + +## Delivery: two PRs + +**PR A — transport (`feat/net-http-hyper`).** New crate + deps; `TokioTimer`; +`HyperBody`/`HyperLeaf`/`ConnConfig`/`hyper_leaf`; `map_hyper_err`; `build()` (leaf +streams only); loopback tests (plain-HTTP + self-signed TLS). CI-green, `build()` +fully functional for streaming responses. + +**PR B — buffering (`feat/net-http-hyper-buffer`, off `main` after PR A merges).** +The leaf reads `req.extensions().get::()` (default `Stream`, ADR-0030 §4); +`Buffer` → `Incoming.collect().await` → `ResponseBody::buffered(bytes)`. Additive only: +no signature/type/layer change. Tests: a buffered request returns a `Buffered` body of +exact length; a streaming request is unchanged. + +## Testing + +Deterministic, no external network (dev-deps: `rcgen`, `tokio` `rt`+`macros`+`net`, +`tracing-subscriber`): + +- **Plain-HTTP loopback** — a `hyper` server on `127.0.0.1:0`: `send` round-trips a + body; a server that aborts mid-response → `HttpError::Other`; an unroutable/black-hole + address → `connect_timeout` fires → `HttpError::Connection`. +- **TLS loopback** — `rcgen` self-signed cert; a rustls loopback server + a test client + whose `HttpsConnector` roots include that cert: exercises the real aws-lc-rs/webpki + handshake and a successful `https://` round-trip in CI. +- **`TokioTimer`** — `sleep(d)` elapses ≈ `d` (paused tokio time). +- **`build()` smoke** — assembles over a trivial single-variant `RateLimitConfig` and + round-trips against the loopback; a `RateLimitConfig` missing coverage → `BuildError` + (proves delegation to `stack()`'s boot check). +- **Optional `#[ignore]` live-https smoke** — one real host, for manual runs only. + +## Docs + +- **ADR-0030 §7** — light amendment recording the resolved provider (`aws-lc-rs`), + roots (`webpki-roots`), and the `connect_timeout` knob. No new ADR; all within §7's + fixed decision. +- **CHANGELOG** `[Unreleased]` — one entry per PR. +- Crate + item rustdoc; `just doc` in per-task verification (net-http rule). + +## Risks / open points + +- **`cargo-deny` first contact with the hyper/rustls/aws-lc tree.** New advisories or + license/ban hits surface here first. Mitigation: run `just deny` early in PR A; a + hit is a manifest/allowlist decision, not a design change. +- **aws-lc-rs build toolchain in CI.** `aws-lc-sys` needs a C toolchain (cmake, maybe + nasm). The devcontainer/CI image likely already has it; verify in PR A's first CI run. From a507402b690680be576362853e4af9c1f47af677 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:23:19 +0000 Subject: [PATCH 02/12] docs(net): hyper backend PR A implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-05-net-http-hyper-backend-pr-a.md | 954 ++++++++++++++++++ 1 file changed, 954 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-net-http-hyper-backend-pr-a.md diff --git a/docs/superpowers/plans/2026-07-05-net-http-hyper-backend-pr-a.md b/docs/superpowers/plans/2026-07-05-net-http-hyper-backend-pr-a.md new file mode 100644 index 0000000..87b0b86 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-net-http-hyper-backend-pr-a.md @@ -0,0 +1,954 @@ +# net-http hyper backend — PR A (transport) 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:** Create the `oath-adapter-net-http-hyper` crate delivering `TokioTimer`, the pooled TLS hyper leaf (`hyper_leaf`/`ConnConfig`/`HyperLeaf`), the `hyper → HttpError` mapping, and `build()` (streaming responses), assembled through #88's `stack()`. + +**Architecture:** A new backend crate — the first to own `hyper`/`tokio`/`rustls` (ADR-0030 §7). `HyperLeaf` implements `Service>` over a `hyper_util` pooled `legacy::Client` on a rustls HTTPS connector; the blanket impl (ADR-0030 §6) makes it `HttpClient`. `build()` is a one-line delegation to `stack(hyper_leaf(conn), …)`. Response bodies always stream in PR A (`ResponseBody::streaming`); buffering is PR B. + +**Tech Stack:** Rust 2024, `hyper` 1, `hyper-util` 0.1 (pooled `legacy::Client`, `TokioExecutor`), `hyper-rustls` 0.27 (aws-lc-rs + webpki-roots), `http-body-util` 0.1 (`Full`, `MapErr`), `tokio` 1. Tests: loopback `hyper` servers (plain + rustls), `rcgen` 0.13 self-signed certs. + +**Spec:** [docs/superpowers/specs/2026-07-05-net-http-hyper-backend-design.md](../specs/2026-07-05-net-http-hyper-backend-design.md) + +## Global Constraints + +- **Edition 2024, MSRV 1.90.** Validate with `just msrv`. +- **No `unsafe`** (`unsafe_code = "deny"` workspace-wide). +- **No `unwrap`/`expect`/indexing in non-test code** (warned) — return `Result`, model errors with `thiserror`. Test code is exempt. +- **`missing_docs` warned** — every `pub` item gets a doc comment. Clippy `all` is **deny-level**; `pedantic`/`nursery` warn. +- **Definition of done = `just ci` passes** (fmt, lint, test, doc, deny, typos). Per net-http rule, run **`just doc`** in each task's checks — `check`/`lint`/`test` miss broken rustdoc intra-doc links. +- **Conventional Commits**, enforced by the `commit-msg` hook; **subject ≤ 72 chars**. End every commit message with: + `Co-Authored-By: Claude Opus 4.8 (1M context) ` +- **Worktree:** all work in `.claude/worktrees/net-http-hyper` on branch `feat/net-http-hyper` (already created off `main`; the spec commit is already there). Never touch the primary checkout's branch. +- **Dependency direction:** this crate depends on `oath-adapter-net-api` (Timer) and `oath-adapter-net-http-api` (everything else) — never the reverse. It is the sole owner of `hyper`/`tokio`/`rustls` production deps. +- **External-API note:** exact builder-method names in `hyper-util`/`hyper-rustls`/`rcgen`/`rustls` may vary by patch release. The code below is the known-good shape for the pinned majors; each TDD step ends with `just check`, which surfaces any drift immediately — resolve against `cargo doc -p --open` for the resolved version, keeping the documented behaviour identical. + +--- + +### Task 1: Crate scaffold + workspace wiring + dependencies + +Creates the empty crate, registers it as a workspace member, and pins the new external deps. Deliverable: the workspace compiles with an empty `oath-adapter-net-http-hyper`. + +**Files:** +- Create: `crates/adapter/net/http/hyper/Cargo.toml` +- Create: `crates/adapter/net/http/hyper/src/lib.rs` +- Modify: `Cargo.toml` (root — `[workspace] members`, `[workspace.dependencies]`) + +**Interfaces:** +- Consumes: nothing (scaffold). +- Produces: crate `oath-adapter-net-http-hyper`, importable as `oath_adapter_net_http_hyper`. + +- [ ] **Step 1: Register the crate as a workspace member** + +In root `Cargo.toml`, add to the `[workspace] members` list (keep it grouped with the other `crates/adapter/net/http/*` entries, alphabetical): + +```toml + "crates/adapter/net/http/hyper", +``` + +- [ ] **Step 2: Add the internal dep pin + external dep pins** + +In root `Cargo.toml` `[workspace.dependencies]`, add the internal-crate pin next to the other `oath-adapter-net-http-*` lines: + +```toml +oath-adapter-net-http-hyper = { path = "crates/adapter/net/http/hyper", version = "0.1.0" } +``` + +Then, in the same table, add the external pins (backend-specific deps live here so the whole workspace pins one version; `cargo-deny` sees them here first): + +```toml +hyper = { version = "1", features = ["client", "http1", "http2"] } +hyper-util = { version = "0.1", features = ["client", "client-legacy", "http1", "http2", "tokio"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "aws-lc-rs", "webpki-roots"] } +rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] } +rcgen = "0.13" +``` + +- [ ] **Step 3: Write the crate manifest** + +Create `crates/adapter/net/http/hyper/Cargo.toml`: + +```toml +[package] +name = "oath-adapter-net-http-hyper" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +oath-adapter-net-api = { workspace = true } +oath-adapter-net-http-api = { workspace = true } +bytes = { workspace = true } +http = { workspace = true } +http-body-util = { workspace = true } +hyper = { workspace = true } +hyper-util = { workspace = true } +hyper-rustls = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +rustls = { workspace = true } +rcgen = { workspace = true } +tracing-subscriber = { workspace = true } + +[lints] +workspace = true +``` + +- [ ] **Step 4: Write the crate root** + +Create `crates/adapter/net/http/hyper/src/lib.rs`: + +```rust +//! The hyper backend for the OATH HTTP stack: a pooled, TLS-terminating leaf and +//! the `build()` construction surface (ADR-0030 §7). +//! +//! This is the only crate that depends on `hyper`/`tokio`/`rustls`. [`build`] +//! assembles the canonical resilience stack (`oath_adapter_net_http_api::stack`) +//! over a fresh [`hyper_leaf`], so backend choice stays behind the `HttpClient` +//! seam (ADR-0030 §6). +``` + +- [ ] **Step 5: Verify the workspace compiles** + +Run: `just check` +Expected: PASS — `oath-adapter-net-http-hyper` compiles (empty), no new warnings. + +- [ ] **Step 6: Commit** + +```bash +git add Cargo.toml crates/adapter/net/http/hyper/ +git commit -m "feat(net): scaffold oath-adapter-net-http-hyper crate" +``` + +--- + +### Task 2: `TokioTimer` — the real `Timer` + +The tokio-backed `oath_adapter_net_api::Timer` the resilience layers run on. + +**Files:** +- Create: `crates/adapter/net/http/hyper/src/timer.rs` +- Modify: `crates/adapter/net/http/hyper/src/lib.rs` + +**Interfaces:** +- Consumes: `oath_adapter_net_api::Timer` (`fn sleep(&self, Duration) -> impl Future + Send`; supertrait `Clone + Send + Sync`). +- Produces: `pub struct TokioTimer;` implementing `Timer`. + +- [ ] **Step 1: Write the failing test** + +Create `crates/adapter/net/http/hyper/src/timer.rs`: + +```rust +//! [`TokioTimer`] — the tokio-backed [`Timer`] the resilience stack sleeps on. + +use oath_adapter_net_api::Timer; +use std::future::Future; +use std::time::Duration; + +/// The tokio-backed [`Timer`]: [`sleep`](Timer::sleep) is `tokio::time::sleep`. +/// +/// Zero-sized and `Copy` — the resilience layers hold it by value and clone it +/// across attempts at no cost. +#[derive(Debug, Clone, Copy, Default)] +pub struct TokioTimer; + +impl Timer for TokioTimer { + fn sleep(&self, dur: Duration) -> impl Future + Send { + tokio::time::sleep(dur) + } +} + +#[cfg(test)] +mod tests { + use super::TokioTimer; + use oath_adapter_net_api::Timer; + use std::time::Duration; + + #[tokio::test(start_paused = true)] + async fn sleep_elapses_the_requested_duration() { + let start = tokio::time::Instant::now(); + TokioTimer.sleep(Duration::from_secs(5)).await; + assert_eq!(start.elapsed(), Duration::from_secs(5)); + } +} +``` + +Add to `crates/adapter/net/http/hyper/src/lib.rs`: + +```rust +pub mod timer; + +pub use timer::TokioTimer; +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `just test` +Expected: PASS — `sleep_elapses_the_requested_duration` (paused tokio time advances by exactly 5s). (This task's impl and test are written together because the impl is a two-line trait forward; the test is the real gate.) + +- [ ] **Step 3: Verify lint + docs** + +Run: `just lint && just doc` +Expected: PASS — no warnings, rustdoc links resolve. + +- [ ] **Step 4: Commit** + +```bash +git add crates/adapter/net/http/hyper/src/timer.rs crates/adapter/net/http/hyper/src/lib.rs +git commit -m "feat(net): TokioTimer — the tokio-backed Timer" +``` + +--- + +### Task 3: The hyper leaf — happy-path round-trip + +`HyperBody`, `ConnConfig`, `HyperLeaf`, `hyper_leaf`, and the error mappers, proven by a plain-HTTP loopback round-trip. + +**Files:** +- Create: `crates/adapter/net/http/hyper/src/error.rs` (the `hyper → HttpError` mappers) +- Create: `crates/adapter/net/http/hyper/src/leaf.rs` +- Modify: `crates/adapter/net/http/hyper/src/lib.rs` + +**Interfaces:** +- Consumes: `oath_adapter_net_http_api::{HttpClient, HttpError, ResponseBody}`; `oath_adapter_net_http_api::Service`; `http`, `bytes::Bytes`, `http_body_util::{Full, combinators::MapErr, BodyExt}`; `hyper::body::Incoming`; `hyper_util::client::legacy::{Client, connect::HttpConnector}`; `hyper_util::rt::{TokioExecutor, TokioTimer as HyperPoolTimer}`; `hyper_rustls::HttpsConnector`. +- Produces: + - `pub type HyperBody = MapErr HttpError>;` + - `pub struct ConnConfig { pub pool_max_idle_per_host: usize, pub pool_idle_timeout: Duration, pub connect_timeout: Duration }` + - `pub struct HyperLeaf` — `Service, Response = http::Response>, Error = HttpError>` (⇒ `HttpClient` by blanket impl) + - `pub fn hyper_leaf(conn: ConnConfig) -> HyperLeaf` + - `pub(crate) fn map_legacy_err(e: hyper_util::client::legacy::Error) -> HttpError` + - `pub(crate) fn map_hyper_err(e: hyper::Error) -> HttpError` + +- [ ] **Step 1: Write the error mappers** + +Create `crates/adapter/net/http/hyper/src/error.rs`: + +```rust +//! Anti-corruption: normalize `hyper`/`hyper-util` errors to [`HttpError`] +//! (ADR-0030 §6). Connect-phase failures (DNS/TCP/TLS/handshake, incl. +//! connect-timeout) map to [`HttpError::Connection`]; everything else — protocol +//! errors, cancellation, and mid-stream body errors — maps to [`HttpError::Other`] +//! ("network error"). No `Timeout`: semantic timeout is the `Timeout` *layer*. + +use oath_adapter_net_http_api::HttpError; + +/// Map a `hyper_util` client send error to [`HttpError`]. +pub(crate) fn map_legacy_err(e: hyper_util::client::legacy::Error) -> HttpError { + if e.is_connect() { + HttpError::connection(e) + } else { + HttpError::other(e) + } +} + +/// Map a `hyper` body/protocol error to [`HttpError`]. Body errors surface after +/// the response head, so there is no connect phase to distinguish — always +/// [`HttpError::Other`]. +pub(crate) fn map_hyper_err(e: hyper::Error) -> HttpError { + HttpError::other(e) +} +``` + +- [ ] **Step 2: Write the failing round-trip test + the leaf** + +Create `crates/adapter/net/http/hyper/src/leaf.rs`: + +```rust +//! The hyper backend leaf: a pooled `hyper_util` client over a rustls HTTPS +//! connector. Implements [`Service`], so it is an [`HttpClient`] by blanket impl +//! (ADR-0030 §6). Response bodies stream (PR A); buffering is PR B. + +use crate::error::{map_hyper_err, map_legacy_err}; +use bytes::Bytes; +use http_body_util::combinators::MapErr; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper_rustls::HttpsConnector; +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::client::legacy::Client; +use hyper_util::rt::{TokioExecutor, TokioTimer as HyperPoolTimer}; +use oath_adapter_net_api::Service; +use oath_adapter_net_http_api::{HttpError, ResponseBody}; +use std::future::Future; +use std::time::Duration; + +/// The leaf response body: hyper's `Incoming` with its `hyper::Error` normalized +/// to [`HttpError`] (ADR-0030 §6). `map_hyper_err` is a named `fn` so the type is +/// nameable in [`HyperLeaf`]'s associated type. +pub type HyperBody = MapErr HttpError>; + +/// Connection-pool + connector configuration for [`hyper_leaf`]. Plain data — no +/// `serde`, no type parameter (like `HttpConfig`); adapters construct it directly. +#[derive(Debug, Clone)] +pub struct ConnConfig { + /// Max idle pooled connections retained per host. + pub pool_max_idle_per_host: usize, + /// How long an idle pooled connection is retained before eviction. + pub pool_idle_timeout: Duration, + /// Bound on TCP connect + TLS handshake — fails fast on a dead host, + /// independent of (and tighter than) the per-attempt `Timeout` layer. + pub connect_timeout: Duration, +} + +/// The hyper backend leaf. `Clone` (the pool is `Arc`-shared internally), so the +/// whole assembled `stack()` is `Clone`. +#[derive(Clone)] +pub struct HyperLeaf { + client: Client, Full>, +} + +impl std::fmt::Debug for HyperLeaf { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HyperLeaf").finish_non_exhaustive() + } +} + +impl Service> for HyperLeaf { + type Response = http::Response>; + type Error = HttpError; + + #[allow(clippy::manual_async_fn)] + fn call( + &self, + req: http::Request, + ) -> impl Future> + Send { + let client = self.client.clone(); + async move { + let (parts, body) = req.into_parts(); + let req = http::Request::from_parts(parts, Full::new(body)); + let resp = client.request(req).await.map_err(map_legacy_err)?; + let (parts, incoming) = resp.into_parts(); + let mapper: fn(hyper::Error) -> HttpError = map_hyper_err; + let body = ResponseBody::streaming(incoming.map_err(mapper)); + Ok(http::Response::from_parts(parts, body)) + } + } +} + +/// Construct the pooled HTTPS leaf: an `HttpConnector` (connect timeout, nodelay) +/// wrapped by a rustls `HttpsConnector` (aws-lc-rs, webpki-roots, ALPN +/// h2+http/1.1), driven by a pooled `legacy::Client` on a `TokioExecutor`. +#[must_use] +pub fn hyper_leaf(conn: ConnConfig) -> HyperLeaf { + let mut http = HttpConnector::new(); + http.enforce_http(false); // let the HTTPS wrapper handle `https://` + http.set_connect_timeout(Some(conn.connect_timeout)); + http.set_nodelay(true); + + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_webpki_roots() + .https_or_http() + .enable_http1() + .enable_http2() + .wrap_connector(http); + + let client = Client::builder(TokioExecutor::new()) + .timer(HyperPoolTimer::new()) + .pool_idle_timeout(conn.pool_idle_timeout) + .pool_max_idle_per_host(conn.pool_max_idle_per_host) + .build(https); + + HyperLeaf { client } +} + +#[cfg(test)] +mod tests { + use super::{hyper_leaf, ConnConfig, HyperLeaf}; + use bytes::Bytes; + use http_body_util::BodyExt; + use oath_adapter_net_api::Service; + use std::convert::Infallible; + use std::time::Duration; + use tokio::net::TcpListener; + + fn test_conn() -> ConnConfig { + ConnConfig { + pool_max_idle_per_host: 4, + pool_idle_timeout: Duration::from_secs(30), + connect_timeout: Duration::from_secs(2), + } + } + + // Spawn a one-connection plain-HTTP hyper server that echoes a fixed body. + // Returns the bound `http://127.0.0.1:PORT` base URL. + async fn spawn_echo_server(reply: &'static [u8]) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let io = hyper_util::rt::TokioIo::new(stream); + tokio::spawn(async move { + let svc = hyper::service::service_fn(move |_req| async move { + Ok::<_, Infallible>(hyper::Response::new( + http_body_util::Full::new(Bytes::from_static(reply)), + )) + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(io, svc) + .await; + }); + } + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn leaf_round_trips_a_plain_http_body() { + let base = spawn_echo_server(b"pong").await; + let leaf: HyperLeaf = hyper_leaf(test_conn()); + let req = http::Request::get(format!("{base}/ping")) + .body(Bytes::new()) + .unwrap(); + + let resp = leaf.call(req).await.expect("round-trip"); + assert_eq!(resp.status(), http::StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::from_static(b"pong")); + } +} +``` + +Add to `crates/adapter/net/http/hyper/src/lib.rs`: + +```rust +mod error; +pub mod leaf; + +pub use leaf::{hyper_leaf, ConnConfig, HyperBody, HyperLeaf}; +``` + +- [ ] **Step 3: Run the test to verify it fails, then passes** + +Run: `just check` +Expected: compiles (resolve any external builder-method drift per the Global Constraints note). +Run: `just test` +Expected: PASS — `leaf_round_trips_a_plain_http_body` returns `200 pong`. + +- [ ] **Step 4: Verify lint + docs** + +Run: `just lint && just doc` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/adapter/net/http/hyper/src/error.rs crates/adapter/net/http/hyper/src/leaf.rs crates/adapter/net/http/hyper/src/lib.rs +git commit -m "feat(net): hyper leaf — ConnConfig, HyperLeaf, hyper_leaf" +``` + +--- + +### Task 4: Leaf error paths — connect failure + connect timeout + +Proves the error mapping: an aborted connection surfaces a non-`Connection` protocol error, and an unreachable host trips `connect_timeout` → `HttpError::Connection`. + +**Files:** +- Modify: `crates/adapter/net/http/hyper/src/leaf.rs` (add tests only) + +**Interfaces:** +- Consumes: `HyperLeaf`, `hyper_leaf`, `ConnConfig`, `HttpError` (from Task 3). +- Produces: nothing new (test-only). + +- [ ] **Step 1: Write the connect-timeout failing test** + +Add to the `tests` module in `crates/adapter/net/http/hyper/src/leaf.rs`: + +```rust + #[tokio::test] + async fn unreachable_host_hits_connect_timeout_as_connection_error() { + // 203.0.113.0/24 (TEST-NET-3) is reserved and unroutable — connect stalls + // until connect_timeout fires. + let conn = ConnConfig { + connect_timeout: Duration::from_millis(150), + ..test_conn() + }; + let leaf = hyper_leaf(conn); + let req = http::Request::get("http://203.0.113.1:9/x") + .body(Bytes::new()) + .unwrap(); + + let err = leaf.call(req).await.expect_err("must time out connecting"); + assert!( + matches!(err, oath_adapter_net_http_api::HttpError::Connection(_)), + "expected Connection, got {err:?}" + ); + } +``` + +- [ ] **Step 2: Write the server-abort failing test** + +Add a server that drops the connection before replying, and a test asserting the send errors. Add this helper and test to the same `tests` module: + +```rust + // A server that accepts then immediately drops the connection (no response). + async fn spawn_aborting_server() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + drop(stream); // close without speaking HTTP + } + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn aborted_connection_surfaces_an_http_error() { + let base = spawn_aborting_server().await; + let leaf = hyper_leaf(test_conn()); + let req = http::Request::get(format!("{base}/x")) + .body(Bytes::new()) + .unwrap(); + + // The connection is established then dropped mid-exchange: hyper-util + // reports it as a (non-connect) send error → HttpError::Other. We assert + // it is an error and not a spurious success. + let err = leaf.call(req).await.expect_err("aborted connection"); + assert!( + matches!(err, oath_adapter_net_http_api::HttpError::Other(_)), + "expected Other, got {err:?}" + ); + } +``` + +- [ ] **Step 3: Run the tests** + +Run: `just test` +Expected: PASS — both error-path tests classify correctly. (If `aborted_connection_surfaces_an_http_error` is flaky because the drop races the connect classification, tighten the server to complete the TCP handshake before dropping — it already does, since `accept()` returns a connected stream.) + +- [ ] **Step 4: Verify lint + docs** + +Run: `just lint && just doc` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/adapter/net/http/hyper/src/leaf.rs +git commit -m "test(net): hyper leaf error-path mapping (connect + abort)" +``` + +--- + +### Task 5: TLS loopback — real aws-lc-rs/webpki handshake in CI + +Stands up a rustls loopback server with an `rcgen` self-signed cert and drives a `HyperLeaf` whose connector trusts that cert, exercising the full TLS path deterministically. + +The test constructs `HyperLeaf { client }` directly with a custom-root client — so it must be an **in-crate** `#[cfg(test)]` test (private-field access), added to the existing `tests` module in `leaf.rs`. An external `tests/` integration file cannot see the private `client` field and would need an exported constructor seam we don't want in the public API; the in-crate test avoids both. + +**Files:** +- Modify: `crates/adapter/net/http/hyper/src/leaf.rs` (add the TLS test to the existing in-crate `tests` module) +- Modify: `crates/adapter/net/http/hyper/Cargo.toml` + root `Cargo.toml` (add `tokio-rustls` dev-dep + pin) + +**Interfaces:** +- Consumes: `HyperLeaf` and its private `client` field (Task 3); `rustls`, `rcgen`, `tokio-rustls`, `hyper`, `hyper-util`, `hyper-rustls`, `tokio` (dev-deps). +- Produces: nothing new (test-only; no public API change). + +- [ ] **Step 1: Write the TLS round-trip failing test** + +Add to the `tests` module in `crates/adapter/net/http/hyper/src/leaf.rs` (imports at the top of the module as needed): + +```rust + // Full TLS path: rcgen self-signed cert → rustls server on loopback → a + // HyperLeaf whose connector trusts exactly that cert. Exercises the real + // aws-lc-rs handshake + webpki verification in CI (custom root, not webpki-roots). + #[tokio::test] + async fn leaf_round_trips_over_tls_with_a_trusted_self_signed_cert() { + use hyper_util::client::legacy::connect::HttpConnector; + use hyper_util::client::legacy::Client; + use hyper_util::rt::{TokioExecutor, TokioIo}; + use std::sync::Arc; + use tokio::net::TcpListener; + use tokio_rustls::TlsAcceptor; + + // 1. Self-signed cert for "localhost". + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap(); + let cert_der = cert.cert.der().clone(); + let key_der = rustls::pki_types::PrivateKeyDer::try_from( + cert.key_pair.serialize_der(), + ) + .unwrap(); + + // 2. rustls server config with that cert. + let server_cfg = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der.clone()], key_der) + .unwrap(); + let acceptor = TlsAcceptor::from(Arc::new(server_cfg)); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let tls = acceptor.accept(tcp).await.unwrap(); + let io = TokioIo::new(tls); + let svc = hyper::service::service_fn(|_req| async { + Ok::<_, std::convert::Infallible>(hyper::Response::new( + http_body_util::Full::new(Bytes::from_static(b"secure")), + )) + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(io, svc) + .await; + }); + + // 3. Client root store trusting only our self-signed cert. + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert_der).unwrap(); + let client_cfg = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + + let mut http = HttpConnector::new(); + http.enforce_http(false); + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(client_cfg) + .https_or_http() + .enable_http1() + .wrap_connector(http); + let client = Client::builder(TokioExecutor::new()).build(https); + let leaf = HyperLeaf { client }; + + // 4. Round-trip over https://localhost:PORT (SNI/cert CN = localhost). + let req = http::Request::get(format!("https://localhost:{port}/")) + .body(Bytes::new()) + .unwrap(); + let resp = leaf.call(req).await.expect("tls round-trip"); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::from_static(b"secure")); + } +``` + +Add `tokio-rustls` to dev-deps in `crates/adapter/net/http/hyper/Cargo.toml`: + +```toml +tokio-rustls = { version = "0.26", default-features = false, features = ["aws-lc-rs"] } +``` + +and its workspace pin in root `Cargo.toml` `[workspace.dependencies]`: + +```toml +tokio-rustls = { version = "0.26", default-features = false, features = ["aws-lc-rs"] } +``` + +- [ ] **Step 2: Run the test** + +Run: `just test` +Expected: PASS — `leaf_round_trips_over_tls_with_a_trusted_self_signed_cert` returns `secure` over a real TLS handshake. (Resolve any rcgen/rustls method drift per the Global Constraints note; behaviour — trust exactly the self-signed cert, round-trip — stays identical.) + +- [ ] **Step 3: Verify lint + docs + deny (first hyper/rustls deny contact)** + +Run: `just lint && just doc` +Expected: PASS. +Run: `just deny` +Expected: PASS — if the hyper/rustls/aws-lc tree trips an advisory/license/ban, record the resolution (allowlist entry with justification) in `deny.toml` and re-run. This is a manifest decision, not a design change (spec Risks). + +- [ ] **Step 4: Commit** + +```bash +git add crates/adapter/net/http/hyper/src/leaf.rs crates/adapter/net/http/hyper/Cargo.toml Cargo.toml +git commit -m "test(net): TLS loopback round-trip over self-signed cert" +``` + +--- + +### Task 6: `build()` — delegate to `stack()` + +The construction surface: `build() = stack(hyper_leaf(conn), …)`, proven by a smoke round-trip through the full stack over the loopback and a `BuildError` on bad coverage. + +**Files:** +- Create: `crates/adapter/net/http/hyper/src/build.rs` +- Modify: `crates/adapter/net/http/hyper/src/lib.rs` + +**Interfaces:** +- Consumes: `hyper_leaf`, `ConnConfig` (Task 3); `oath_adapter_net_http_api::{stack, HttpConfig, HttpClient, AuthSource}`; `oath_adapter_net_http_api::rate::{RateKey, RateLimitConfig, BuildError}`; `oath_adapter_net_api::Timer`. +- Produces: `pub fn build(cfg: HttpConfig, timer: T, auth: A, rate_limits: RateLimitConfig, conn: ConnConfig) -> Result`. + +- [ ] **Step 1: Write `build()`** + +Create `crates/adapter/net/http/hyper/src/build.rs`: + +```rust +//! [`build`] — the hyper construction surface. Assembles the canonical resilience +//! stack (ADR-0031 §1) over a fresh pooled hyper leaf by delegating to +//! `oath_adapter_net_http_api::stack`; ordering invariants stay tested there (#88). + +use crate::leaf::{hyper_leaf, ConnConfig}; +use oath_adapter_net_api::Timer; +use oath_adapter_net_http_api::rate::{BuildError, RateKey, RateLimitConfig}; +use oath_adapter_net_http_api::{stack, AuthSource, HttpClient, HttpConfig}; +use std::fmt; + +/// Assemble the canonical resilience stack over a fresh pooled hyper leaf. +/// +/// `build(cfg, timer, auth, rate_limits, conn) == stack(hyper_leaf(conn), cfg, +/// timer, auth, rate_limits)`. The return is fully opaque — adapters use it +/// through the `HttpClient` seam, never naming the concrete layered type. +/// +/// # Errors +/// [`BuildError`] from `stack()` if `rate_limits` is not total over `K::all()`, +/// a policy is out of range, or the concurrency-singleton invariant is breached. +pub fn build( + cfg: HttpConfig, + timer: T, + auth: A, + rate_limits: RateLimitConfig, + conn: ConnConfig, +) -> Result +where + T: Timer + 'static, + A: AuthSource + 'static, + K: RateKey + fmt::Debug, +{ + stack(hyper_leaf(conn), cfg, timer, auth, rate_limits) +} +``` + +Add to `crates/adapter/net/http/hyper/src/lib.rs`: + +```rust +pub mod build; + +pub use build::build; +``` + +Confirm the `stack`-side re-export path: `oath_adapter_net_http_api` re-exports `stack`, `HttpConfig`, `AuthSource`, `HttpClient` at the crate root, and `RateKey`/`RateLimitConfig`/`BuildError` from its `rate` module (also re-exported at root as `BuildError`/`RateKey`/`RateLimitConfig`). If the `rate::` path fails to resolve, import them from the crate root instead (`oath_adapter_net_http_api::{BuildError, RateKey, RateLimitConfig}`). + +- [ ] **Step 2: Write the smoke + BuildError tests** + +Add to the bottom of `crates/adapter/net/http/hyper/src/build.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::build; + use crate::leaf::ConnConfig; + use crate::timer::TokioTimer; + use bytes::Bytes; + use http_body_util::BodyExt; + use oath_adapter_net_api::Service; + use oath_adapter_net_http_api::rate::{LimitDecl, LimitPolicy, RateLimitConfig}; + use oath_adapter_net_http_api::{ + BuildError, CircuitBreakerConfig, HttpConfig, NoAuth, RateKey, RateScope, RetryConfig, + Scope, + }; + use std::collections::HashMap; + use std::convert::Infallible; + use std::num::NonZeroU32; + use std::time::Duration; + use tokio::net::TcpListener; + + // A single-variant, drift-proof RateKey (compiler-checked `all()`). + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + enum Key { + Rest, + } + impl RateKey for Key { + fn all() -> &'static [Self] { + &[Self::Rest] + } + } + + fn conn() -> ConnConfig { + ConnConfig { + pool_max_idle_per_host: 4, + pool_idle_timeout: Duration::from_secs(30), + connect_timeout: Duration::from_secs(2), + } + } + + fn http_cfg() -> HttpConfig { + HttpConfig { + timeout: Duration::from_secs(5), + retry: RetryConfig { + max_attempts: NonZeroU32::new(1).unwrap(), + base: Duration::ZERO, + cap: Duration::ZERO, + seed: 1, + }, + circuit_breaker: CircuitBreakerConfig { + failure_threshold: NonZeroU32::new(3).unwrap(), + cooldown: Duration::from_secs(30), + throttle_cooldown: Duration::from_secs(900), + half_open_probes: NonZeroU32::new(1).unwrap(), + }, + headers: http::HeaderMap::new(), + rate_limit_max_wait: Duration::ZERO, + } + } + + // A total pacing config over Key (global unlimited; Rest global-only). + fn total_rates() -> RateLimitConfig { + RateLimitConfig { + global: LimitPolicy::TokenBucket { + rate: 1000, + per: Duration::from_secs(1), + burst: 1000, + }, + local: HashMap::from([(Key::Rest, LimitDecl::GlobalOnly)]), + } + } + + async fn spawn_echo() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let io = hyper_util::rt::TokioIo::new(stream); + tokio::spawn(async move { + let svc = hyper::service::service_fn(|_r| async { + Ok::<_, Infallible>(hyper::Response::new( + http_body_util::Full::new(Bytes::from_static(b"ok")), + )) + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(io, svc) + .await; + }); + } + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn build_assembles_a_working_stack_over_the_hyper_leaf() { + let base = spawn_echo().await; + let client = build(http_cfg(), TokioTimer, NoAuth, total_rates(), conn()) + .expect("total config builds"); + + let mut req = http::Request::get(format!("{base}/x")) + .body(Bytes::new()) + .unwrap(); + // Fail-closed pacing: every request carries an explicit Scope (ADR-0034 #1). + req.extensions_mut().insert(RateScope:: { + scope: Scope::Global, + key: None, + }); + + let resp = client.call(req).await.expect("round-trip through the stack"); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::from_static(b"ok")); + } + + #[test] + fn build_rejects_a_config_missing_pacing_coverage() { + // `local` empty ⇒ Key::Rest unclassified ⇒ BuildError before any leaf work. + let rates = RateLimitConfig:: { + global: LimitPolicy::TokenBucket { + rate: 1000, + per: Duration::from_secs(1), + burst: 1000, + }, + local: HashMap::new(), + }; + let err = build(http_cfg(), TokioTimer, NoAuth, rates, conn()) + .expect_err("missing coverage must fail closed"); + assert!( + matches!(err, BuildError::UndeclaredKey(_)), + "expected UndeclaredKey, got {err:?}" + ); + } +} +``` + +Note: confirm the `RateScope` field/type shape (`RateScope { scope: Scope, key: Option }`) and the `BuildError::UndeclaredKey` variant name against `oath_adapter_net_http_api::rate` — both are used verbatim in #88's `stack.rs` tests (`req()` helper stamps `RateScope { scope, key }`; the missing-key test matches `BuildError::UndeclaredKey`). + +- [ ] **Step 3: Run the tests** + +Run: `just test` +Expected: PASS — `build_assembles_a_working_stack_over_the_hyper_leaf` round-trips `ok` through all layers; `build_rejects_a_config_missing_pacing_coverage` returns `BuildError::UndeclaredKey`. + +- [ ] **Step 4: Verify lint + docs** + +Run: `just lint && just doc` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/adapter/net/http/hyper/src/build.rs crates/adapter/net/http/hyper/src/lib.rs +git commit -m "feat(net): build() — assemble the stack over the hyper leaf" +``` + +--- + +### Task 7: Docs — ADR-0030 §7 amendment + CHANGELOG + full CI + +Records the resolved TLS/connector decisions and closes the PR out against the full gate. + +**Files:** +- Modify: `docs/adr/0030-http-transport-contract-wire-bytes-streaming-composition.md` (§7 amendment note) +- Modify: `CHANGELOG.md` (`[Unreleased]`) + +**Interfaces:** +- Consumes/Produces: none (docs). + +- [ ] **Step 1: Amend ADR-0030 §7** + +At the end of ADR-0030 §7 (after the "The cost is more wiring…" paragraph), add an amendment note: + +```markdown +> **Amendment (2026-07-05, hyper-backend slice #).** The leaf's resolved TLS +> wiring: crypto provider **aws-lc-rs** (the rustls 0.23 default; FIPS-capable); +> trust anchors **webpki-roots** (bundled Mozilla roots — reproducible, +> container-friendly, no OS trust-store dependency); ALPN offers `h2`+`http/1.1`. +> `ConnConfig` exposes three knobs — `pool_max_idle_per_host`, `pool_idle_timeout`, +> and `connect_timeout` (a distinct, tighter bound on connect+handshake, separate +> from the per-attempt `Timeout` layer). See +> [the hyper-backend design](../superpowers/specs/2026-07-05-net-http-hyper-backend-design.md). +``` + +(Replace `#` with the actual PR number once opened.) + +- [ ] **Step 2: Add the CHANGELOG entry** + +In `CHANGELOG.md` under `## [Unreleased]` → `### Added` (create the subsection if absent), add: + +```markdown +- **net-http hyper backend (transport).** New `oath-adapter-net-http-hyper` crate: + `TokioTimer` (the tokio `Timer`), the pooled TLS leaf (`hyper_leaf`/`ConnConfig`/ + `HyperLeaf`) over hyper-util + rustls (aws-lc-rs, webpki-roots), the + `hyper → HttpError` mapping, and `build()` delegating to `stack()`. Response + bodies stream; buffering follows in PR B. (#) +``` + +- [ ] **Step 3: Run the full CI gate** + +Run: `just ci` +Expected: PASS — fmt, lint, test, doc, deny, typos all green (identical to GitHub Actions). Also run `just msrv` to confirm the hyper/rustls tree builds on MSRV 1.90. + +Run: `just msrv` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add docs/adr/0030-http-transport-contract-wire-bytes-streaming-composition.md CHANGELOG.md +git commit -m "docs(net): record hyper leaf TLS decisions (ADR-0030 §7)" +``` + +- [ ] **Step 5: Open the PR** + +```bash +git push -u origin feat/net-http-hyper +gh issue create --title "feat(net): hyper backend transport — hyper_leaf + build() + TokioTimer (Slice, PR A)" \ + --label enhancement --body "The hyper-backend slice PR A: create oath-adapter-net-http-hyper with TokioTimer, the pooled TLS leaf, hyper→HttpError mapping, and build() delegating to stack(). Design: docs/superpowers/specs/2026-07-05-net-http-hyper-backend-design.md" +gh pr create --title "feat(net): hyper backend transport (hyper_leaf + build + TokioTimer)" \ + --body "Closes #. See docs/superpowers/plans/2026-07-05-net-http-hyper-backend-pr-a.md. PR B (buffering) follows off main." +``` + +(Fill `#` from the created issue; update the ADR/CHANGELOG `#` placeholders after the PR number is known, in a follow-up commit if desired.) + +--- + +## Notes for the executor + +- **Task 5's file plan was corrected inline:** the TLS test is an **in-crate** `#[cfg(test)]` test in `leaf.rs` (so it can construct `HyperLeaf { client }` with private-field access + custom roots), not a separate `tests/` integration file. No `pub(crate)` seam is exported. +- **PR B (buffering) is out of scope here** — it branches off `main` after this merges: the leaf reads `req.extensions().get::()`, and on `BufferMode::Buffer` collects `Incoming` into `Bytes` → `ResponseBody::buffered(bytes)`. Additive; no signature/type change. +- **External-API drift:** every code step ends with `just check`/`just test`; a builder-method rename in a hyper/rustls patch release surfaces there. Keep the documented behaviour identical when resolving. From 02e2e68d8621354812d1cff225997c2042eafeb5 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:28:52 +0000 Subject: [PATCH 03/12] feat(net): scaffold oath-adapter-net-http-hyper crate Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 519 ++++++++++++++++++++++- Cargo.toml | 20 + crates/adapter/net/http/hyper/Cargo.toml | 26 ++ crates/adapter/net/http/hyper/src/lib.rs | 7 + 4 files changed, 566 insertions(+), 6 deletions(-) create mode 100644 crates/adapter/net/http/hyper/Cargo.toml create mode 100644 crates/adapter/net/http/hyper/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 98394b0..63adffb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,12 +22,47 @@ dependencies = [ "event-listener 2.5.3", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.8.0" @@ -55,12 +90,33 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -76,6 +132,24 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -83,7 +157,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -119,12 +193,33 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -142,6 +237,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -161,6 +262,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -184,6 +296,31 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "http" version = "1.4.2" @@ -217,12 +354,95 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -264,9 +484,15 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -307,6 +533,25 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "oath-adapter-net-http-hyper" +version = "0.1.0" +dependencies = [ + "bytes", + "http", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "oath-adapter-net-api", + "oath-adapter-net-http-api", + "rcgen", + "rustls", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "oath-adapter-net-http-mock" version = "0.1.0" @@ -509,12 +754,34 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -617,6 +884,19 @@ dependencies = [ "rand_core", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -632,6 +912,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -642,7 +936,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -715,6 +1044,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -744,9 +1079,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.118" @@ -768,7 +1109,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -800,6 +1141,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tokio" version = "1.52.3" @@ -814,7 +1174,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -828,6 +1188,35 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -870,6 +1259,12 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unarray" version = "0.1.4" @@ -882,6 +1277,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -891,6 +1292,15 @@ dependencies = [ "libc", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -906,12 +1316,30 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -921,12 +1349,85 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "zerocopy" version = "0.8.52" @@ -947,6 +1448,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 732e96c..5735037 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/adapter/net/api", "crates/adapter/net/mock", "crates/adapter/net/http/api", + "crates/adapter/net/http/hyper", "crates/adapter/net/http/mock", "crates/adapter/net/ws/api", "crates/adapter/net/ws/mock", @@ -47,6 +48,7 @@ oath-model = { path = "crates/model", version = "0.1.0" } oath-adapter-net-api = { path = "crates/adapter/net/api", version = "0.1.0" } oath-adapter-net-mock = { path = "crates/adapter/net/mock", version = "0.1.0" } oath-adapter-net-http-api = { path = "crates/adapter/net/http/api", version = "0.1.0" } +oath-adapter-net-http-hyper = { path = "crates/adapter/net/http/hyper", version = "0.1.0" } oath-adapter-net-http-mock = { path = "crates/adapter/net/http/mock", version = "0.1.0" } oath-adapter-net-ws-api = { path = "crates/adapter/net/ws/api", version = "0.1.0" } oath-adapter-net-ws-mock = { path = "crates/adapter/net/ws/mock", version = "0.1.0" } @@ -85,6 +87,24 @@ tracing-subscriber = { version = "0.3", default-features = false, features = [ ] } proptest = "1" serde_json = "1" +hyper = { version = "1", features = ["client", "http1", "http2"] } +hyper-util = { version = "0.1", features = [ + "client", + "client-legacy", + "http1", + "http2", + "tokio", +] } +hyper-rustls = { version = "0.27", default-features = false, features = [ + "http1", + "http2", + "aws-lc-rs", + "webpki-roots", +] } +rustls = { version = "0.23", default-features = false, features = [ + "aws-lc-rs", +] } +rcgen = "0.13" # --- Lints --- # Centralized lint configuration inherited by all workspace crates via `[lints] workspace = true`. diff --git a/crates/adapter/net/http/hyper/Cargo.toml b/crates/adapter/net/http/hyper/Cargo.toml new file mode 100644 index 0000000..59bcc1d --- /dev/null +++ b/crates/adapter/net/http/hyper/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "oath-adapter-net-http-hyper" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +oath-adapter-net-api = { workspace = true } +oath-adapter-net-http-api = { workspace = true } +bytes = { workspace = true } +http = { workspace = true } +http-body-util = { workspace = true } +hyper = { workspace = true } +hyper-util = { workspace = true } +hyper-rustls = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +rustls = { workspace = true } +rcgen = { workspace = true } +tracing-subscriber = { workspace = true } + +[lints] +workspace = true diff --git a/crates/adapter/net/http/hyper/src/lib.rs b/crates/adapter/net/http/hyper/src/lib.rs new file mode 100644 index 0000000..03e7b01 --- /dev/null +++ b/crates/adapter/net/http/hyper/src/lib.rs @@ -0,0 +1,7 @@ +//! The hyper backend for the OATH HTTP stack: a pooled, TLS-terminating leaf and +//! the `build()` construction surface (ADR-0030 §7). +//! +//! This is the only crate that depends on `hyper`/`tokio`/`rustls`. `build()` +//! assembles the canonical resilience stack (`oath_adapter_net_http_api::stack`) +//! over a fresh `hyper_leaf`, so backend choice stays behind the `HttpClient` +//! seam (ADR-0030 §6). From 714456424e1d8bd87f0f0ebd7859b3c7aa2df6e7 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:44:28 +0000 Subject: [PATCH 04/12] =?UTF-8?q?feat(net):=20TokioTimer=20=E2=80=94=20the?= =?UTF-8?q?=20tokio-backed=20Timer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapter/net/http/hyper/Cargo.toml | 1 + crates/adapter/net/http/hyper/src/lib.rs | 4 +++ crates/adapter/net/http/hyper/src/timer.rs | 36 ++++++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 crates/adapter/net/http/hyper/src/timer.rs diff --git a/crates/adapter/net/http/hyper/Cargo.toml b/crates/adapter/net/http/hyper/Cargo.toml index 59bcc1d..4d10a0b 100644 --- a/crates/adapter/net/http/hyper/Cargo.toml +++ b/crates/adapter/net/http/hyper/Cargo.toml @@ -18,6 +18,7 @@ tokio = { workspace = true } tracing = { workspace = true } [dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } rustls = { workspace = true } rcgen = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/adapter/net/http/hyper/src/lib.rs b/crates/adapter/net/http/hyper/src/lib.rs index 03e7b01..8e909c6 100644 --- a/crates/adapter/net/http/hyper/src/lib.rs +++ b/crates/adapter/net/http/hyper/src/lib.rs @@ -5,3 +5,7 @@ //! assembles the canonical resilience stack (`oath_adapter_net_http_api::stack`) //! over a fresh `hyper_leaf`, so backend choice stays behind the `HttpClient` //! seam (ADR-0030 §6). + +pub mod timer; + +pub use timer::TokioTimer; diff --git a/crates/adapter/net/http/hyper/src/timer.rs b/crates/adapter/net/http/hyper/src/timer.rs new file mode 100644 index 0000000..60ceee8 --- /dev/null +++ b/crates/adapter/net/http/hyper/src/timer.rs @@ -0,0 +1,36 @@ +//! [`TokioTimer`] — the tokio-backed [`Timer`] the resilience stack sleeps on. + +use oath_adapter_net_api::Timer; +use std::future::Future; +use std::time::{Duration, Instant}; + +/// The tokio-backed [`Timer`]: [`sleep`](Timer::sleep) is `tokio::time::sleep`. +/// +/// Zero-sized and `Copy` — the resilience layers hold it by value and clone it +/// across attempts at no cost. +#[derive(Debug, Clone, Copy, Default)] +pub struct TokioTimer; + +impl Timer for TokioTimer { + fn sleep(&self, dur: Duration) -> impl Future + Send { + tokio::time::sleep(dur) + } + + fn now(&self) -> Instant { + Instant::now() + } +} + +#[cfg(test)] +mod tests { + use super::TokioTimer; + use oath_adapter_net_api::Timer; + use std::time::Duration; + + #[tokio::test(start_paused = true)] + async fn sleep_elapses_the_requested_duration() { + let start = tokio::time::Instant::now(); + TokioTimer.sleep(Duration::from_secs(5)).await; + assert_eq!(start.elapsed(), Duration::from_secs(5)); + } +} From e6b52e73c45570766ae35e2cf7936caf435ee5b0 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:59:04 +0000 Subject: [PATCH 05/12] =?UTF-8?q?feat(net):=20hyper=20leaf=20=E2=80=94=20C?= =?UTF-8?q?onnConfig,=20HyperLeaf,=20hyper=5Fleaf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 7 + crates/adapter/net/http/hyper/Cargo.toml | 1 + crates/adapter/net/http/hyper/src/error.rs | 30 ++++ crates/adapter/net/http/hyper/src/leaf.rs | 161 +++++++++++++++++++++ crates/adapter/net/http/hyper/src/lib.rs | 3 + 5 files changed, 202 insertions(+) create mode 100644 crates/adapter/net/http/hyper/src/error.rs create mode 100644 crates/adapter/net/http/hyper/src/leaf.rs diff --git a/Cargo.lock b/Cargo.lock index 63adffb..8170e9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,6 +360,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -374,6 +380,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", diff --git a/crates/adapter/net/http/hyper/Cargo.toml b/crates/adapter/net/http/hyper/Cargo.toml index 4d10a0b..7104826 100644 --- a/crates/adapter/net/http/hyper/Cargo.toml +++ b/crates/adapter/net/http/hyper/Cargo.toml @@ -19,6 +19,7 @@ tracing = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } +hyper = { workspace = true, features = ["server"] } rustls = { workspace = true } rcgen = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/adapter/net/http/hyper/src/error.rs b/crates/adapter/net/http/hyper/src/error.rs new file mode 100644 index 0000000..029b721 --- /dev/null +++ b/crates/adapter/net/http/hyper/src/error.rs @@ -0,0 +1,30 @@ +//! Anti-corruption: normalize `hyper`/`hyper-util` errors to [`HttpError`] +//! (ADR-0030 §6). Connect-phase failures (DNS/TCP/TLS/handshake, incl. +//! connect-timeout) map to [`HttpError::Connection`]; everything else — protocol +//! errors, cancellation, and mid-stream body errors — maps to [`HttpError::Other`] +//! ("network error"). No `Timeout`: semantic timeout is the `Timeout` *layer*. + +use oath_adapter_net_http_api::HttpError; + +/// Map a `hyper_util` client send error to [`HttpError`]. +// `clippy::redundant_pub_crate` wants `pub` here since `error` is a private +// module — but that would trip `unreachable_pub` (also warn-level workspace- +// wide) instead, which correctly flags a `pub` item that is not actually +// reachable outside the crate. `pub(crate)` states the true, intended +// visibility; silence the (equally valid, but losing) alternative lint. +#[allow(clippy::redundant_pub_crate)] +pub(crate) fn map_legacy_err(e: hyper_util::client::legacy::Error) -> HttpError { + if e.is_connect() { + HttpError::connection(e) + } else { + HttpError::other(e) + } +} + +/// Map a `hyper` body/protocol error to [`HttpError`]. Body errors surface after +/// the response head, so there is no connect phase to distinguish — always +/// [`HttpError::Other`]. +#[allow(clippy::redundant_pub_crate)] +pub(crate) fn map_hyper_err(e: hyper::Error) -> HttpError { + HttpError::other(e) +} diff --git a/crates/adapter/net/http/hyper/src/leaf.rs b/crates/adapter/net/http/hyper/src/leaf.rs new file mode 100644 index 0000000..f284129 --- /dev/null +++ b/crates/adapter/net/http/hyper/src/leaf.rs @@ -0,0 +1,161 @@ +//! The hyper backend leaf: a pooled `hyper_util` client over a rustls HTTPS +//! connector. +//! +//! Implements [`Service`], so it is an [`HttpClient`](oath_adapter_net_http_api::HttpClient) +//! by blanket impl (ADR-0030 §6). Response bodies stream (PR A); buffering is PR B. + +use crate::error::{map_hyper_err, map_legacy_err}; +use bytes::Bytes; +use http_body_util::combinators::MapErr; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper_rustls::HttpsConnector; +use hyper_util::client::legacy::Client; +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::rt::{TokioExecutor, TokioTimer as HyperPoolTimer}; +use oath_adapter_net_http_api::{HttpError, ResponseBody, Service}; +use std::future::Future; +use std::time::Duration; + +/// The leaf response body: hyper's `Incoming` with its `hyper::Error` normalized +/// to [`HttpError`] (ADR-0030 §6). +/// +/// `map_hyper_err` is a named `fn` so the type is nameable in [`HyperLeaf`]'s +/// associated type. +pub type HyperBody = MapErr HttpError>; + +/// Connection-pool + connector configuration for [`hyper_leaf`]. Plain data — no +/// `serde`, no type parameter (like `HttpConfig`); adapters construct it directly. +#[derive(Debug, Clone)] +pub struct ConnConfig { + /// Max idle pooled connections retained per host. + pub pool_max_idle_per_host: usize, + /// How long an idle pooled connection is retained before eviction. + pub pool_idle_timeout: Duration, + /// Bound on TCP connect + TLS handshake — fails fast on a dead host, + /// independent of (and tighter than) the per-attempt `Timeout` layer. + pub connect_timeout: Duration, +} + +/// The hyper backend leaf. `Clone` (the pool is `Arc`-shared internally), so the +/// whole assembled `stack()` is `Clone`. +#[derive(Clone)] +pub struct HyperLeaf { + client: Client, Full>, +} + +impl std::fmt::Debug for HyperLeaf { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HyperLeaf").finish_non_exhaustive() + } +} + +impl Service> for HyperLeaf { + type Response = http::Response>; + type Error = HttpError; + + #[allow(clippy::manual_async_fn)] + fn call( + &self, + req: http::Request, + ) -> impl Future> + Send { + let client = self.client.clone(); + async move { + let (parts, body) = req.into_parts(); + let req = http::Request::from_parts(parts, Full::new(body)); + let resp = client.request(req).await.map_err(map_legacy_err)?; + let (parts, incoming) = resp.into_parts(); + let mapper: fn(hyper::Error) -> HttpError = map_hyper_err; + let body = ResponseBody::streaming(incoming.map_err(mapper)); + Ok(http::Response::from_parts(parts, body)) + } + } +} + +/// Construct the pooled HTTPS leaf. +/// +/// An `HttpConnector` (connect timeout, nodelay) wrapped by a rustls +/// `HttpsConnector` (aws-lc-rs, webpki-roots, ALPN h2+http/1.1), driven by a +/// pooled `legacy::Client` on a `TokioExecutor`. +// `conn`'s fields are all `Copy` and only read here, but the public signature +// takes it by value to match `stack()`'s "config consumed once at boot" shape. +#[allow(clippy::needless_pass_by_value)] +#[must_use] +pub fn hyper_leaf(conn: ConnConfig) -> HyperLeaf { + let mut http = HttpConnector::new(); + http.enforce_http(false); // let the HTTPS wrapper handle `https://` + http.set_connect_timeout(Some(conn.connect_timeout)); + http.set_nodelay(true); + + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_webpki_roots() + .https_or_http() + .enable_http1() + .enable_http2() + .wrap_connector(http); + + let client = Client::builder(TokioExecutor::new()) + .timer(HyperPoolTimer::new()) + .pool_idle_timeout(conn.pool_idle_timeout) + .pool_max_idle_per_host(conn.pool_max_idle_per_host) + .build(https); + + HyperLeaf { client } +} + +#[cfg(test)] +mod tests { + use super::{ConnConfig, HyperLeaf, hyper_leaf}; + use bytes::Bytes; + use http_body_util::BodyExt; + use oath_adapter_net_http_api::Service; + use std::convert::Infallible; + use std::time::Duration; + use tokio::net::TcpListener; + + fn test_conn() -> ConnConfig { + ConnConfig { + pool_max_idle_per_host: 4, + pool_idle_timeout: Duration::from_secs(30), + connect_timeout: Duration::from_secs(2), + } + } + + // Spawn a one-connection plain-HTTP hyper server that echoes a fixed body. + // Returns the bound `http://127.0.0.1:PORT` base URL. + async fn spawn_echo_server(reply: &'static [u8]) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let io = hyper_util::rt::TokioIo::new(stream); + tokio::spawn(async move { + let svc = hyper::service::service_fn(move |_req| async move { + Ok::<_, Infallible>(hyper::Response::new(http_body_util::Full::new( + Bytes::from_static(reply), + ))) + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(io, svc) + .await; + }); + } + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn leaf_round_trips_a_plain_http_body() { + let base = spawn_echo_server(b"pong").await; + let leaf: HyperLeaf = hyper_leaf(test_conn()); + let req = http::Request::get(format!("{base}/ping")) + .body(Bytes::new()) + .unwrap(); + + let resp = leaf.call(req).await.expect("round-trip"); + assert_eq!(resp.status(), http::StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::from_static(b"pong")); + } +} diff --git a/crates/adapter/net/http/hyper/src/lib.rs b/crates/adapter/net/http/hyper/src/lib.rs index 8e909c6..d5cd253 100644 --- a/crates/adapter/net/http/hyper/src/lib.rs +++ b/crates/adapter/net/http/hyper/src/lib.rs @@ -6,6 +6,9 @@ //! over a fresh `hyper_leaf`, so backend choice stays behind the `HttpClient` //! seam (ADR-0030 §6). +mod error; +pub mod leaf; pub mod timer; +pub use leaf::{ConnConfig, HyperBody, HyperLeaf, hyper_leaf}; pub use timer::TokioTimer; From df78e6e3795b3f5427151b4cdd32604f48f89ac0 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:07:58 +0000 Subject: [PATCH 06/12] test(net): hyper leaf error-path mapping (connect + abort) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapter/net/http/hyper/src/leaf.rs | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/adapter/net/http/hyper/src/leaf.rs b/crates/adapter/net/http/hyper/src/leaf.rs index f284129..06f6f29 100644 --- a/crates/adapter/net/http/hyper/src/leaf.rs +++ b/crates/adapter/net/http/hyper/src/leaf.rs @@ -158,4 +158,62 @@ mod tests { let body = resp.into_body().collect().await.unwrap().to_bytes(); assert_eq!(body, Bytes::from_static(b"pong")); } + + #[tokio::test] + async fn unreachable_host_hits_connect_timeout_as_connection_error() { + // 203.0.113.0/24 (TEST-NET-3) is reserved and unroutable — connect stalls + // until connect_timeout fires. + let conn = ConnConfig { + connect_timeout: Duration::from_millis(150), + ..test_conn() + }; + let leaf = hyper_leaf(conn); + let req = http::Request::get("http://203.0.113.1:9/x") + .body(Bytes::new()) + .unwrap(); + + // `Result::expect_err` needs `T: Debug`, but the `Ok` payload + // (`Response>`) isn't `Debug` — go via `Option` + // instead, which only needs `E: Debug` (satisfied by `HttpError`). + let err = leaf + .call(req) + .await + .err() + .expect("must time out connecting"); + assert!( + matches!(err, oath_adapter_net_http_api::HttpError::Connection(_)), + "expected Connection, got {err:?}" + ); + } + + // A server that accepts then immediately drops the connection (no response). + async fn spawn_aborting_server() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + drop(stream); // close without speaking HTTP + } + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn aborted_connection_surfaces_an_http_error() { + let base = spawn_aborting_server().await; + let leaf = hyper_leaf(test_conn()); + let req = http::Request::get(format!("{base}/x")) + .body(Bytes::new()) + .unwrap(); + + // The connection is established then dropped mid-exchange: hyper-util + // reports it as a (non-connect) send error → HttpError::Other. We assert + // it is an error and not a spurious success. + let err = leaf.call(req).await.err().expect("aborted connection"); + assert!( + matches!(err, oath_adapter_net_http_api::HttpError::Other(_)), + "expected Other, got {err:?}" + ); + } } From 80650c5ae0aa6d44e68ef7e74228c9b54b95d6a1 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:16:27 +0000 Subject: [PATCH 07/12] test(net): TLS loopback round-trip over self-signed cert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tokio-rustls as a dev-dependency and drive a real aws-lc-rs/webpki handshake in-crate: an rcgen self-signed cert served over a rustls loopback listener, and a HyperLeaf whose connector trusts exactly that cert (a custom RootCertStore, not webpki-roots). Also records a narrowly-scoped deny.toml license exception for webpki-roots' CDLA-Permissive-2.0 data license — the first time cargo-deny fully exercised the hyper/rustls/aws-lc dependency tree. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + Cargo.toml | 3 + crates/adapter/net/http/hyper/Cargo.toml | 1 + crates/adapter/net/http/hyper/src/leaf.rs | 67 +++++++++++++++++++++++ deny.toml | 12 ++++ 5 files changed, 84 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 8170e9e..a429081 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -555,6 +555,7 @@ dependencies = [ "rcgen", "rustls", "tokio", + "tokio-rustls", "tracing", "tracing-subscriber", ] diff --git a/Cargo.toml b/Cargo.toml index 5735037..b351ea9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,6 +105,9 @@ rustls = { version = "0.23", default-features = false, features = [ "aws-lc-rs", ] } rcgen = "0.13" +tokio-rustls = { version = "0.26", default-features = false, features = [ + "aws-lc-rs", +] } # --- Lints --- # Centralized lint configuration inherited by all workspace crates via `[lints] workspace = true`. diff --git a/crates/adapter/net/http/hyper/Cargo.toml b/crates/adapter/net/http/hyper/Cargo.toml index 7104826..278acf0 100644 --- a/crates/adapter/net/http/hyper/Cargo.toml +++ b/crates/adapter/net/http/hyper/Cargo.toml @@ -22,6 +22,7 @@ tokio = { workspace = true, features = ["test-util"] } hyper = { workspace = true, features = ["server"] } rustls = { workspace = true } rcgen = { workspace = true } +tokio-rustls = { workspace = true } tracing-subscriber = { workspace = true } [lints] diff --git a/crates/adapter/net/http/hyper/src/leaf.rs b/crates/adapter/net/http/hyper/src/leaf.rs index 06f6f29..2da5fde 100644 --- a/crates/adapter/net/http/hyper/src/leaf.rs +++ b/crates/adapter/net/http/hyper/src/leaf.rs @@ -216,4 +216,71 @@ mod tests { "expected Other, got {err:?}" ); } + + // Full TLS path: rcgen self-signed cert → rustls server on loopback → a + // HyperLeaf whose connector trusts exactly that cert. Exercises the real + // aws-lc-rs handshake + webpki verification in CI (custom root, not webpki-roots). + #[tokio::test] + async fn leaf_round_trips_over_tls_with_a_trusted_self_signed_cert() { + use hyper_util::client::legacy::Client; + use hyper_util::client::legacy::connect::HttpConnector; + use hyper_util::rt::{TokioExecutor, TokioIo}; + use std::sync::Arc; + use tokio::net::TcpListener; + use tokio_rustls::TlsAcceptor; + + // 1. Self-signed cert for "localhost". + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap(); + let cert_der = cert.cert.der().clone(); + let key_der = + rustls::pki_types::PrivateKeyDer::try_from(cert.key_pair.serialize_der()).unwrap(); + + // 2. rustls server config with that cert. + let server_cfg = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der.clone()], key_der) + .unwrap(); + let acceptor = TlsAcceptor::from(Arc::new(server_cfg)); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let tls = acceptor.accept(tcp).await.unwrap(); + let io = TokioIo::new(tls); + let svc = hyper::service::service_fn(|_req| async { + Ok::<_, std::convert::Infallible>(hyper::Response::new(http_body_util::Full::new( + Bytes::from_static(b"secure"), + ))) + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(io, svc) + .await; + }); + + // 3. Client root store trusting only our self-signed cert. + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert_der).unwrap(); + let client_cfg = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + + let mut http = HttpConnector::new(); + http.enforce_http(false); + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(client_cfg) + .https_or_http() + .enable_http1() + .wrap_connector(http); + let client = Client::builder(TokioExecutor::new()).build(https); + let leaf = HyperLeaf { client }; + + // 4. Round-trip over https://localhost:PORT (SNI/cert CN = localhost). + let req = http::Request::get(format!("https://localhost:{port}/")) + .body(Bytes::new()) + .unwrap(); + let resp = leaf.call(req).await.expect("tls round-trip"); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::from_static(b"secure")); + } } diff --git a/deny.toml b/deny.toml index ddf12e5..b6a2a91 100644 --- a/deny.toml +++ b/deny.toml @@ -56,6 +56,18 @@ allow = [ # Workspace crates are not published to crates.io; skip license checks for them. ignore = true +# Per-crate exceptions to the allowlist above — scoped to a single crate so an +# unusual license doesn't get silently accepted workspace-wide. +[[licenses.exceptions]] +# webpki-roots (pulled in by hyper-rustls's `webpki-roots` feature, used by +# `hyper_leaf`'s default connector) ships Mozilla's CA bundle under +# CDLA-Permissive-2.0 — a permissive, OSI-recognized data license for the root +# certificate data itself, not the (Apache-2.0/MIT) code. First surfaced when +# Task 5 (net-http-hyper) exercised the full hyper/rustls/aws-lc dependency +# tree through cargo-deny for the first time. +name = "webpki-roots" +allow = ["CDLA-Permissive-2.0"] + # --- Bans --- # Detects duplicate dependency versions and wildcard version requirements. [bans] From 5840b8f75c36e2a5154c9af7a730f787c654ae94 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:22:21 +0000 Subject: [PATCH 08/12] chore(net): drop unused tracing dep from hyper crate Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 - crates/adapter/net/http/hyper/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a429081..2e890fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,7 +556,6 @@ dependencies = [ "rustls", "tokio", "tokio-rustls", - "tracing", "tracing-subscriber", ] diff --git a/crates/adapter/net/http/hyper/Cargo.toml b/crates/adapter/net/http/hyper/Cargo.toml index 278acf0..ecaf835 100644 --- a/crates/adapter/net/http/hyper/Cargo.toml +++ b/crates/adapter/net/http/hyper/Cargo.toml @@ -15,7 +15,6 @@ hyper = { workspace = true } hyper-util = { workspace = true } hyper-rustls = { workspace = true } tokio = { workspace = true } -tracing = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } From d42f82aa9d16db6b67653bde1e3add61d01da94a Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:28:55 +0000 Subject: [PATCH 09/12] =?UTF-8?q?feat(net):=20build()=20=E2=80=94=20assemb?= =?UTF-8?q?le=20the=20stack=20over=20the=20hyper=20leaf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapter/net/http/hyper/src/build.rs | 173 +++++++++++++++++++++ crates/adapter/net/http/hyper/src/lib.rs | 2 + 2 files changed, 175 insertions(+) create mode 100644 crates/adapter/net/http/hyper/src/build.rs diff --git a/crates/adapter/net/http/hyper/src/build.rs b/crates/adapter/net/http/hyper/src/build.rs new file mode 100644 index 0000000..1af4f0f --- /dev/null +++ b/crates/adapter/net/http/hyper/src/build.rs @@ -0,0 +1,173 @@ +//! [`build`] — the hyper construction surface. +//! +//! Assembles the canonical resilience stack (ADR-0031 §1) over a fresh pooled +//! hyper leaf by delegating to `oath_adapter_net_http_api::stack`; ordering +//! invariants stay tested there (#88). + +use crate::leaf::{ConnConfig, hyper_leaf}; +use oath_adapter_net_api::Timer; +use oath_adapter_net_http_api::rate::{BuildError, RateKey, RateLimitConfig}; +use oath_adapter_net_http_api::{AuthSource, HttpClient, HttpConfig, stack}; +use std::fmt; + +/// Assemble the canonical resilience stack over a fresh pooled hyper leaf. +/// +/// `build(cfg, timer, auth, rate_limits, conn) == stack(hyper_leaf(conn), cfg, +/// timer, auth, rate_limits)`. The return is fully opaque — adapters use it +/// through the `HttpClient` seam, never naming the concrete layered type. +/// +/// # Errors +/// [`BuildError`] from `stack()` if `rate_limits` is not total over `K::all()`, +/// a policy is out of range, or the concurrency-singleton invariant is breached. +pub fn build( + cfg: HttpConfig, + timer: T, + auth: A, + rate_limits: RateLimitConfig, + conn: ConnConfig, +) -> Result +where + T: Timer + 'static, + A: AuthSource + 'static, + K: RateKey + fmt::Debug, +{ + stack(hyper_leaf(conn), cfg, timer, auth, rate_limits) +} + +#[cfg(test)] +mod tests { + use super::build; + use crate::leaf::ConnConfig; + use crate::timer::TokioTimer; + use bytes::Bytes; + use http_body_util::BodyExt; + use oath_adapter_net_http_api::rate::{LimitDecl, LimitPolicy, RateLimitConfig}; + use oath_adapter_net_http_api::{ + BuildError, CircuitBreakerConfig, HttpConfig, NoAuth, RateKey, RateScope, RetryConfig, + Scope, Service, + }; + use std::collections::HashMap; + use std::convert::Infallible; + use std::num::NonZeroU32; + use std::time::Duration; + use tokio::net::TcpListener; + + // A single-variant, drift-proof RateKey (compiler-checked `all()`). + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + enum Key { + Rest, + } + impl RateKey for Key { + fn all() -> &'static [Self] { + &[Self::Rest] + } + } + + fn conn() -> ConnConfig { + ConnConfig { + pool_max_idle_per_host: 4, + pool_idle_timeout: Duration::from_secs(30), + connect_timeout: Duration::from_secs(2), + } + } + + fn http_cfg() -> HttpConfig { + HttpConfig { + timeout: Duration::from_secs(5), + retry: RetryConfig { + max_attempts: NonZeroU32::new(1).unwrap(), + base: Duration::ZERO, + cap: Duration::ZERO, + seed: 1, + }, + circuit_breaker: CircuitBreakerConfig { + failure_threshold: NonZeroU32::new(3).unwrap(), + cooldown: Duration::from_secs(30), + throttle_cooldown: Duration::from_secs(900), + half_open_probes: NonZeroU32::new(1).unwrap(), + }, + headers: http::HeaderMap::new(), + rate_limit_max_wait: Duration::ZERO, + } + } + + // A total pacing config over Key (global unlimited; Rest global-only). + fn total_rates() -> RateLimitConfig { + RateLimitConfig { + global: LimitPolicy::TokenBucket { + rate: 1000, + per: Duration::from_secs(1), + burst: 1000, + }, + local: HashMap::from([(Key::Rest, LimitDecl::GlobalOnly)]), + } + } + + async fn spawn_echo() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let io = hyper_util::rt::TokioIo::new(stream); + tokio::spawn(async move { + let svc = hyper::service::service_fn(|_r| async { + Ok::<_, Infallible>(hyper::Response::new(http_body_util::Full::new( + Bytes::from_static(b"ok"), + ))) + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(io, svc) + .await; + }); + } + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn build_assembles_a_working_stack_over_the_hyper_leaf() { + let base = spawn_echo().await; + let client = build(http_cfg(), TokioTimer, NoAuth, total_rates(), conn()) + .expect("total config builds"); + + let mut req = http::Request::get(format!("{base}/x")) + .body(Bytes::new()) + .unwrap(); + // Fail-closed pacing: every request carries an explicit Scope (ADR-0034 #1). + req.extensions_mut().insert(RateScope:: { + scope: Scope::Global, + key: None, + }); + + let resp = client + .call(req) + .await + .expect("round-trip through the stack"); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::from_static(b"ok")); + } + + #[test] + fn build_rejects_a_config_missing_pacing_coverage() { + // `local` empty ⇒ Key::Rest unclassified ⇒ BuildError before any leaf work. + let rates = RateLimitConfig:: { + global: LimitPolicy::TokenBucket { + rate: 1000, + per: Duration::from_secs(1), + burst: 1000, + }, + local: HashMap::new(), + }; + // `.expect_err()` would require `Debug` on the opaque `Ok` type, which the + // return bound (`impl HttpClient + Clone + Send + Sync + 'static`) deliberately + // omits; `let...else` extracts the error without needing it. + let Err(err) = build(http_cfg(), TokioTimer, NoAuth, rates, conn()) else { + panic!("missing coverage must fail closed"); + }; + assert!( + matches!(err, BuildError::UndeclaredKey(_)), + "expected UndeclaredKey, got {err:?}" + ); + } +} diff --git a/crates/adapter/net/http/hyper/src/lib.rs b/crates/adapter/net/http/hyper/src/lib.rs index d5cd253..5847c11 100644 --- a/crates/adapter/net/http/hyper/src/lib.rs +++ b/crates/adapter/net/http/hyper/src/lib.rs @@ -6,9 +6,11 @@ //! over a fresh `hyper_leaf`, so backend choice stays behind the `HttpClient` //! seam (ADR-0030 §6). +pub mod build; mod error; pub mod leaf; pub mod timer; +pub use build::build; pub use leaf::{ConnConfig, HyperBody, HyperLeaf, hyper_leaf}; pub use timer::TokioTimer; From 59590d9e9d0904ff2067a9e00e216e0b8888eb1b Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:36:27 +0000 Subject: [PATCH 10/12] =?UTF-8?q?docs(net):=20record=20hyper=20leaf=20TLS?= =?UTF-8?q?=20decisions=20(ADR-0030=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +++++ ...ransport-contract-wire-bytes-streaming-composition.md | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4178f90..2c9e249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Auth, Scope fail-closed) are regression-tested over an inline leaf + `MockTimer`. No new dependency; no existing-layer change. (ADR-0031 §1, ADR-0034.) The hyper leaf + `build()` land in the following slice. +- **net-http hyper backend (transport).** New `oath-adapter-net-http-hyper` crate: + `TokioTimer` (the tokio `Timer`), the pooled TLS leaf (`hyper_leaf`/`ConnConfig`/ + `HyperLeaf`) over hyper-util + rustls (aws-lc-rs, webpki-roots), the + `hyper → HttpError` mapping, and `build()` delegating to `stack()`. Response + bodies stream; buffering follows in PR B. - net-http construction-surface design refinements (ADR-0034 append-only Amendments 2026-07-04, spec updated) — an absent `RateLimit` directive now **fails closed** (not "defaults to `Global`"), closing the last silent diff --git a/docs/adr/0030-http-transport-contract-wire-bytes-streaming-composition.md b/docs/adr/0030-http-transport-contract-wire-bytes-streaming-composition.md index a4a7a7e..42518d0 100644 --- a/docs/adr/0030-http-transport-contract-wire-bytes-streaming-composition.md +++ b/docs/adr/0030-http-transport-contract-wire-bytes-streaming-composition.md @@ -133,6 +133,15 @@ The cost is more wiring (we assemble the pooled HTTPS connector); it is containe the `HttpClient` seam, so swapping to a future `net-http-reqwest` is zero churn to the stack. +> **Amendment (2026-07-05, hyper-backend slice).** The leaf's resolved TLS +> wiring: crypto provider **aws-lc-rs** (the rustls 0.23 default; FIPS-capable); +> trust anchors **webpki-roots** (bundled Mozilla roots — reproducible, +> container-friendly, no OS trust-store dependency); ALPN offers `h2`+`http/1.1`. +> `ConnConfig` exposes three knobs — `pool_max_idle_per_host`, `pool_idle_timeout`, +> and `connect_timeout` (a distinct, tighter bound on connect+handshake, separate +> from the per-attempt `Timeout` layer). See +> [the hyper-backend design](../superpowers/specs/2026-07-05-net-http-hyper-backend-design.md). + ### 8. Default assembled stack, data config, three-tier override `oath-adapter-net-http-hyper` ships the canonical stack behind a data-driven From a11fc05df5877eff0cff58117ae1e55c8fffd770 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:45:21 +0000 Subject: [PATCH 11/12] fix(net): TokioTimer::now honors tokio's virtual clock Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapter/net/http/hyper/src/timer.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/adapter/net/http/hyper/src/timer.rs b/crates/adapter/net/http/hyper/src/timer.rs index 60ceee8..8219204 100644 --- a/crates/adapter/net/http/hyper/src/timer.rs +++ b/crates/adapter/net/http/hyper/src/timer.rs @@ -4,7 +4,8 @@ use oath_adapter_net_api::Timer; use std::future::Future; use std::time::{Duration, Instant}; -/// The tokio-backed [`Timer`]: [`sleep`](Timer::sleep) is `tokio::time::sleep`. +/// The tokio-backed [`Timer`]: [`sleep`](Timer::sleep) and [`now`](Timer::now) +/// both read tokio's clock (real time in production, virtual time under `start_paused`). /// /// Zero-sized and `Copy` — the resilience layers hold it by value and clone it /// across attempts at no cost. @@ -17,7 +18,7 @@ impl Timer for TokioTimer { } fn now(&self) -> Instant { - Instant::now() + tokio::time::Instant::now().into_std() } } From 7f9dc1057c15fb654ffaa56a57ad2ed2b7937de9 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:27:36 +0000 Subject: [PATCH 12/12] test(net): cover map_hyper_err mid-stream body-error path Addresses a CodeRabbit review nitpick on #90: the streaming body-error mapper had no test; only the send-phase mapper (map_legacy_err) did. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapter/net/http/hyper/src/leaf.rs | 63 +++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/adapter/net/http/hyper/src/leaf.rs b/crates/adapter/net/http/hyper/src/leaf.rs index 2da5fde..3b6d095 100644 --- a/crates/adapter/net/http/hyper/src/leaf.rs +++ b/crates/adapter/net/http/hyper/src/leaf.rs @@ -111,6 +111,7 @@ mod tests { use oath_adapter_net_http_api::Service; use std::convert::Infallible; use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; fn test_conn() -> ConnConfig { @@ -217,6 +218,68 @@ mod tests { ); } + // A raw-TCP server (not hyper's `service_fn`) that speaks a valid response + // head promising a body longer than what it actually sends, then closes the + // connection mid-body. This makes the error surface while the body is being + // *streamed*, after the response head has already arrived successfully — so + // it exercises `map_hyper_err` (the streaming-body mapper), not + // `map_legacy_err` (the send/response-head mapper). + async fn spawn_truncating_server() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + + // Drain the request head so the client's write side completes cleanly. + let mut buf = Vec::new(); + let mut chunk = [0_u8; 256]; + loop { + let n = stream.read(&mut chunk).await.unwrap(); + assert!(n > 0, "peer closed before sending a full request head"); + buf.extend_from_slice(&chunk[..n]); + if buf.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + + // Promise 100 bytes of body, deliver 7, then drop the connection. + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\npartial") + .await + .unwrap(); + stream.flush().await.unwrap(); + drop(stream); + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn truncated_response_body_surfaces_as_other() { + let base = spawn_truncating_server().await; + let leaf = hyper_leaf(test_conn()); + let req = http::Request::get(format!("{base}/")) + .body(Bytes::new()) + .unwrap(); + + // The head is complete and valid, so the send phase (`map_legacy_err`) + // succeeds — proving the subsequent failure comes from the body stream. + let resp = leaf.call(req).await.expect("response head must arrive"); + assert_eq!(resp.status(), http::StatusCode::OK); + + // Unlike the send-phase tests above, the collected `Ok` payload + // (`Collected`) *is* `Debug`, so clippy requires `expect_err` + // here instead of the `.err().expect()` workaround. + let err = resp + .into_body() + .collect() + .await + .expect_err("truncated body must error"); + assert!( + matches!(err, oath_adapter_net_http_api::HttpError::Other(_)), + "expected Other, got {err:?}" + ); + } + // Full TLS path: rcgen self-signed cert → rustls server on loopback → a // HyperLeaf whose connector trusts exactly that cert. Exercises the real // aws-lc-rs handshake + webpki verification in CI (custom root, not webpki-roots).