fix(gateway): unify INVALID_INPUT to 422 on hand-rolled paths + sink deadline (GW-16, GW-17)

GW-16: empty body / malformed first line / missing header fields /
per-line cap / batch over-cap rejections now route through
call_error_to_http_response_with_identity, mapping INVALID_INPUT to
422 — same status as mid-stream chunk errors. One error class, one
status.

GW-17: invoke_sink wraps the registry sink invoke in the same 30 s
tokio::time::timeout the Once-op invoke uses; a hung sink handler
surfaces as a TIMEOUT (504, retryable) error envelope instead of
holding the HTTP connection forever. The sink wrapper bounds the
whole dispatch (chunk pacing included), matching http-server.md's
deadline contract.

Docs: http-server.md error table documents the 422 triggers and the
sink deadline; http-adapters.md batch cap status corrected.

to_openapi: gateway spec version 1.3.0 -> 1.4.0 (ADR-045 minor):
/publish framing faults and /batch cap reject documented at 422 (the
400 slots moved with the runtime); /publish 400 slot removed; 504
description covers the sink dispatch.

Verification: scripts/verify.sh OK (397 tests); cargo test
--all-features OK (513 tests); clippy --all-features --all-targets -D
warnings OK; cargo fmt --check OK.
This commit is contained in:
2026-08-31 01:03:29 +00:00
parent a7f10ed04c
commit ac6b4b6c9a
7 changed files with 191 additions and 77 deletions
+3 -1
View File
@@ -545,7 +545,9 @@ adapter contract from alkcall ADR-022 faithful on the error axis — no
silent dropping of error contracts. The `/batch` endpoint documents no
HTTP 500 (review-002 PRJ-21): every per-entry dispatch failure is an
in-band `results[]` entry; the only HTTP error status is the
request-level cap failure (400). `BatchResultEntry.error` references
request-level cap failure (422; GW-16 unified it with the
`INVALID_INPUT → 422` mapping — it was hand-rolled as 400 before
review-002). `BatchResultEntry.error` references
the `BatchError` component (review-002 PRJ-16b): the serialized
`CallError` as a oneOf over the six protocol-code envelopes plus a
generic arm carrying the operation-declared codes. See alkcall ADR-016.
+11 -7
View File
@@ -217,7 +217,7 @@ the response is `text/event-stream` (negotiated via
check before dispatch. The two methods diverge only on the return shape
(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).
deadline on Once-op and sink 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
@@ -358,7 +358,7 @@ 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_INPUT` (input-data fault) | `422` | one status for every trigger: schema mismatch, `/publish` NDJSON framing faults (empty body, malformed first line, missing `operation`/`chunk`, per-line cap, body-read failure), and the `/batch` over-cap reject (`INVALID_INPUT` is an input-data fault wherever it fires — GW-16 unified the formerly hand-rolled 400s onto this row) |
| `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` | |
@@ -379,11 +379,15 @@ 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 —
entries, `/search`, `/schema`) and sink invokes (the `/publish` final
envelope) are bounded by the 30 s gateway deadline
(`GatewayDispatch::invoke` and `GatewayDispatch::invoke_sink` wrap the
registry invoke in `tokio::time::timeout`, GW-17); a hung handler —
Once or sink — surfaces as a `TIMEOUT` error (`504`, `retryable:
true`), not an indefinitely-held HTTP request. The sink wrapper bounds
the whole dispatch (chunk pacing included), so the final envelope
always arrives, or the deadline trips, within the window. Streaming
invokes (`/subscribe`) are unbounded —
subscriptions are long-lived by contract (alkcall ADR-021 sets
`deadline: None` for the streaming branch). The same time/bytes split
governs the **outbound** half of an imported subscription: