fix(gateway): SSE terminality, deadline, error-mapping fidelity (GW-03..GW-07, GW-12..GW-14)

- GW-04: /subscribe error events are terminal — scan-based emission of
  the error frame, then end of stream (matches call.error semantics).
- GW-05: enforce the 30 s gateway deadline via tokio::time::timeout in
  GatewayDispatch::invoke; hung handlers surface as TIMEOUT (504),
  streaming/sink stay unbounded per ADR-021.
- GW-07: gateway error paths route through the new identity-aware
  call_error_to_http_response_with_identity; retryable HTTP_429/HTTP_503
  now carry Retry-After on /call, /batch, /search, /schema, /publish.
- GW-13: SSE keep-alive (15 s comment frames) + retry: 15000 field.
- GW-14: module doc fixed (6 endpoints; /publish lives in routes.rs).
- GW-03/GW-12: mapping rides d7ee302's INVALID_OPERATION_TYPE mapper
  (documented in http-server.md table); 200-on-stream asymmetry
  documented.

Verification: cargo test (243 passed); cargo clippy --all-targets -- -D
warnings clean; cargo fmt --check clean.
This commit is contained in:
2026-08-29 10:13:03 +00:00
parent 713f1eba46
commit 9bc9e669e1
5 changed files with 422 additions and 45 deletions
+43 -8
View File
@@ -215,18 +215,38 @@ the response is `text/event-stream` (negotiated via
security invariants are identical to `invoke()`: `internal: false`,
`forwarded_for: None`, same capabilities, same `scoped_env`, same ACL
check before dispatch. The two methods diverge only on the return shape
(stream vs single envelope).
(stream vs single envelope). Streaming invokes set `deadline: None`
subscriptions are unbounded by contract, unlike the 30 s gateway
deadline on Once-op invokes (see Error Mapping below).
- For each `ResponseEnvelope` the stream yields, writes an SSE `data:` frame:
`Ok(value)``data:` frame with the output serialized as JSON; `Err`
SSE error event with the `CallError` serialized, then close (an `Err` is
terminal — the stream ends after it, matching the wire protocol's
`call.error` semantics).
`call.error` semantics; the gateway enforces this with a scan-based
framing that emits the error frame and terminates the stream rather
than continuing after it).
- On natural stream end (the streaming handler's stream completes), closes
the SSE stream (normal end — corresponds to `call.completed` on the wire).
- On `call.aborted` or HTTP client disconnect (detected as the response
writer closing), drops the stream future — `Drop` guards release the
handler's resources, and the abort cascade runs per alkcall ADR-020
"Abort Cascade for Nested Calls".
- Sends SSE keep-alive comment frames on quiet streams every 15 s and
carries a `retry: 15000` field on stream events (a client that loses
the stream reconnects after 15 s). Quiet-but-alive is the normal state
for subscriptions; without keep-alive, LB/proxy idle timeouts
(30-60 s) silently terminate the connection.
**Status-code asymmetry on `/subscribe` (GW-12).** Because the SSE
response is committed as HTTP `200` before the stream runs,
pre-dispatch failures on `/subscribe` (unknown op, Internal op, ACL
denial) surface as HTTP 200 + an `event:error` frame with the mapped
`CallError` — while the same denial on `/call` surfaces as HTTP 401/403
(ADR-049 makes errors-on-the-stream defensible: the response is the
stream). Standard HTTP monitoring sees no auth failures on
`/subscribe`; clients must inspect `event:error` frames. This
asymmetry is deliberate, not an oversight, and applies to all stream
runs — the OpenAPI projection documents the same behavior (PRJ-05).
This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket
([websocket.md](websocket.md)), the subscription projects directly onto
@@ -339,18 +359,33 @@ Schemas") map to HTTP status codes:
| `NOT_FOUND` (operation not registered, or Internal op) | `404` | |
| `FORBIDDEN` (insufficient scopes, or unauthenticated) | `401` (no token) / `403` (token present) | |
| `INVALID_INPUT` (schema mismatch) | `422` | |
| `INVALID_OPERATION_TYPE` (wrong dispatch path for the op's type) | `422` (token present) / `401` (no token) | consistent across `/call`, `/batch`, `/publish` — a client fault, never a server fault |
| `TIMEOUT` | `504` | `retryable: true` |
| `INTERNAL` | `500` | |
| Operation-level domain code with `http_status` (alkcall ADR-016) | the declared `http_status` | `from_openapi`-imported ops carry the original status |
| Operation-level domain code without `http_status` | `500` | |
The `retryable` field from `CallError` maps to an HTTP `Retry-After`
hint for `503`/`429`-class errors. The mapping is a two-way-door
default (the exact status for ambiguous codes can be refined
additively); the one-way constraint is that protocol-level and
operation-level codes are distinct (alkcall ADR-016) and
`from_openapi`-imported codes are prefixed `HTTP_<status>` to avoid
collision with protocol codes.
hint for `429`/`503`-class statuses: the value comes from the error's
`details.retry_after` (string or number seconds). All live gateway
error paths (`/call`, `/batch` entries, `/search`, `/schema`,
`/publish`) route through the shared identity-aware mapper
(`call_error_to_http_response_with_identity`), so a retryable
`HTTP_429`/`HTTP_503` from a handler carries `Retry-After` on every
endpoint. The mapping is a two-way-door default (the exact status for
ambiguous codes can be refined additively); the one-way constraint is
that protocol-level and operation-level codes are distinct (alkcall
ADR-016) and `from_openapi`-imported codes are prefixed `HTTP_<status>`
to avoid collision with protocol codes.
**Per-endpoint dispatch deadline.** Once-op invokes (`/call`, `/batch`
entries, `/search`, `/schema`, and the `/publish` final envelope) are
bounded by a 30 s gateway deadline (`GatewayDispatch::invoke` wraps the
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).
### `/healthz` (raw route)