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
+77 -2
View File
@@ -17,6 +17,17 @@
//! The root `OperationContext` is constructed identically for both
//! gateways, making them provably identical on the security axis (auth,
//! authority, ACL); they diverge only on wire-framing.
//!
//! # Deadline
//!
//! Once-op invokes ([`GatewayDispatch::invoke`]) are bounded by
//! `DEFAULT_TIMEOUT` (30 s): the registry invoke is wrapped in
//! `tokio::time::timeout` and a hung handler surfaces as a `TIMEOUT`
//! error envelope (`504` under the gateway's error mapping), not an
//! indefinitely-held HTTP request. Streaming and sink dispatch set
//! `deadline: None` (subscriptions are unbounded per alkcall ADR-021,
//! and a `/publish` body is bounded by the client's upload, not a
//! fixed window).
use std::collections::HashMap;
use std::sync::Arc;
@@ -24,7 +35,7 @@ use std::time::{Duration, Instant};
use alkcall::core::auth::{AuthToken, Identity, IdentityProvider};
use alkcall::core::types::Capabilities;
use alkcall::protocol::wire::ResponseEnvelope;
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
use alkcall::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
use alkcall::registry::env::LocalOperationEnv;
use alkcall::registry::registration::OperationRegistry;
@@ -70,7 +81,20 @@ impl GatewayDispatch {
let operation_name = strip_leading_slash(op).to_string();
let request_id = uuid::Uuid::new_v4().to_string();
let context = self.build_root_context(&request_id, &operation_name, identity);
self.registry.invoke(&operation_name, input, context).await
let result = tokio::time::timeout(
DEFAULT_TIMEOUT,
self.registry.invoke(&operation_name, input, context),
)
.await;
match result {
Ok(envelope) => envelope,
Err(_elapsed) => ResponseEnvelope::error(
request_id,
CallError::timeout(format!(
"operation did not complete within the {DEFAULT_TIMEOUT:?} gateway deadline"
)),
),
}
}
pub fn invoke_streaming(
@@ -186,6 +210,9 @@ mod tests {
use futures::StreamExt;
use std::sync::Mutex as StdMutex;
// The tests below run against the module's real 30 s deadline; only
// the elapsed-time bound (< 60 s, anti-flake) is asserted by hand.
struct StaticIdentityProvider {
tokens: StdMutex<HashMap<String, Identity>>,
}
@@ -287,6 +314,54 @@ mod tests {
}
}
#[tokio::test]
async fn invoke_enforces_the_default_deadline_on_a_hung_handler() {
use std::time::Duration;
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
spec("hung/op", Visibility::External, OperationType::Query),
HandlerKind::Once(make_handler(|_input, ctx| async move {
tokio::time::sleep(Duration::from_secs(120)).await;
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
})),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let dispatch =
GatewayDispatch::new(Arc::new(registry), Arc::new(StaticIdentityProvider::new()));
let started = std::time::Instant::now();
let envelope = dispatch
.invoke(None, "/hung/op", serde_json::json!({}))
.await;
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(60),
"the 30 s deadline must fire well before the 120 s handler sleep, took {elapsed:?}"
);
match envelope.result {
Err(error) => {
assert_eq!(error.code, "TIMEOUT");
assert!(error.retryable, "the deadline error is retryable");
}
Ok(v) => panic!("expected a TIMEOUT error, got {v:?}"),
}
}
#[tokio::test]
async fn invoke_completes_within_the_deadline_for_a_fast_handler() {
let dispatch =
GatewayDispatch::new(echo_registry(), Arc::new(StaticIdentityProvider::new()));
let envelope = dispatch
.invoke(None, "/echo/run", serde_json::json!({}))
.await;
assert!(envelope.result.is_ok(), "a fast handler must not time out");
}
#[tokio::test]
async fn streaming_sub_op_streams_envelopes() {
let mut registry = OperationRegistry::new();