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:
+195
-26
@@ -1,6 +1,17 @@
|
||||
//! The 6 fixed gateway endpoints (`/search`, `/schema`, `/call`,
|
||||
//! `/batch`, `/subscribe`, `/publish`) — the sole HTTP invoke path
|
||||
//! (ADR-042, ADR-047; `/publish` per ADR-068).
|
||||
//!
|
||||
//! Each endpoint delegates to `GatewayDispatch` (the shared dispatch
|
||||
//! spine); auth is the shared `bearer_auth_middleware`; error mapping is
|
||||
//! `gateway::error`. There is no per-operation `POST /{service}/{op}`
|
||||
//! direct-call surface (ADR-047). `/publish` lives in this module too
|
||||
//! (the review-001 "separate module" note is stale).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use alkcall::core::auth::{Identity, IdentityProvider};
|
||||
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
|
||||
@@ -9,7 +20,7 @@ use alkcall::registry::spec::{AccessResult, Visibility};
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{FromRef, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::sse::Event;
|
||||
use axum::response::sse::{Event, KeepAlive};
|
||||
use axum::response::{IntoResponse, Json, Response, Sse};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
@@ -20,7 +31,7 @@ use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::dispatch::GatewayDispatch;
|
||||
use super::error::{call_error_to_http_response, call_error_to_http_status_with_identity};
|
||||
use super::error::call_error_to_http_response_with_identity;
|
||||
use crate::server::auth::ResolvedIdentity;
|
||||
use crate::server::state::RouterState;
|
||||
|
||||
@@ -29,6 +40,12 @@ const SERVICES_SCHEMA: &str = "services/schema";
|
||||
const MAX_BATCH_OPERATIONS: usize = 100;
|
||||
const MAX_PUBLISH_LINE_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// SSE keep-alive interval on `/subscribe` (GW-13). Shared comment
|
||||
/// frames (axum's KeepAlive::default) plus a `retry:` field on the
|
||||
/// stream's first event reconnect the client on drops; 15 s sits under
|
||||
/// the common LB/proxy idle timeouts (30-60 s).
|
||||
const SSE_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
type ByteStream = futures::stream::BoxStream<'static, Result<Bytes, axum::Error>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -168,7 +185,7 @@ pub(crate) async fn subscribe_handler(
|
||||
State(state): State<GatewayState>,
|
||||
ResolvedIdentity(identity): ResolvedIdentity,
|
||||
Json(request): Json<CallRequest>,
|
||||
) -> Sse<SubscribeStream> {
|
||||
) -> Response {
|
||||
let stream = if is_internal_op(&state.registry, &request.operation) {
|
||||
subscribe_stream_internal_error(request.operation)
|
||||
} else {
|
||||
@@ -178,6 +195,12 @@ pub(crate) async fn subscribe_handler(
|
||||
subscribe_stream_from_envelope_stream(envelope_stream)
|
||||
};
|
||||
Sse::new(stream)
|
||||
.keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(SSE_KEEP_ALIVE_INTERVAL)
|
||||
.event(keep_alive_event()),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub type SubscribeStream = BoxStream<'static, Result<Event, Infallible>>;
|
||||
@@ -415,23 +438,56 @@ impl futures::Stream for NdjsonChunkStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// The SSE projection of a streaming envelope (GW-04): each `Ok`
|
||||
/// envelope becomes a `data:` frame carrying the output JSON plus the
|
||||
/// `retry:` reconnect hint; the first `Err` envelope becomes an
|
||||
/// `event:error` frame carrying the serialized `CallError` **and ends
|
||||
/// the stream** — matching the wire dispatcher's `call.error`-is-
|
||||
/// terminal semantics and http-server.md's documented contract
|
||||
/// (an `Err` is terminal; the stream does not continue after it).
|
||||
/// A quiet-but-alive stream is kept alive by axum comment frames at
|
||||
/// `SSE_KEEP_ALIVE_INTERVAL` (GW-13).
|
||||
///
|
||||
/// The `scan` closure yields the error frame and flags the stream done
|
||||
/// (subsequent polls return `None`), so the frame that ends the stream
|
||||
/// is still written — the error event is emitted, not swallowed.
|
||||
fn subscribe_stream_from_envelope_stream(
|
||||
stream: BoxStream<'static, ResponseEnvelope>,
|
||||
) -> SubscribeStream {
|
||||
Box::pin(stream.map(|envelope| match envelope.result {
|
||||
Ok(output) => {
|
||||
let data = serde_json::to_string(&output).unwrap_or_else(|_| "null".to_string());
|
||||
Ok(Event::default().data(data))
|
||||
}
|
||||
Err(error) => {
|
||||
let payload = serde_json::to_value(&error).unwrap_or(Value::Null);
|
||||
let data = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
|
||||
Ok(Event::default().event("error").data(data))
|
||||
}
|
||||
Box::pin(stream.scan(false, |done, envelope| {
|
||||
std::future::ready(if *done {
|
||||
None
|
||||
} else {
|
||||
let item = match envelope.result {
|
||||
Ok(output) => {
|
||||
let data =
|
||||
serde_json::to_string(&output).unwrap_or_else(|_| "null".to_string());
|
||||
Event::default().retry(SSE_KEEP_ALIVE_INTERVAL).data(data)
|
||||
}
|
||||
Err(error) => {
|
||||
*done = true;
|
||||
let payload = serde_json::to_value(&error).unwrap_or(Value::Null);
|
||||
let data =
|
||||
serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
|
||||
Event::default()
|
||||
.event("error")
|
||||
.retry(SSE_KEEP_ALIVE_INTERVAL)
|
||||
.data(data)
|
||||
}
|
||||
};
|
||||
Some(Ok::<_, Infallible>(item))
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
fn subscribe_stream_internal_error(operation: String) -> SubscribeStream {
|
||||
/// The keep-alive comment frame: an SSE comment (colon-prefixed, no
|
||||
/// event/data) plus a `retry:` hint, emitted on quiet streams so
|
||||
/// LB/proxy idle timeouts do not kill the connection.
|
||||
fn keep_alive_event() -> Event {
|
||||
Event::default().retry(SSE_KEEP_ALIVE_INTERVAL)
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_stream_internal_error(operation: String) -> SubscribeStream {
|
||||
Box::pin(stream::once(async move { error_event(&operation) }))
|
||||
}
|
||||
|
||||
@@ -441,13 +497,7 @@ fn envelope_to_response(envelope: ResponseEnvelope, identity: Option<&Identity>)
|
||||
let body = envelope_to_ok_json(&envelope.request_id, &output);
|
||||
(StatusCode::OK, Json(body)).into_response()
|
||||
}
|
||||
Err(error) => {
|
||||
let status_code = call_error_to_http_status_with_identity(&error, identity);
|
||||
let status =
|
||||
StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body = serde_json::to_value(&error).unwrap_or(Value::Null);
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
Err(error) => call_error_to_http_response_with_identity(&error, identity),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,15 +535,12 @@ fn not_found_envelope_json(operation: &str) -> Value {
|
||||
|
||||
fn not_found_response(operation: &str) -> Response {
|
||||
let error = CallError::not_found(operation);
|
||||
call_error_to_http_response(&error)
|
||||
call_error_to_http_response_with_identity(&error, None)
|
||||
}
|
||||
|
||||
fn forbidden_response(message: String, identity: Option<&Identity>) -> Response {
|
||||
let error = CallError::forbidden(message);
|
||||
let status_code = call_error_to_http_status_with_identity(&error, identity);
|
||||
let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body = serde_json::to_value(&error).unwrap_or(Value::Null);
|
||||
(status, Json(body)).into_response()
|
||||
call_error_to_http_response_with_identity(&error, identity)
|
||||
}
|
||||
|
||||
fn access_check_for_op(
|
||||
@@ -799,6 +846,10 @@ mod tests {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// The gateway deadline is the module's real 30 s constant (GW-05
|
||||
// asserts enforcement in dispatch.rs); these SSE tests only touch
|
||||
// streaming ops, which are exempt from it.
|
||||
|
||||
fn auth_header(token: &str) -> (&'static str, String) {
|
||||
("authorization", format!("Bearer {token}"))
|
||||
}
|
||||
@@ -1192,6 +1243,86 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_stream_is_terminal_after_an_error_event() {
|
||||
let router = build_router(
|
||||
registry_with_subscription_stream_continuing_after_error(
|
||||
"events/continue",
|
||||
CallError::internal("mid-stream failure"),
|
||||
),
|
||||
unused_provider(),
|
||||
);
|
||||
let req = json_request(
|
||||
"POST",
|
||||
"/subscribe",
|
||||
json!({ "operation": "events/continue", "input": {} }),
|
||||
);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
let error_events = body.matches("event:").count();
|
||||
assert_eq!(error_events, 1, "exactly one error event, got: {body}");
|
||||
let data_frames = body.matches("data:").count();
|
||||
assert_eq!(
|
||||
data_frames, 1,
|
||||
"the error frame is the last event — no post-error data frames, got: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("\"after\":true"),
|
||||
"the post-error envelope must not reach the wire, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_stream_carries_retry_field_and_keep_alive_comment() {
|
||||
let router = build_router(
|
||||
registry_with_subscription_stream("events/quiet", vec![json!({ "n": 1 })]),
|
||||
unused_provider(),
|
||||
);
|
||||
let req = json_request(
|
||||
"POST",
|
||||
"/subscribe",
|
||||
json!({ "operation": "events/quiet", "input": {} }),
|
||||
);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
body.contains("retry: 15000"),
|
||||
"expected a retry: hint on stream events, got: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(':'),
|
||||
"expected a keep-alive comment frame, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
fn registry_with_subscription_stream_continuing_after_error(
|
||||
name: &str,
|
||||
error: CallError,
|
||||
) -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
subscription_spec(name, Visibility::External, AccessControl::default()),
|
||||
HandlerKind::Stream(make_streaming_handler(move |_input, ctx| {
|
||||
let request_id = ctx.request_id.clone();
|
||||
let error = error.clone();
|
||||
futures::stream::iter(vec![
|
||||
ResponseEnvelope::error(request_id.clone(), error),
|
||||
ResponseEnvelope::ok(request_id, json!({ "after": true })),
|
||||
])
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_response_content_type_is_text_event_stream() {
|
||||
let router = build_router(
|
||||
@@ -1344,6 +1475,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_error_envelope_carries_retry_after_on_retryable_503() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec("flaky/op", AccessControl::default()),
|
||||
HandlerKind::Once(make_handler(|_input, ctx| async move {
|
||||
ResponseEnvelope::error(
|
||||
ctx.request_id,
|
||||
CallError::new("HTTP_503", "overloaded", true)
|
||||
.with_details(json!({ "retry_after": "30" })),
|
||||
)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let router = build_router(Arc::new(registry), unused_provider());
|
||||
let req = json_request(
|
||||
"POST",
|
||||
"/call",
|
||||
json!({ "operation": "flaky/op", "input": {} }),
|
||||
);
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let retry_after = resp
|
||||
.headers()
|
||||
.get(axum::http::header::RETRY_AFTER)
|
||||
.map(|v| v.to_str().unwrap().to_string());
|
||||
assert_eq!(
|
||||
retry_after.as_deref(),
|
||||
Some("30"),
|
||||
"a retryable HTTP_503 from a handler must carry Retry-After on the gateway error path"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_with_leading_slash_in_operation_dispatches() {
|
||||
let router = build_router(registry_with_echo(), unused_provider());
|
||||
|
||||
Reference in New Issue
Block a user