docs(http): resolve OQ-40 reqwest client config — ClientWithMiddleware + retry/retry-after middleware stack
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.
This commit is contained in:
@@ -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<reqwest::Client>`, 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<Url, SystemTime>` 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)
|
||||
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)
|
||||
Reference in New Issue
Block a user