docs(arch): stream-client split + total-bytes cap for subscription forwards (FWD-15, FWD-14)

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.
This commit is contained in:
2026-08-30 12:01:59 +00:00
parent 7f89db1058
commit 18c78acc79
2 changed files with 57 additions and 9 deletions
+47 -8
View File
@@ -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<ResponseEnvelope>` (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<ResponseEnvelope>` (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
+10 -1
View File
@@ -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)