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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user