From 18c78acc79260c29df46828e133bb8cfa81628a2 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 30 Aug 2026 12:01:59 +0000 Subject: [PATCH] docs(arch): stream-client split + total-bytes cap for subscription forwards (FWD-15, FWD-14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http-adapters.md: forwarding-handler step 5 documents the time/bytes split for Sub forwards (stream client without the total request timeout, total streamed-bytes cap, read timeout as the staleness guard); the HTTP Client section documents the two derived clients (request vs stream), the reqwest 0.13 per-request-override limitation that forces the derived-client design, and that only the total timeout differs between them. http-server.md: the per-endpoint dispatch deadline paragraph now covers the outbound half — unbounded time, bounded bytes on both the gateway and the forwarding side (ADR-049/021 note: unbounded time by design for subscriptions, bounded bytes per subscription). Verified: cargo doc --no-deps clean, full suite green. --- docs/architecture/http-adapters.md | 55 +++++++++++++++++++++++++----- docs/architecture/http-server.md | 11 +++++- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/architecture/http-adapters.md b/docs/architecture/http-adapters.md index 7ca18d7..2ead72e 100644 --- a/docs/architecture/http-adapters.md +++ b/docs/architecture/http-adapters.md @@ -181,14 +181,30 @@ The forwarding handler is stored in the `HandlerRegistration` as a wraps it in a `ResponseEnvelope`, returns. Registered as `HandlerKind::Once` — a `Handler` returning a single `ResponseEnvelope`. -5. For a `Sub` (`text/event-stream` response): streams - `call.responded` events as the SSE chunks arrive (same SSE parsing as - the TS `parseSSEFrames`), then the stream ends on SSE close (which - becomes `call.completed` on the wire). Registered as - `HandlerKind::Stream` — a `StreamingHandler` returning a - `BoxStream` (alkcall ADR-021). Each SSE `data:` - frame becomes a `ResponseEnvelope::ok()`; an HTTP error (non-2xx) - becomes a single `ResponseEnvelope::error()` and ends the stream. + 5. For a `Sub` (`text/event-stream` response): streams + `call.responded` events as the SSE chunks arrive (same SSE parsing as + the TS `parseSSEFrames`), then the stream ends on SSE close (which + becomes `call.completed` on the wire). Registered as + `HandlerKind::Stream` — a `StreamingHandler` returning a + `BoxStream` (alkcall ADR-021). Each SSE `data:` + frame becomes a `ResponseEnvelope::ok()`; an HTTP error (non-2xx) + becomes a single `ResponseEnvelope::error()` and ends the stream. + The streaming send rides the shared HTTP client's + **stream client** (see HTTP Client below): the same config minus + the total request timeout (FWD-15) — a subscription is unbounded in + *time* by contract (alkcall ADR-021 sets `deadline: None`), so the + outbound half must not die at the 30 s request deadline the + request/response half carries. Time-unbounded does not mean + memory-unbounded: the SSE parse loop enforces a **total + streamed-bytes cap** per subscription + (`HttpClientConfig.stream_total_byte_cap`, default 1 GiB), + accumulated across every chunk fed to the parser (FWD-14); + exceeding it terminates the stream with a single terminal error + envelope (`HTTP_413`), the same stream-ends semantics as the other + terminal arms. The bounded-time companion is the client's read + timeout, which stays armed on the stream client: it bounds + upstream *staleness* (no byte for 30 s → terminal), not stream + lifetime. 6. On HTTP error (non-2xx): maps to the declared `ErrorDefinition` by HTTP status code (see Error Fidelity below), returns a `CallError`. @@ -205,6 +221,29 @@ connection pooling, keep-alive, TLS, and a retry stack. The shared type is both retry and Retry-After are middleware on the stack, and middleware requires the `ClientWithMiddleware` wrapper. +The shared `SharedHttpClient` exposes **two derived clients** from one +config, rebuilt-and-swapped together (the same atomicity rule as the +config itself): + +- **Request client** (`SharedHttpClient::client`) — carries the total + request timeout (default 30 s, anchored to the gateway's Once-op + deadline). Every request/response forward (`from_openapi`/ + `from_jsonschema` Queries and Mutations) sends through it. +- **Stream client** (`SharedHttpClient::stream_client`) — built from + the same config with the total request timeout removed and the + connect + read timeouts retained (FWD-15). Streaming (SSE) + subscription forwards send through it. reqwest 0.13's per-request + timeout override can lengthen a client-level total timeout but never + clear it (the request-scoped `None` falls back to the client + default), so a timeout-free subscription send requires the derived + client rather than a request extension. Unbounded *time* per + subscription is the contract (alkcall ADR-021); the complementary + bounds are the read timeout (staleness) and the total streamed-bytes + cap (see the forwarding-handler step 5 above). + +Both clients carry the same middleware stack, redirect policy, and TLS +trust — the total request timeout is the only delta between them. + The middleware stack has two layers: 1. **`RetryTransientMiddleware`** (from `reqwest-retry`) — exponential diff --git a/docs/architecture/http-server.md b/docs/architecture/http-server.md index 9dc3c58..5e95795 100644 --- a/docs/architecture/http-server.md +++ b/docs/architecture/http-server.md @@ -385,7 +385,16 @@ registry invoke in `tokio::time::timeout`); a hung handler surfaces as a `TIMEOUT` error (`504`, `retryable: true`), not an indefinitely-held HTTP request. Streaming invokes (`/subscribe`) are unbounded — subscriptions are long-lived by contract (alkcall ADR-021 sets -`deadline: None` for the streaming branch). +`deadline: None` for the streaming branch). The same time/bytes split +governs the **outbound** half of an imported subscription: +`from_openapi`/`from_jsonschema` SSE forwards send through the shared +HTTP client's stream client, built without the total request timeout +(FWD-15) so a healthy subscription is not killed at the 30 s outbound +deadline, while a total streamed-bytes cap +(`HttpClientConfig::stream_total_byte_cap`, default 1 GiB) bounds the +bytes a single subscription may pull (FWD-14) and the client's read +timeout remains the upstream-staleness guard. Unbounded time, bounded +bytes — on both the gateway and the forwarding side. ### `/healthz` (raw route)