From 3327d585dabeeec85f23b1bf3338ca95f840e4d3 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Tue, 30 Jun 2026 08:02:30 +0000 Subject: [PATCH] =?UTF-8?q?docs(http):=20resolve=20OQ-40=20reqwest=20clien?= =?UTF-8?q?t=20config=20=E2=80=94=20ClientWithMiddleware=20+=20retry/retry?= =?UTF-8?q?-after=20middleware=20stack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OQ-40 resolved: alknet-http owns a shared reqwest_middleware::ClientWithMiddleware (not a bare reqwest::Client) with a two-layer middleware stack — RetryTransientMiddleware (reqwest-retry, exponential backoff on transient failures) + inlined RetryAfterMiddleware (from melotic/reqwest-retry-after, MIT, ~50 lines, inlined to bound the upstream's unbounded HashMap storage). The two are complementary: reqwest-retry's default strategy does not honor Retry-After. Hot-reload is rebuild-and-swap via ArcSwap (same pattern as ConfigIdentityProvider, ADR-035); a rebuild drops the connection pool, which is acceptable since a config change wanting a fresh pool is the trigger. The three one-way constraints stand unchanged: alknet-http owns its client (no env-var config, no shared global), credentials inject per-request from OperationContext.capabilities, outbound TLS uses the system trust store. Records the downstream layering boundary: the agent crate's provider SSE normalization (the solid part of aisdk's pattern — Vercel-UI-message normalization) sits on top of this client, consuming the reqwest::Response stream; it does not replace the client. The aisdk core/client.rs reference for client construction is dropped (env-var config + hand-rolled retry are the anti-patterns discarded); the from_openapi.ts SSE normalization reference in the forwarding-handler section is kept (separate, solid pattern). No ADR — the decision is internal to alknet-http: the client type does not cross crate boundaries (alknet-call never sees reqwest), the library choice is reversible, and it does not touch the system's structure, constraints, or cross-crate API surface. Updates: http-adapters.md (HTTP client section rewritten, references updated, constraints/OQ bullets updated), http-mcp.md (OQ-40 status flip), open- questions.md (OQ-40 resolved with full config-shape table), README.md (OQ-40 folded into the existing two-way-doors bucket), and three secondary docs (crates/http/README.md, overview.md, http-server.md) that carried stale 'open' OQ-40 references. --- docs/architecture/README.md | 4 +- docs/architecture/crates/http/README.md | 2 +- .../architecture/crates/http/http-adapters.md | 106 +++++++++++++----- docs/architecture/crates/http/http-mcp.md | 5 +- docs/architecture/crates/http/http-server.md | 6 +- docs/architecture/crates/http/overview.md | 4 +- docs/architecture/open-questions.md | 65 +++++++---- 7 files changed, 137 insertions(+), 55 deletions(-) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 313557f..49e5323 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-06-29 +last_updated: 2026-06-30 --- # Alknet Architecture @@ -127,6 +127,7 @@ See [open-questions.md](open-questions.md) for the full tracker. - **OQ-22**: Key rotation — version-indexed derivation paths; `rotate` method re-encrypts (ADR-021) - **OQ-23**: Handler identity registration path — registration bundle with provenance, composition authority, scoped env, capabilities (ADR-022) - **OQ-24**: Operation error schemas — declared domain errors with typed `details` payload; adapter fidelity for `from_openapi`/`to_openapi` (ADR-023) +- **OQ-40**: reqwest client config and connection pooling — `ClientWithMiddleware` + `RetryTransientMiddleware` + inlined `RetryAfterMiddleware`; rebuild-and-swap hot-reload; per-request credential injection; agent-crate SSE normalization sits on top of the client, doesn't replace it **Resolved by the storage/repo-pattern ADRs (ADR-030–033):** - **OQ-33**: ~~PeerId stability~~ — **resolved by ADR-030** (logical id; source is `Identity.id` = `PeerEntry.peer_id`, stable across key rotation; UUID workaround removed) @@ -146,7 +147,6 @@ See [open-questions.md](open-questions.md) for the full tracker. - **OQ-37**: ~~X.509 outgoing-only case~~ — **resolved by ADR-034** (three remote roles named: public X.509 endpoint, transport relay, hub; `PeerEntry` asymmetry is correct; client-side verifier selection by `PeerEntry` presence) - **OQ-38**: WebTransport standalone relay service scope — the standalone relay (future `alknet-relay`, fork of iroh-relay with WebTransport proxy fallback) is distinct from the in-process ALPN-stream-proxy (ADR-040); scope question, not deferral - **OQ-39**: `to_openapi` published-spec versioning — versioning strategy for generated OpenAPI specs (one-way after first publication) -- **OQ-40**: reqwest client config and connection pooling — two-way-door config shape for the outbound HTTP client **Deferred (not active):** - **OQ-09**: WASM target boundaries — design constraint, not deliverable diff --git a/docs/architecture/crates/http/README.md b/docs/architecture/crates/http/README.md index 9ce827e..8cd9c3a 100644 --- a/docs/architecture/crates/http/README.md +++ b/docs/architecture/crates/http/README.md @@ -62,7 +62,7 @@ protocol), and hosts the HTTP-backed call-protocol adapters | OQ-37 | X.509 outgoing-only / three peer roles | resolved | Browsers are not peers; hub with mixed fingerprints | | OQ-38 | WebTransport standalone relay service scope | open (scope, not deferral) | The standalone relay (future `alknet-relay`, fork of iroh-relay) — distinct from the in-process ALPN-stream-proxy (ADR-040) | | OQ-39 | `to_openapi` published-spec versioning | open | Versioning strategy for generated OpenAPI specs | -| OQ-40 | reqwest client config and connection pooling | open | Two-way-door: pooling/retry config shape | +| OQ-40 | reqwest client config and connection pooling | resolved | `ClientWithMiddleware` + `RetryTransientMiddleware` + inlined `RetryAfterMiddleware`; rebuild-and-swap hot-reload | ## Key Design Principles diff --git a/docs/architecture/crates/http/http-adapters.md b/docs/architecture/crates/http/http-adapters.md index 988e799..079cb35 100644 --- a/docs/architecture/crates/http/http-adapters.md +++ b/docs/architecture/crates/http/http-adapters.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-06-29 +last_updated: 2026-06-30 --- # HTTP Adapters — from_openapi and to_openapi @@ -130,7 +130,7 @@ The forwarding handler is the `Arc` stored in the - Headers: `Content-Type: application/json` + the auth header built from `context.capabilities` (see No-Env-Vars below). - Body: the `body` field of the input (for `Mutation`/`Subscription`). -3. Sends the request via the shared `reqwest::Client` (see HTTP Client +3. Sends the request via the shared HTTP client (see HTTP Client below). 4. For a `Query`/`Mutation`: parses the response body (JSON, text, or binary — same content-type branching as the TS `createHTTPOperation`), @@ -146,22 +146,69 @@ the registry dispatches. `alknet-call` never sees `reqwest`. ### HTTP client (reqwest) -`alknet-http` maintains a shared `reqwest::Client` (constructed once, -reused across all `from_openapi`/`from_mcp` forwarding handlers). The -client handles connection pooling, keep-alive, and TLS. The aisdk -`core/client.rs` reference shows the pattern worth referencing: a shared -client with `OnceLock`, retry logic (exponential -backoff, `Retry-After` header), and separate streaming vs non-streaming -clients. `alknet-http` owns its HTTP client; it does not inherit aisdk's. +`alknet-http` maintains a shared HTTP client, constructed once and reused +across all `from_openapi`/`from_mcp` forwarding handlers. The client owns +connection pooling, keep-alive, TLS, and a retry stack. The shared type is +`reqwest_middleware::ClientWithMiddleware`, not a bare `reqwest::Client` — +both retry and Retry-After are middleware on the stack, and middleware +requires the `ClientWithMiddleware` wrapper. -The retry/pooling config comes from `StaticConfig` or `DynamicConfig` -(hot-reloadable). The credential injection happens per-request (from -`OperationContext.capabilities`), not at client construction — the -client is shared across all operations, the credentials are per-call. +The middleware stack has two layers: -The exact pooling/retry config is a two-way-door implementation detail -(OQ-40); the one-way constraint is that `alknet-http` owns its `reqwest` -client (no env-var-based client config, no shared global client). +1. **`RetryTransientMiddleware`** (from `reqwest-retry`) — exponential + backoff on transient failures (connection errors, 5xx). The "retry N + times with increasing intervals" part. Configured via an + `ExponentialBackoff` policy at client construction. +2. **Inlined `RetryAfterMiddleware`** — parses the `Retry-After` header + on 429/503 and sleeps before the next request to that URL. The + "respect what the server told you" part. Inlined (MIT, ~50 lines of + real logic) from `melotic/reqwest-retry-after`, not pulled as a + dependency: the crate is complementary to `reqwest-retry` (whose + default strategy does not honor `Retry-After`), and inlining lets + the upstream's unbounded `HashMap` storage be + bounded for a long-running process. + +Pooling, keep-alive, and TLS come from `reqwest::ClientBuilder` defaults; +outbound TLS uses the system trust store (standard HTTPS to external APIs +like OpenAI, Anthropic). Custom CA bundle + client certs are an optional +config for self-hosted API gateways (two-way-door implementation detail; +the credential comes from `Capabilities`, the TLS trust comes from the +system). + +Credential injection happens per-request (from +`OperationContext.capabilities`), not at client construction — the client +is shared across all operations, the credentials are per-call. + +Hot-reload of the pooling/retry config is **rebuild-and-swap**: a config +change rebuilds the `ClientWithMiddleware` and swaps it via `ArcSwap` +(the same pattern `ConfigIdentityProvider` uses, ADR-035). A rebuild +drops the connection pool / keep-alive state, which is acceptable — a +config change wanting a fresh pool is the case that triggers it. The +retry policy is baked into the middleware at `ClientBuilder::build()` +time; live policy mutation is not supported by `reqwest-retry`, so cheap +per-policy updates are not part of the model. + +The exact pooling/retry config (pool size, retry count, timeout +defaults, hot-reloadability via `DynamicConfig`) is a two-way-door +implementation detail (OQ-40, now resolved); the one-way constraint is +that `alknet-http` owns its HTTP client (no env-var-based client config, +no shared global client). + +**Downstream layering boundary.** The agent crate's provider SSE +normalization (replicating the solid part of aisdk's pattern — the +Vercel-UI-message normalization that maps different providers' SSE to a +common shape) sits on top of this `ClientWithMiddleware`: it consumes the +`reqwest::Response` stream the forwarding handler produces and emits +`call.responded` events. It does not replace the client or own +transport/pooling/retry. `alknet-http` owns transport; the agent crate +owns provider-specific SSE → Vercel-UI-message mapping. The aisdk +`core/client.rs` reference for HTTP client construction is *not* carried +forward — its env-var config and hand-rolled retry are the anti-patterns +discarded in favor of the middleware stack above. The +`@alkdev/operations/src/from_openapi.ts` SSE *normalization* pattern is +separate and stays referenced in the Forwarding Handler section above +(the `parseSSEFrames`, `createHTTPOperation`, content-type branching +patterns). ### No-Env-Vars credential injection @@ -332,9 +379,12 @@ once published, the 5-endpoint gateway shape is one-way. generated spec's versioning (tied to the registry's `External` operation set version) must be emitted so consumers can detect mapping changes (ADR-017 Consequences, OQ-39). -- **`alknet-http` owns its `reqwest::Client`.** Shared across all - forwarding handlers, constructed once. No env-var-based client config. - Pooling/retry config is a two-way door (OQ-40). +- **`alknet-http` owns its HTTP client.** Shared across all forwarding + handlers, constructed once. The shared type is + `reqwest_middleware::ClientWithMiddleware` (middleware stack: + `RetryTransientMiddleware` + inlined `RetryAfterMiddleware`). No + env-var-based client config. Pooling/retry config is a two-way door, + resolved in OQ-40. - **TLS for outbound calls uses the system trust store by default.** Standard HTTPS to external APIs (OpenAI, Anthropic). Custom CA bundle + client certs are an optional config for self-hosted API gateways. @@ -363,9 +413,10 @@ See [open-questions.md](../../open-questions.md) for full details. versioning strategy for generated OpenAPI specs (tied to the registry's `External` operation set version). One-way after first publication. -- **OQ-40** (open): reqwest client config and connection pooling — - two-way-door: the exact pooling/retry config shape, hot-reloadable - via `DynamicConfig`. +- **OQ-40** (resolved): reqwest client config and connection pooling — + `ClientWithMiddleware` + `RetryTransientMiddleware` + inlined + `RetryAfterMiddleware`; rebuild-and-swap hot-reload; per-request + credential injection. Two-way-door config shape, now resolved. ## References @@ -379,6 +430,11 @@ See [open-questions.md](../../open-questions.md) for full details. `OperationAdapter` trait, `AdapterError` variants (OQ-26), no-env-vars invariant - `/workspace/@alkdev/operations/src/from_openapi.ts` — TypeScript prior - art (parsing, SSE, auth headers, `createHTTPOperation`) -- `/workspace/aisdk/src/core/client.rs` — HTTP client reference (pooling, - retry, streaming vs non-streaming) \ No newline at end of file + art (parsing, SSE, auth headers, `createHTTPOperation`, + `parseSSEFrames` — the SSE normalization patterns, not the client + construction) +- `reqwest-retry` crate (https://docs.rs/reqwest-retry/) — + `RetryTransientMiddleware` / `ExponentialBackoff` retry policy +- `melotic/reqwest-retry-after` + (https://github.com/melotic/reqwest-retry-after) — `RetryAfterMiddleware` + source (MIT, inlined, not a dependency) \ No newline at end of file diff --git a/docs/architecture/crates/http/http-mcp.md b/docs/architecture/crates/http/http-mcp.md index 62e2132..5951465 100644 --- a/docs/architecture/crates/http/http-mcp.md +++ b/docs/architecture/crates/http/http-mcp.md @@ -277,8 +277,9 @@ every other HTTP request. See [open-questions.md](../../open-questions.md) for full details. -- **OQ-40** (open): reqwest client config — the shared `reqwest::Client` - used by `from_mcp` (same client as `from_openapi`). +- **OQ-40** (resolved): reqwest client config — the shared + `ClientWithMiddleware` used by `from_mcp` (same client as + `from_openapi`). ## References diff --git a/docs/architecture/crates/http/http-server.md b/docs/architecture/crates/http/http-server.md index c11cd0a..b6163e3 100644 --- a/docs/architecture/crates/http/http-server.md +++ b/docs/architecture/crates/http/http-server.md @@ -394,9 +394,9 @@ See [open-questions.md](../../open-questions.md) for full details. - **OQ-39** (open): `to_openapi` published-spec versioning — the generated OpenAPI spec is a compatibility contract (ADR-017 Consequences); the versioning strategy needs specifying. -- **OQ-40** (open): reqwest client config and connection pooling — - two-way-door config shape for the outbound HTTP client used by - `from_openapi`/`from_mcp`. +- **OQ-40** (resolved): reqwest client config and connection pooling — + `ClientWithMiddleware` + middleware stack; the outbound HTTP client + used by `from_openapi`/`from_mcp`. ## References diff --git a/docs/architecture/crates/http/overview.md b/docs/architecture/crates/http/overview.md index 641d354..2857c5a 100644 --- a/docs/architecture/crates/http/overview.md +++ b/docs/architecture/crates/http/overview.md @@ -274,8 +274,8 @@ See [open-questions.md](../../open-questions.md) for full details. live in `alknet-http` or a separate relay crate? - **OQ-39** (open): `to_openapi` published-spec versioning — versioning strategy for generated OpenAPI specs. -- **OQ-40** (open): reqwest client config and connection pooling — - two-way-door config shape. +- **OQ-40** (resolved): reqwest client config and connection pooling — + `ClientWithMiddleware` + middleware stack (retry + Retry-After). ## References diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index a00e4a2..b642d99 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-06-28 +last_updated: 2026-06-30 --- # Open Questions @@ -836,27 +836,52 @@ is a feature extension, not an unmade architecture decision. - **Origin**: [http-adapters.md](crates/http/http-adapters.md), [http-mcp.md](crates/http/http-mcp.md), the alknet-http Phase 0 findings DH-7 -- **Status**: open +- **Status**: resolved (2026-06-30) - **Door type**: Two-way - **Priority**: low -- **Resolution**: `alknet-http` maintains a shared `reqwest::Client` - (constructed once, reused across all `from_openapi`/`from_mcp` - forwarding handlers) with connection pooling, keep-alive, and TLS. - The aisdk `core/client.rs` reference shows the pattern worth - referencing: `OnceLock`, retry logic (exponential - backoff, `Retry-After` header), and separate streaming vs - non-streaming clients. +- **Resolution**: `alknet-http` owns a shared HTTP client constructed + once and reused across all `from_openapi`/`from_mcp` forwarding + handlers. The client carries connection pooling, keep-alive, TLS, + and a retry stack. The config shape is: - The exact pooling/retry config (pool size, retry policy, timeout - defaults, hot-reloadability via `DynamicConfig`) is a two-way-door - implementation detail. The one-way constraints are: (1) - `alknet-http` owns its `reqwest::Client` (no env-var-based client - config, no shared global client), (2) credential injection happens + | Aspect | Decision | + |--------|----------| + | Shared client type | `reqwest_middleware::ClientWithMiddleware` (not a bare `reqwest::Client`) — required because both retry and Retry-After are middleware on the stack | + | Middleware stack | `RetryTransientMiddleware` (from `reqwest-retry` — exponential backoff on transient failures: connection errors, 5xx) + inlined `RetryAfterMiddleware` (parses the `Retry-After` header on 429/503 and sleeps before the next request to that URL) | + | `Retry-After` handler | Inlined from `melotic/reqwest-retry-after` (MIT, ~50 lines of real logic). The crate is complementary to `reqwest-retry`, not a replacement — `reqwest-retry`'s default strategy does not honor `Retry-After`, which is why the separate middleware exists. Inlining lets the unbounded `HashMap` storage in the upstream crate be bounded (the melotic version grows without limit over a long-running process). | + | Pooling / keep-alive / TLS | `reqwest::ClientBuilder` defaults; system trust store for outbound HTTPS (standard calls to OpenAI, Anthropic, etc.) | + | Hot-reload | Rebuild-and-swap the `ClientWithMiddleware` via `ArcSwap` (same pattern as `ConfigIdentityProvider`, ADR-035). A rebuild drops the connection pool / keep-alive state — acceptable, since a config change wanting a fresh pool is the case that triggers it. Retry policy is baked into the middleware at `ClientBuilder::build()` time; live policy mutation is not supported by `reqwest-retry` (no cheap per-policy update path exists). | + | Credentials | Per-request from `OperationContext.capabilities` — see the one-way constraints below | + + The one-way constraints (settled before this OQ, restated unchanged): + (1) `alknet-http` owns its HTTP client — no env-var-based client + config, no shared global client; (2) credential injection happens per-request (from `OperationContext.capabilities`), not at client - construction (the client is shared across all operations, the - credentials are per-call), and (3) TLS for outbound calls uses the + construction — the client is shared across all operations, the + credentials are per-call; (3) TLS for outbound calls uses the system trust store by default (custom CA bundle + client certs are - an optional config for self-hosted API gateways). This OQ tracks the - two-way-door config shape; the constraints are settled. -- **Cross-references**: ADR-014, ADR-017, - [http-adapters.md](crates/http/http-adapters.md) \ No newline at end of file + an optional config for self-hosted API gateways). + + **Downstream layering boundary (so the agent crate doesn't + accidentally re-invent a client).** The agent crate's provider SSE + normalization (replicating the solid part of aisdk's pattern — the + Vercel-UI-message normalization that maps different providers' SSE + to a common shape) sits *on top of* this `ClientWithMiddleware`: it + consumes the `reqwest::Response` stream the forwarding handler + produces and emits `call.responded` events. It does not replace the + client or own transport/pooling/retry. `alknet-http` owns transport; + the agent crate owns provider-specific SSE → Vercel-UI-message + mapping. The aisdk `core/client.rs` reference for HTTP client + construction is *not* carried forward — its env-var config and + hand-rolled retry are the anti-patterns being discarded; the + aisdk/`@alkdev/operations/src/from_openapi.ts` SSE *normalization* + pattern is separate and stays referenced in the forwarding-handler + section of [http-adapters.md](crates/http/http-adapters.md). + + No ADR — the decision is internal to `alknet-http`: the client type + does not cross crate boundaries (`alknet-call` never sees reqwest), + the library choice is reversible, and it does not touch the + system's structure, constraints, or API surface across crates. +- **Cross-references**: ADR-014, ADR-017, ADR-035, + [http-adapters.md](crates/http/http-adapters.md), + [http-mcp.md](crates/http/http-mcp.md) \ No newline at end of file