Files
sglang/sgl-model-gateway/tests/reliability/upstream_cancel_test.rs
T
e5589843a3 feature: upstream cancel (#19524)
Co-authored-by: Kangyan Zhou <zky314343421@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:20:09 +08:00

2715 lines
103 KiB
Rust

//! Upstream request cancellation tests
//!
//! Verifies that when a client disconnects mid-stream, the gateway
//! terminates the upstream request to the backend worker promptly
//! (via the `tokio::select!` / `tx.closed()` mechanism in the router).
use std::{sync::Arc, time::Duration};
use axum::{
body::Body,
extract::Request,
http::{header::CONTENT_TYPE, StatusCode},
};
use http_body_util::BodyExt;
use serde_json::json;
use smg::config::RouterConfig;
use tower::ServiceExt;
use crate::common::{
mock_worker::{
clear_fail_status_code, clear_slow_stream_chunks, clear_stream_error_after_chunks,
get_stream_tracking_state, reset_stream_tracker, set_fail_status_code,
set_slow_stream_chunks, set_stream_error_after_chunks, wait_for_stream_finish,
StreamTrackingState, MOCK_STREAM_BUFFER,
},
AppTestContext, TestRouterConfig, TestWorkerConfig,
};
/// Read up to `max_chunks` data frames from a streaming response body.
async fn read_n_chunks(body: &mut Body, max_chunks: usize) -> usize {
let mut chunks_read = 0;
while chunks_read < max_chunks {
match body.frame().await {
Some(Ok(frame)) if frame.is_data() => {
chunks_read += 1;
}
Some(Ok(_)) => continue,
_ => break,
}
}
chunks_read
}
/// Read up to `max_chunks` data frames, returning the count and accumulated
/// bytes so callers can parse the SSE payload (e.g. to capture a `response.id`
/// before dropping the body).
async fn read_n_chunks_with_bytes(body: &mut Body, max_chunks: usize) -> (usize, Vec<u8>) {
let mut chunks_read = 0;
let mut buf: Vec<u8> = Vec::new();
while chunks_read < max_chunks {
match body.frame().await {
Some(Ok(frame)) if frame.is_data() => {
if let Ok(data) = frame.into_data() {
buf.extend_from_slice(&data);
}
chunks_read += 1;
}
Some(Ok(_)) => continue,
_ => break,
}
}
(chunks_read, buf)
}
/// Extract the `id` field from the first `response.created` SSE event in `buf`.
/// Returns `None` if the event hasn't arrived yet (caller should read more).
fn extract_response_id_from_sse(buf: &[u8]) -> Option<String> {
let s = std::str::from_utf8(buf).ok()?;
let data_line = s
.lines()
.find(|l| l.starts_with("data:") && l.contains("\"response.created\""))?;
let json_str = data_line.trim_start_matches("data:").trim();
let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
value
.get("response")
.and_then(|r| r.get("id"))
.and_then(|id| id.as_str())
.map(|s| s.to_string())
}
/// Safety timeout for `wait_for_stream_finish` — the worker notifies the
/// instant its producer task exits, so a healthy run returns well before
/// this. The 3s budget is just a guard against a hung test.
const STREAM_FINISH_TIMEOUT: Duration = Duration::from_secs(3);
async fn assert_cancelled_before_completion(port: u16) -> StreamTrackingState {
let state = wait_for_stream_finish(port, STREAM_FINISH_TIMEOUT)
.await
.unwrap_or_else(|| {
panic!(
"Stream tracking state should exist for worker port {}",
port
)
});
assert!(
!state.completed,
"Stream should NOT have completed - gateway should have cancelled it. \
Chunks sent: {}, total: {}",
state.chunks_sent, state.total_chunks
);
assert!(
state.chunks_sent < state.total_chunks,
"Worker should have sent fewer chunks than total ({} < {}). \
Stream was not cancelled in time.",
state.chunks_sent,
state.total_chunks
);
state
}
#[cfg(test)]
mod upstream_cancel_tests {
use super::*;
/// Test that the gateway cancels the upstream stream when the client
/// disconnects before consuming all chunks.
///
/// Setup:
/// - Mock worker sends 20 chunks with 50ms delay between each (~1s total).
/// - Client reads a few chunks then drops the response body.
///
/// Expectation:
/// - The mock worker stops producing once the gateway closes its
/// upstream connection. We assert that by waiting on the worker's
/// exit notifier (fired when its producer task drops, either via
/// send-failure or natural completion) and snapshotting
/// `chunks_sent` before/after the drop — proving the worker
/// actually halted instead of just being slower than our fixed sleep.
#[tokio::test]
async fn test_streaming_cancel_on_client_disconnect() {
let worker_port = 20250;
let total_chunks: usize = 20;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let config = TestRouterConfig::round_robin(4250);
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 50)])
.await;
let app = ctx.create_app().await;
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "Tell me a long story"}],
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut body = resp.into_body();
let chunks_read = read_n_chunks(&mut body, 3).await;
assert!(
chunks_read > 0,
"Should have read at least one chunk before disconnecting"
);
// Snapshot the worker counter the moment we drop, then wait for
// the producer task to fire its exit notifier. If cancel propagation
// is broken the producer keeps running until total_chunks and the
// counter ends up at `total_chunks`.
let snapshot = get_stream_tracking_state(worker_port)
.map(|s| s.chunks_sent)
.unwrap_or(0);
drop(body);
let final_state = assert_cancelled_before_completion(worker_port).await;
assert!(
final_state.chunks_sent <= snapshot + MOCK_STREAM_BUFFER,
"Chunks_sent grew by more than the channel buffer ({} -> {}); \
gateway likely did not propagate cancel.",
snapshot,
final_state.chunks_sent
);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// Test that a fully consumed stream is NOT cancelled prematurely —
/// the worker sends all chunks and completes normally.
#[tokio::test]
async fn test_streaming_completes_when_client_consumes_all() {
let worker_port = 20251;
let total_chunks: usize = 5;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let config = TestRouterConfig::round_robin(4251);
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 10)])
.await;
let app = ctx.create_app().await;
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "Short response"}],
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let _full_body = resp.into_body().collect().await.unwrap().to_bytes();
let state = wait_for_stream_finish(worker_port, STREAM_FINISH_TIMEOUT)
.await
.expect("Stream tracking state should exist");
assert!(
state.completed,
"Stream should have completed when client consumed all chunks. \
Chunks sent: {}, total: {}",
state.chunks_sent, state.total_chunks
);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// Test that a non-streaming request is not affected by cancel logic.
#[tokio::test]
async fn test_non_streaming_request_unaffected() {
let config = TestRouterConfig::round_robin(4252);
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::healthy(20252)]).await;
let app = ctx.create_app().await;
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "Hello"}],
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(
body.get("object").and_then(|v| v.as_str()),
Some("chat.completion"),
"Non-streaming response should be a complete chat.completion object"
);
ctx.shutdown().await;
}
/// Cancel before the worker emits any chunk. Catches a select! that
/// only wakes `tx.closed()` after the first `stream.next()` resolves.
#[tokio::test]
async fn test_streaming_cancel_before_first_chunk() {
let worker_port = 20253;
let total_chunks: usize = 10;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let config = TestRouterConfig::round_robin(4253);
let ctx = AppTestContext::new_with_config(
// 200ms per-chunk delay; the very first chunk takes the
// full 200ms because the worker sleeps before emitting.
config,
vec![TestWorkerConfig::slow(worker_port, 200)],
)
.await;
let app = ctx.create_app().await;
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Drop immediately, before pulling any frame.
drop(resp.into_body());
let state = assert_cancelled_before_completion(worker_port).await;
assert!(
state.chunks_sent <= 4,
"Worker should have sent very few chunks (≤ buffer capacity), \
saw {} of {}",
state.chunks_sent,
state.total_chunks
);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// Disconnecting *after* the stream completes naturally must be a
/// no-op — no panic, no spurious "cancel" log, completed=true stays.
#[tokio::test]
async fn test_streaming_cancel_after_done_is_noop() {
let worker_port = 20254;
let total_chunks: usize = 3;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let config = TestRouterConfig::round_robin(4254);
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 5)])
.await;
let app = ctx.create_app().await;
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Drain entire body, then drop after a short pause.
let body = resp.into_body().collect().await.unwrap().to_bytes();
assert!(!body.is_empty());
tokio::time::sleep(Duration::from_millis(50)).await;
drop(body);
let state = wait_for_stream_finish(worker_port, STREAM_FINISH_TIMEOUT)
.await
.expect("tracking state");
assert!(state.completed, "Stream should have completed cleanly");
assert_eq!(state.chunks_sent, state.total_chunks);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// Cancelling one client request must not affect a concurrent request
/// that's hitting a *different* upstream replica.
#[tokio::test]
async fn test_cancel_one_request_does_not_affect_concurrent() {
let worker_a = 20255;
let worker_b = 20256;
let total_a: usize = 20;
let total_b: usize = 5;
reset_stream_tracker(worker_a);
reset_stream_tracker(worker_b);
set_slow_stream_chunks(worker_a, total_a);
set_slow_stream_chunks(worker_b, total_b);
let config = TestRouterConfig::round_robin(4255);
let ctx = AppTestContext::new_with_config(
config,
vec![
TestWorkerConfig::slow(worker_a, 50),
TestWorkerConfig::slow(worker_b, 10),
],
)
.await;
// Two parallel requests. Round-robin should send them to different
// workers. We don't strictly need to know which got which, but we
// assume the FIRST request lands on worker_a — that's the one we
// cancel — and we await the SECOND to completion.
let app = ctx.create_app().await;
let app2 = app.clone();
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "Long"}],
"stream": true
});
let body_str = Arc::new(serde_json::to_string(&payload).unwrap());
let body_str_a = Arc::clone(&body_str);
let h_cancel = tokio::spawn(async move {
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from((*body_str_a).clone()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut body = resp.into_body();
let _ = read_n_chunks(&mut body, 2).await;
drop(body);
});
// Small stagger so round-robin index advances.
tokio::time::sleep(Duration::from_millis(20)).await;
let body_str_b = Arc::clone(&body_str);
let h_consume = tokio::spawn(async move {
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from((*body_str_b).clone()))
.unwrap();
let resp = app2.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let _ = resp.into_body().collect().await.unwrap();
});
h_cancel.await.unwrap();
h_consume.await.unwrap();
// Worker that should have completed normally.
let state_b = wait_for_stream_finish(worker_b, STREAM_FINISH_TIMEOUT)
.await
.expect("worker_b tracking state");
// Worker that should have been cancelled.
let state_a = wait_for_stream_finish(worker_a, STREAM_FINISH_TIMEOUT)
.await
.expect("worker_a tracking state");
// We don't know which worker got which request because round-robin
// is shared across the run, so accept either ordering.
let (cancelled, completed) = if state_a.completed {
(state_b, state_a)
} else {
(state_a, state_b)
};
assert!(
!cancelled.completed,
"Cancelled stream should not have completed (sent {}/{})",
cancelled.chunks_sent, cancelled.total_chunks
);
assert!(
completed.completed,
"Concurrent stream should have completed (sent {}/{})",
completed.chunks_sent, completed.total_chunks
);
clear_slow_stream_chunks(worker_a);
clear_slow_stream_chunks(worker_b);
ctx.shutdown().await;
}
/// Mid-stream worker error must surface as `Stream error: …` to the
/// client, must NOT be silently swallowed as cancel, and must trigger
/// the gateway's error log path. We assert (a) the client sees the
/// error frame and (b) `chunks_sent` reflects the partial output the
/// worker sent before erroring.
#[tokio::test]
async fn test_streaming_worker_error_propagates_not_cancel() {
let worker_port = 20257;
let total_chunks: usize = 10;
let error_after: usize = 3;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
set_stream_error_after_chunks(worker_port, error_after);
let config = TestRouterConfig::round_robin(4256);
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 10)])
.await;
let app = ctx.create_app().await;
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Collecting the body should yield a transport-level error AFTER
// the first few chunks. axum surfaces the upstream error as a
// failed body.collect(), so we just stitch frames manually.
let mut body = resp.into_body();
let mut combined = Vec::<u8>::new();
let mut saw_transport_err = false;
loop {
match body.frame().await {
Some(Ok(frame)) => {
if let Ok(data) = frame.into_data() {
combined.extend_from_slice(&data);
}
}
Some(Err(_)) => {
saw_transport_err = true;
break;
}
None => break,
}
}
let combined_str = String::from_utf8_lossy(&combined);
assert!(
saw_transport_err
|| combined_str.contains("Stream error")
|| combined_str.contains("simulated upstream worker crash"),
"Client should observe a stream error event or transport error; got: {}",
combined_str
);
// Worker should have sent some chunks but not all, and the stream
// should NOT be marked completed (we crashed before [DONE]).
let state = wait_for_stream_finish(worker_port, STREAM_FINISH_TIMEOUT)
.await
.expect("tracking state");
assert!(!state.completed, "Errored stream should not be completed");
assert!(
state.chunks_sent <= error_after,
"Worker reported {} chunks_sent, expected ≤ {}",
state.chunks_sent,
error_after
);
clear_stream_error_after_chunks(worker_port);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// PD-disagg streaming cancel: client disconnects mid-decode-stream.
/// The decode worker's slow-stream tracker is what proves cancel
/// actually reached the upstream — prefill is fully drained
/// synchronously by `process_prefill_response`, so it's expected
/// to complete regardless.
#[tokio::test]
async fn test_pd_streaming_cancel_on_client_disconnect() {
let prefill_port = 20258;
let decode_port = 20259;
let total_chunks: usize = 20;
reset_stream_tracker(decode_port);
set_slow_stream_chunks(decode_port, total_chunks);
let config = RouterConfig::builder()
.prefill_decode_mode(
vec![(format!("http://127.0.0.1:{}", prefill_port), None)],
vec![format!("http://127.0.0.1:{}", decode_port)],
)
.round_robin_policy()
.host("127.0.0.1")
.port(4257)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![TestWorkerConfig::prefill(prefill_port), {
// Use a slow decode worker (50ms per chunk).
let mut w = TestWorkerConfig::decode(decode_port);
w.response_delay_ms = 50;
w
}],
)
.await;
let app = ctx.create_app().await;
let payload = json!({
"text": "PD streaming test",
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut body = resp.into_body();
let chunks_read = read_n_chunks(&mut body, 3).await;
assert!(
chunks_read > 0,
"Should have read at least one chunk before disconnecting"
);
let snapshot = get_stream_tracking_state(decode_port)
.map(|s| s.chunks_sent)
.unwrap_or(0);
drop(body);
let final_state = assert_cancelled_before_completion(decode_port).await;
assert!(
final_state.chunks_sent <= snapshot + MOCK_STREAM_BUFFER,
"Decode chunks_sent grew by more than the buffer ({} -> {}); \
gateway likely did not propagate cancel through PD path.",
snapshot,
final_state.chunks_sent
);
clear_slow_stream_chunks(decode_port);
ctx.shutdown().await;
}
/// /v1/responses with no persistence (`store: false`, no conversation):
/// client disconnect must propagate to the upstream worker.
#[tokio::test]
async fn test_responses_streaming_cancel_no_persistence() {
let worker_port = 20260;
let total_chunks: usize = 20;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let config = RouterConfig::builder()
.openai_mode(vec![format!("http://127.0.0.1:{}", worker_port)])
.round_robin_policy()
.host("127.0.0.1")
.port(4258)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 50)])
.await;
let app = ctx.create_app().await;
let payload = json!({
"model": "mock-model",
"input": "Tell me a story",
"stream": true,
"store": false
});
let req = Request::builder()
.method("POST")
.uri("/v1/responses")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut body = resp.into_body();
// First two events are response.created/response.in_progress, then
// chunks start. Read a few to ensure we're past the bootstrap.
let chunks_read = read_n_chunks(&mut body, 3).await;
assert!(chunks_read > 0);
let snapshot = get_stream_tracking_state(worker_port)
.map(|s| s.chunks_sent)
.unwrap_or(0);
drop(body);
let final_state = assert_cancelled_before_completion(worker_port).await;
assert!(
final_state.chunks_sent <= snapshot + MOCK_STREAM_BUFFER,
"/responses chunks_sent grew by more than the buffer ({} -> {}); \
gateway likely did not propagate cancel through /responses path.",
snapshot,
final_state.chunks_sent
);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// /v1/responses with persistence (`store: true`): the upstream is
/// intentionally NOT cancelled on client disconnect — the gateway
/// keeps consuming so the response can be persisted. We assert the
/// worker reaches `completed = true` after the client disconnects.
#[tokio::test]
async fn test_responses_streaming_persistence_drains_after_disconnect() {
let worker_port = 20261;
let total_chunks: usize = 6;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let config = RouterConfig::builder()
.openai_mode(vec![format!("http://127.0.0.1:{}", worker_port)])
.round_robin_policy()
.host("127.0.0.1")
.port(4259)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 20)])
.await;
let app = ctx.create_app().await;
let payload = json!({
"model": "mock-model",
"input": "Tell me a story",
"stream": true,
"store": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/responses")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut body = resp.into_body();
// Read enough frames to capture the response.created event so we
// can later look up the stored response by its id.
let (_, captured) = read_n_chunks_with_bytes(&mut body, 3).await;
let response_id =
extract_response_id_from_sse(&captured).expect("response.created event with id");
drop(body);
// With persistence the gateway keeps reading; worker should
// eventually mark the stream completed despite client gone.
let state = wait_for_stream_finish(worker_port, Duration::from_secs(5))
.await
.expect("tracking state");
assert!(
state.completed,
"Persistence path should drain upstream to completion despite \
client disconnect (sent {}/{})",
state.chunks_sent, state.total_chunks
);
assert_eq!(state.chunks_sent, state.total_chunks);
// Draining is necessary but not sufficient — also verify the
// gateway actually called persist_conversation_items and the
// response landed in storage. Poll briefly because persistence
// happens after the upstream loop exits.
let storage = ctx.app_context.response_storage.clone();
let stored = {
use data_connector::ResponseId;
let id = ResponseId::from(response_id.clone());
let mut found = None;
for _ in 0..20 {
if let Ok(Some(r)) = storage.get_response(&id).await {
found = Some(r);
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
found.unwrap_or_else(|| {
panic!(
"Response {} should have been persisted after client \
disconnect on store=true /responses stream",
response_id
)
})
};
assert_eq!(stored.id.0, response_id);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// Dual of `_drains_after_disconnect`: with `store=true` and a mid-stream
/// upstream error, the gateway must NOT persist a torn response. The
/// commit `714b62f24` warn-log on the persistence-skipped path is the
/// observable signal; here we assert the stronger property that no row
/// lands in storage.
#[tokio::test]
async fn test_responses_simple_streaming_error_skips_persistence() {
let worker_port = 20266;
let total_chunks: usize = 10;
let error_after: usize = 3;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
set_stream_error_after_chunks(worker_port, error_after);
let _guard = StreamInjectionGuard(worker_port);
let config = RouterConfig::builder()
.openai_mode(vec![format!("http://127.0.0.1:{}", worker_port)])
.round_robin_policy()
.host("127.0.0.1")
.port(4266)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 20)])
.await;
let worker_url = format!("http://127.0.0.1:{}", worker_port);
let worker = pin_worker(&ctx, &worker_url);
let (_, f_pre) = breaker_counts(&worker);
let app = ctx.create_app().await;
let payload = json!({
"model": "mock-model",
"input": "Tell me a story",
"stream": true,
"store": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/responses")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Drain the body so the response.created event lands in `captured`
// and the upstream error is observed (no client disconnect — the
// skip is driven by the error, not by a cancel).
let mut body = resp.into_body();
let mut captured: Vec<u8> = Vec::new();
while let Some(Ok(frame)) = body.frame().await {
if frame.is_data() {
if let Ok(data) = frame.into_data() {
captured.extend_from_slice(&data);
}
}
}
drop(body);
let response_id =
extract_response_id_from_sse(&captured).expect("response.created event with id");
// Wait for the gateway-side producer task to exit so persistence
// (or its skip) and breaker tick have run.
let _ = wait_for_stream_finish(worker_port, STREAM_FINISH_TIMEOUT).await;
// Co-assert that the breaker tick fired. Without this, a regression
// that silently swallowed the error (record nothing, persist nothing)
// would also produce an empty storage and pass the lookup below.
let (_, f_post) = breaker_counts(&worker);
assert!(
f_post > f_pre,
"/responses simple: mid-stream upstream error must record a \
breaker failure. failures {}→{}",
f_pre,
f_post
);
let storage = ctx.app_context.response_storage.clone();
use data_connector::ResponseId;
let id = ResponseId::from(response_id.clone());
if let Ok(Some(_)) = storage.get_response(&id).await {
panic!(
"Response {} should NOT have been persisted after \
mid-stream upstream error on store=true /responses",
response_id
);
}
ctx.shutdown().await;
}
/// OpenAI-mode (non-/responses) chat-completions cancel: this exercises
/// the `OpenAIRouter` impl, which is a separate codepath from
/// `http::Router`, so the same cancel semantics need their own test.
#[tokio::test]
async fn test_openai_router_streaming_cancel() {
let worker_port = 20262;
let total_chunks: usize = 20;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let config = RouterConfig::builder()
.openai_mode(vec![format!("http://127.0.0.1:{}", worker_port)])
.round_robin_policy()
.host("127.0.0.1")
.port(4260)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 50)])
.await;
let app = ctx.create_app().await;
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "Tell me a story"}],
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut body = resp.into_body();
let chunks_read = read_n_chunks(&mut body, 3).await;
assert!(chunks_read > 0);
let snapshot = get_stream_tracking_state(worker_port)
.map(|s| s.chunks_sent)
.unwrap_or(0);
drop(body);
let final_state = assert_cancelled_before_completion(worker_port).await;
assert!(
final_state.chunks_sent <= snapshot + MOCK_STREAM_BUFFER,
"OpenAI-mode chunks_sent grew by more than the buffer ({} -> {})",
snapshot,
final_state.chunks_sent
);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// Tool-interception streaming cancel: when the client disconnects mid
/// second-turn (after the gateway has executed an MCP tool call and
/// re-issued an upstream request), the gateway must drop that second
/// upstream connection promptly. This guards the explicit policy
/// documented at `streaming.rs:712-722` ("don't keep workers and
/// external MCP services busy on results no one will read") against
/// silent regression — the inner `select! { ... _ = tx.closed() }`
/// in `handle_streaming_with_tool_interception` is the load-bearing
/// piece.
#[tokio::test]
async fn test_tool_interception_streaming_cancel_on_client_disconnect() {
use smg::routers::{RouterFactory, RouterTrait};
use crate::common::{
mock_mcp_server::MockMCPServer,
mock_worker::{HealthStatus, MockWorker, MockWorkerConfig, WorkerType},
};
let worker_port = 20263;
let total_chunks: usize = 20;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let mut mcp = MockMCPServer::start().await.expect("start mcp");
let mcp_yaml = format!(
"servers:\n - name: mock\n protocol: streamable\n url: {}\n",
mcp.url()
);
let dir = tempfile::tempdir().expect("tmpdir");
let cfg_path = dir.path().join("mcp.yaml");
std::fs::write(&cfg_path, mcp_yaml).expect("write mcp cfg");
let mut worker = MockWorker::new(MockWorkerConfig {
port: worker_port,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 50,
fail_rate: 0.0,
});
let worker_url = worker.start().await.expect("start worker");
// Allow the mock worker's HTTP listener to bind before the router
// probes its health.
tokio::time::sleep(Duration::from_millis(200)).await;
let router_cfg = RouterConfig::builder()
.openai_mode(vec![worker_url])
.random_policy()
.host("127.0.0.1")
.port(4263)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx = crate::common::create_test_context_with_mcp_config(
router_cfg,
cfg_path.to_str().unwrap(),
)
.await;
let router: Arc<dyn RouterTrait> =
Arc::from(RouterFactory::create_router(&ctx).await.expect("router"));
let app = crate::common::test_app::create_test_app_with_context(router, ctx);
let payload = json!({
"model": "mock-model",
"input": "search something",
"stream": true,
"store": false,
"tools": [{
"type": "mcp",
"server_label": "mock",
"server_url": mcp.url()
}]
});
let req = Request::builder()
.method("POST")
.uri("/v1/responses")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut body = resp.into_body();
// Pull a few frames so the body keeps draining while the gateway
// works through turn 1 (tool call) and starts turn 2 (slow text).
let _ = read_n_chunks(&mut body, 8).await;
// Wait until the slow second-turn upstream request has actually
// started producing chunks — only the slow-stream branch in the
// mock initialises the tracker, so seeing chunks_sent>0 here
// means we're inside the second upstream request.
let mut waited_ms: u64 = 0;
loop {
if let Some(s) = get_stream_tracking_state(worker_port) {
if s.chunks_sent > 0 {
break;
}
}
if waited_ms >= 5000 {
panic!(
"Second-turn upstream stream never started producing chunks \
within 5s — tool-interception path did not reach select!"
);
}
tokio::time::sleep(Duration::from_millis(50)).await;
waited_ms += 50;
}
let snapshot = get_stream_tracking_state(worker_port)
.map(|s| s.chunks_sent)
.unwrap_or(0);
drop(body);
let final_state = assert_cancelled_before_completion(worker_port).await;
assert!(
final_state.chunks_sent <= snapshot + MOCK_STREAM_BUFFER,
"Tool-interception second-turn chunks_sent grew by more than \
the buffer ({} -> {}) — cancel did not propagate to upstream",
snapshot,
final_state.chunks_sent
);
clear_slow_stream_chunks(worker_port);
worker.stop().await;
mcp.stop().await;
}
/// Tool-interception path: client disconnects WHILE the gateway is
/// still waiting for the upstream's response headers (inside the
/// `request_builder.send().await` future, not yet streaming).
///
/// The mock sleeps 1500ms before returning headers; the test drops
/// the response body ~50ms after the gateway has dispatched the
/// request. With the `tokio::select! { res = send() => …, _ = tx.closed() => return }`
/// guard in place, the gateway aborts the send before the mock ever
/// reaches its slow-stream init — so `get_stream_tracking_state`
/// stays at `None`. If the guard regresses to a plain
/// `request_builder.send().await`, the mock would complete its sleep,
/// initialise the tracker, and `get_stream_tracking_state` would
/// return `Some(...)`.
#[tokio::test]
async fn test_tool_interception_cancel_during_send() {
use smg::routers::{RouterFactory, RouterTrait};
use crate::common::{
mock_mcp_server::MockMCPServer,
mock_worker::{HealthStatus, MockWorker, MockWorkerConfig, WorkerType},
};
let worker_port = 20264;
// slow_stream is configured so that IF the mock ever gets past
// the pre-response delay, the tracker is populated and the test
// would observe the regression.
let total_chunks: usize = 5;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let mut mcp = MockMCPServer::start().await.expect("start mcp");
let mcp_yaml = format!(
"servers:\n - name: mock\n protocol: streamable\n url: {}\n",
mcp.url()
);
let dir = tempfile::tempdir().expect("tmpdir");
let cfg_path = dir.path().join("mcp.yaml");
std::fs::write(&cfg_path, mcp_yaml).expect("write mcp cfg");
let mut worker = MockWorker::new(MockWorkerConfig {
port: worker_port,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
// 1500ms pre-response delay: long enough that the test's
// ~50ms drop reliably races *inside* the send().await window.
response_delay_ms: 1500,
fail_rate: 0.0,
});
let worker_url = worker.start().await.expect("start worker");
tokio::time::sleep(Duration::from_millis(200)).await;
let router_cfg = RouterConfig::builder()
.openai_mode(vec![worker_url])
.random_policy()
.host("127.0.0.1")
.port(4264)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx = crate::common::create_test_context_with_mcp_config(
router_cfg,
cfg_path.to_str().unwrap(),
)
.await;
let router: Arc<dyn RouterTrait> =
Arc::from(RouterFactory::create_router(&ctx).await.expect("router"));
let app = crate::common::test_app::create_test_app_with_context(router, ctx);
let payload = json!({
"model": "mock-model",
"input": "search something",
"stream": true,
"store": false,
"tools": [{
"type": "mcp",
"server_label": "mock",
"server_url": mcp.url()
}]
});
let req = Request::builder()
.method("POST")
.uri("/v1/responses")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Drop immediately, while the mock is still inside its 1500ms
// pre-response sleep. The gateway's spawned task is parked in
// `select! { res = send() => ..., _ = tx.closed() => return }`.
let body = resp.into_body();
tokio::time::sleep(Duration::from_millis(50)).await;
drop(body);
// Give the gateway and mock plenty of time to process the cancel
// and finish their respective sleeps. 2500ms > 1500ms ensures
// that even if the select! guard regressed, the mock would have
// long since reached `init_stream_tracking` by the time we check.
tokio::time::sleep(Duration::from_millis(2500)).await;
assert!(
get_stream_tracking_state(worker_port).is_none(),
"Mock worker initialised the slow-stream tracker, which means \
its handler completed the pre-response sleep — i.e. the gateway \
waited for upstream headers instead of cancelling send().await \
on client disconnect. Tracker state: {:?}",
get_stream_tracking_state(worker_port)
);
clear_slow_stream_chunks(worker_port);
worker.stop().await;
mcp.stop().await;
}
/// Tool-interception path with `store=true`: when the second-turn
/// upstream errors mid-stream, the gateway must NOT persist a torn
/// response. Mirrors `test_responses_simple_streaming_error_skips_persistence`
/// for the MCP-interception branch (streaming.rs:1023-1033).
#[tokio::test]
async fn test_tool_interception_streaming_error_skips_persistence() {
use data_connector::ResponseId;
use smg::routers::{RouterFactory, RouterTrait};
use crate::common::{
mock_mcp_server::MockMCPServer,
mock_worker::{HealthStatus, MockWorker, MockWorkerConfig, WorkerType},
};
let worker_port = 20267;
let total_chunks: usize = 10;
let error_after: usize = 2;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
set_stream_error_after_chunks(worker_port, error_after);
let _guard = StreamInjectionGuard(worker_port);
let mut mcp = MockMCPServer::start().await.expect("start mcp");
let mcp_yaml = format!(
"servers:\n - name: mock\n protocol: streamable\n url: {}\n",
mcp.url()
);
let dir = tempfile::tempdir().expect("tmpdir");
let cfg_path = dir.path().join("mcp.yaml");
std::fs::write(&cfg_path, mcp_yaml).expect("write mcp cfg");
let mut worker = MockWorker::new(MockWorkerConfig {
port: worker_port,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 20,
fail_rate: 0.0,
});
let worker_url = worker.start().await.expect("start worker");
tokio::time::sleep(Duration::from_millis(200)).await;
let router_cfg = RouterConfig::builder()
.openai_mode(vec![worker_url.clone()])
.random_policy()
.host("127.0.0.1")
.port(4267)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx = crate::common::create_test_context_with_mcp_config(
router_cfg,
cfg_path.to_str().unwrap(),
)
.await;
let storage = ctx.response_storage.clone();
let router: Arc<dyn RouterTrait> =
Arc::from(RouterFactory::create_router(&ctx).await.expect("router"));
let pinned_worker = ctx
.worker_registry
.get_by_url(&worker_url)
.expect("worker should be registered after router create");
let (_, f_pre) = breaker_counts(&pinned_worker);
let app = crate::common::test_app::create_test_app_with_context(router, ctx);
let payload = json!({
"model": "mock-model",
"input": "search something",
"stream": true,
"store": true,
"tools": [{
"type": "mcp",
"server_label": "mock",
"server_url": mcp.url()
}]
});
let req = Request::builder()
.method("POST")
.uri("/v1/responses")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Drain fully so the second-turn upstream error is observed (no
// client disconnect — the skip is driven by the error).
let mut body = resp.into_body();
let mut captured: Vec<u8> = Vec::new();
while let Some(Ok(frame)) = body.frame().await {
if frame.is_data() {
if let Ok(data) = frame.into_data() {
captured.extend_from_slice(&data);
}
}
}
drop(body);
let response_id =
extract_response_id_from_sse(&captured).expect("response.created event with id");
// Wait for the second-turn producer to exit.
let state = wait_for_stream_finish(worker_port, STREAM_FINISH_TIMEOUT)
.await
.expect("second-turn producer to exit within timeout");
// Pin that the second-turn slow_stream branch actually fired with the
// error injection: if a future mock refactor sent turn 2 down the
// happy-path JSON branch, no chunks would have streamed and the
// persistence-skip assertion below would still pass for the wrong
// reason.
assert!(
state.chunks_sent >= error_after && !state.completed,
"Second-turn must have entered slow_stream and errored after \
{} chunks (got chunks_sent={}, completed={})",
error_after,
state.chunks_sent,
state.completed,
);
// Co-assert the breaker tick fired so a "skip-persistence + record
// nothing" regression can't silently pass this test.
let (_, f_post) = breaker_counts(&pinned_worker);
assert!(
f_post > f_pre,
"tool-interception: second-turn upstream error must record a \
breaker failure. failures {}→{}",
f_pre,
f_post
);
let id = ResponseId::from(response_id.clone());
if let Ok(Some(_)) = storage.get_response(&id).await {
panic!(
"Response {} should NOT have been persisted after \
second-turn upstream error on store=true tool-interception path",
response_id
);
}
worker.stop().await;
mcp.stop().await;
}
/// After enough consecutive mid-stream upstream errors, the
/// `BreakerTrackedStream` drop should record failures often enough
/// that the worker's circuit breaker opens. This locks in the
/// contract that mid-stream errors are *not* silently swallowed —
/// regressing to "log only, no breaker tick" would leave a
/// 200-then-broken worker permanently selectable.
#[tokio::test]
async fn test_streaming_errors_trip_circuit_breaker() {
use smg::config::CircuitBreakerConfig;
let worker_port = 20265;
let total_chunks: usize = 10;
let error_after: usize = 1;
let failure_threshold = 3;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
set_stream_error_after_chunks(worker_port, error_after);
let config = TestRouterConfig::round_robin_with_circuit_breaker(
4265,
CircuitBreakerConfig {
failure_threshold,
success_threshold: 2,
timeout_duration_secs: 30,
window_duration_secs: 60,
},
);
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 10)])
.await;
let worker_url = format!("http://127.0.0.1:{}", worker_port);
let app = ctx.create_app().await;
// Drain each request fully so the BreakerTrackedStream sees
// `Some(Err(...))` and tags the terminal state as Errored before
// Drop fires `record_failure`.
for _ in 0..failure_threshold {
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
// Don't assert on status — once the breaker trips, the gateway
// returns 503 instead of dispatching. Both outcomes count
// toward the test as long as the breaker opens by the end.
let mut body = resp.into_body();
while body.frame().await.is_some() {}
}
let worker = ctx
.app_context
.worker_registry
.get_by_url(&worker_url)
.expect("worker should be registered");
let breaker = worker.circuit_breaker();
assert!(
!matches!(breaker.state(), smg::core::CircuitState::Closed),
"Circuit breaker should NOT be Closed after {} streaming errors; \
state = {:?}, consecutive_failures = {}, total_failures = {}, \
total_successes = {}",
failure_threshold,
breaker.state(),
breaker.consecutive_failures(),
breaker.total_failures(),
breaker.total_successes(),
);
clear_slow_stream_chunks(worker_port);
clear_stream_error_after_chunks(worker_port);
ctx.shutdown().await;
}
// -------- Breaker accounting tests --------
//
// For single-upstream-call streaming paths (http chat, OpenAI chat, PD
// generate, /responses simple), the worker's circuit breaker is ticked
// exactly once per request based on the upstream's actual termination:
// success on clean end, failure on mid-stream error, neither on client
// disconnect. The tool-interception path (/responses with MCP) is the
// documented exception — it ticks once per upstream HTTP call inside
// the tool loop, so a 3-iteration loop can tick up to 3 times.
/// Returns `(total_successes, total_failures)` for the given worker.
///
/// Callers MUST capture the `Arc<dyn Worker>` once at test start (via
/// `worker_registry.get_by_url(...).unwrap()`) and reuse it for every
/// snapshot. Looking up by URL each time is unsafe: any path that
/// re-registers a worker (e.g. the admin `UpdateWorkerPropertiesStep`
/// workflow) replaces the registry's `Arc` with a freshly-built worker
/// that has a fresh `CircuitBreaker`. Two `get_by_url` calls bracketing
/// a request can therefore return handles to two different breakers,
/// making counter deltas vacuous.
fn breaker_counts(worker: &Arc<dyn smg::core::Worker>) -> (u64, u64) {
let breaker = worker.circuit_breaker();
(breaker.total_successes(), breaker.total_failures())
}
/// Capture the worker for a given URL once at test start. See
/// `breaker_counts` for why repeated `get_by_url` lookups are unsafe.
fn pin_worker(ctx: &AppTestContext, worker_url: &str) -> Arc<dyn smg::core::Worker> {
ctx.app_context
.worker_registry
.get_by_url(worker_url)
.expect("worker should be registered")
}
/// RAII cleanup for per-port stream injection state. Tests that
/// configure `set_slow_stream_chunks` / `set_stream_error_after_chunks`
/// must use this — without it, a panicking assertion would leave the
/// global injection map populated and poison any future test that
/// reuses the same port.
struct StreamInjectionGuard(u16);
impl Drop for StreamInjectionGuard {
fn drop(&mut self) {
clear_stream_error_after_chunks(self.0);
clear_slow_stream_chunks(self.0);
}
}
/// http chat: client disconnect mid-stream must NOT tick the breaker.
/// Guards `BreakerTrackedStream`'s drop-while-Active path.
#[tokio::test]
async fn test_disconnect_does_not_move_breaker_http_chat() {
let worker_port = 20270;
let total_chunks: usize = 20;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let config = TestRouterConfig::round_robin(4270);
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 50)])
.await;
let app = ctx.create_app().await;
let worker_url = format!("http://127.0.0.1:{}", worker_port);
let worker = pin_worker(&ctx, &worker_url);
let (s_pre, f_pre) = breaker_counts(&worker);
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "long"}],
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut body = resp.into_body();
let _ = read_n_chunks(&mut body, 3).await;
drop(body);
// Wait until the upstream producer exits so the body Drop has
// run and any breaker tick has landed.
let _ = assert_cancelled_before_completion(worker_port).await;
let (s_post, f_post) = breaker_counts(&worker);
assert_eq!(
(s_post - s_pre, f_post - f_pre),
(0, 0),
"http chat: client disconnect must not move breaker. \
successes {}→{}, failures {}→{}",
s_pre,
s_post,
f_pre,
f_post
);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// OpenAIRouter chat: client disconnect mid-stream must NOT tick the
/// breaker. Same `BreakerTrackedStream` drop-while-Active story as the
/// http chat path.
#[tokio::test]
async fn test_disconnect_does_not_move_breaker_openai_chat() {
let worker_port = 20271;
let total_chunks: usize = 20;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let config = RouterConfig::builder()
.openai_mode(vec![format!("http://127.0.0.1:{}", worker_port)])
.round_robin_policy()
.host("127.0.0.1")
.port(4271)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 50)])
.await;
let app = ctx.create_app().await;
let worker_url = format!("http://127.0.0.1:{}", worker_port);
let worker = pin_worker(&ctx, &worker_url);
let (s_pre, f_pre) = breaker_counts(&worker);
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "long"}],
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut body = resp.into_body();
let _ = read_n_chunks(&mut body, 3).await;
drop(body);
let _ = assert_cancelled_before_completion(worker_port).await;
let (s_post, f_post) = breaker_counts(&worker);
assert_eq!(
(s_post - s_pre, f_post - f_pre),
(0, 0),
"openai chat: client disconnect must not move breaker. \
successes {}→{}, failures {}→{}",
s_pre,
s_post,
f_pre,
f_post
);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// PD streaming client disconnect:
/// - decode breaker must show zero delta (`BreakerTrackedStream` drops
/// Active → no tick).
/// - prefill breaker must show exactly +1 success (prefill is fully
/// drained before decode streaming starts, so `record_outcome(true)`
/// fires for the 2xx prefill regardless of what the client does to
/// the decode stream).
#[tokio::test]
async fn test_disconnect_does_not_move_breaker_pd_decode() {
let prefill_port = 20272;
let decode_port = 20273;
let total_chunks: usize = 20;
reset_stream_tracker(decode_port);
set_slow_stream_chunks(decode_port, total_chunks);
let config = RouterConfig::builder()
.prefill_decode_mode(
vec![(format!("http://127.0.0.1:{}", prefill_port), None)],
vec![format!("http://127.0.0.1:{}", decode_port)],
)
.round_robin_policy()
.host("127.0.0.1")
.port(4272)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![TestWorkerConfig::prefill(prefill_port), {
let mut w = TestWorkerConfig::decode(decode_port);
w.response_delay_ms = 50;
w
}],
)
.await;
let app = ctx.create_app().await;
let decode_url = format!("http://127.0.0.1:{}", decode_port);
let prefill_url = format!("http://127.0.0.1:{}", prefill_port);
let decode_worker = pin_worker(&ctx, &decode_url);
let prefill_worker = pin_worker(&ctx, &prefill_url);
let (s_pre_decode, f_pre_decode) = breaker_counts(&decode_worker);
let (s_pre_prefill, f_pre_prefill) = breaker_counts(&prefill_worker);
let payload = json!({
"text": "PD streaming breaker test",
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut body = resp.into_body();
let _ = read_n_chunks(&mut body, 3).await;
drop(body);
let _ = assert_cancelled_before_completion(decode_port).await;
let (s_post_decode, f_post_decode) = breaker_counts(&decode_worker);
assert_eq!(
(s_post_decode - s_pre_decode, f_post_decode - f_pre_decode),
(0, 0),
"PD decode: client disconnect must not move breaker. \
successes {}→{}, failures {}→{}",
s_pre_decode,
s_post_decode,
f_pre_decode,
f_post_decode
);
let (s_post_prefill, f_post_prefill) = breaker_counts(&prefill_worker);
assert_eq!(
(
s_post_prefill - s_pre_prefill,
f_post_prefill - f_pre_prefill
),
(1, 0),
"PD prefill: 2xx must record exactly one success regardless of \
client decode-stream disconnect. successes {}→{}, failures {}→{}",
s_pre_prefill,
s_post_prefill,
f_pre_prefill,
f_post_prefill
);
clear_slow_stream_chunks(decode_port);
ctx.shutdown().await;
}
/// /v1/responses simple (no-persist): client disconnect must not
/// move the worker's circuit breaker — recording neither success
/// nor failure on a request the client abandoned mid-stream.
#[tokio::test]
async fn test_disconnect_does_not_move_breaker_responses_simple() {
let worker_port = 20274;
let total_chunks: usize = 20;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let config = RouterConfig::builder()
.openai_mode(vec![format!("http://127.0.0.1:{}", worker_port)])
.round_robin_policy()
.host("127.0.0.1")
.port(4274)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 50)])
.await;
let app = ctx.create_app().await;
let worker_url = format!("http://127.0.0.1:{}", worker_port);
let worker = pin_worker(&ctx, &worker_url);
let (s_pre, f_pre) = breaker_counts(&worker);
let payload = json!({
"model": "mock-model",
"input": "Tell me a story",
"stream": true,
"store": false
});
let req = Request::builder()
.method("POST")
.uri("/v1/responses")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let mut body = resp.into_body();
let _ = read_n_chunks(&mut body, 3).await;
drop(body);
let _ = assert_cancelled_before_completion(worker_port).await;
let (s_post, f_post) = breaker_counts(&worker);
assert_eq!(
(s_post - s_pre, f_post - f_pre),
(0, 0),
"/responses simple: client disconnect must not move breaker. \
successes {}→{}, failures {}→{}",
s_pre,
s_post,
f_pre,
f_post
);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// /v1/responses simple, mid-stream error: the spawned forwarder in
/// `handle_simple_streaming_passthrough` must record a failure on
/// the worker's circuit breaker when an upstream stream errors
/// after headers — otherwise a "200 OK then broken pipe" worker
/// would never trip the breaker.
#[tokio::test]
async fn test_responses_simple_mid_stream_error_records_failure() {
let worker_port = 20275;
let total_chunks: usize = 10;
let error_after: usize = 2;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
set_stream_error_after_chunks(worker_port, error_after);
let config = RouterConfig::builder()
.openai_mode(vec![format!("http://127.0.0.1:{}", worker_port)])
.round_robin_policy()
.host("127.0.0.1")
.port(4275)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 10)])
.await;
let app = ctx.create_app().await;
let worker_url = format!("http://127.0.0.1:{}", worker_port);
let worker = pin_worker(&ctx, &worker_url);
let (_, f_pre) = breaker_counts(&worker);
let payload = json!({
"model": "mock-model",
"input": "Tell me a story",
"stream": true,
"store": false
});
let req = Request::builder()
.method("POST")
.uri("/v1/responses")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Drain the body so we observe the mid-stream error.
let mut body = resp.into_body();
while body.frame().await.is_some() {}
drop(body);
// Wait for the producer task to exit so any breaker tick is
// observable.
let _ = wait_for_stream_finish(worker_port, STREAM_FINISH_TIMEOUT).await;
let (_, f_post) = breaker_counts(&worker);
assert!(
f_post > f_pre,
"/responses simple: mid-stream upstream error must record \
at least one failure on the breaker. failures {}→{}",
f_pre,
f_post
);
clear_stream_error_after_chunks(worker_port);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// OpenAIRouter chat, mid-stream upstream error: breaker MUST record
/// at least one failure. `Some(Err(_))` → terminal = Errored →
/// Drop ticks `record_failure`. Single-request analogue of
/// `test_streaming_errors_trip_circuit_breaker`.
#[tokio::test]
async fn test_openai_chat_mid_stream_error_records_failure() {
let worker_port = 20276;
let total_chunks: usize = 10;
let error_after: usize = 2;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
set_stream_error_after_chunks(worker_port, error_after);
let config = RouterConfig::builder()
.openai_mode(vec![format!("http://127.0.0.1:{}", worker_port)])
.round_robin_policy()
.host("127.0.0.1")
.port(4276)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 10)])
.await;
let app = ctx.create_app().await;
let worker_url = format!("http://127.0.0.1:{}", worker_port);
let worker = pin_worker(&ctx, &worker_url);
let (_, f_pre) = breaker_counts(&worker);
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
});
reset_stream_tracker(worker_port);
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
let _ = resp.status();
let mut body = resp.into_body();
while body.frame().await.is_some() {}
drop(body);
let _ = wait_for_stream_finish(worker_port, STREAM_FINISH_TIMEOUT).await;
let (_s_post, f_post) = breaker_counts(&worker);
assert!(
f_post > f_pre,
"openai chat: mid-stream upstream error must record at \
least one failure on the breaker. failures {}→{}",
f_pre,
f_post
);
clear_stream_error_after_chunks(worker_port);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
// -------- 5xx-streaming and happy-path success coverage --------
/// http::Router streaming 5xx must record `record_failure`, not success.
/// Guards the `mark_errored()` pre-tag on the streaming branch — without
/// it, the small error body streams cleanly to `None` and Drop would
/// record a spurious success.
#[tokio::test]
async fn test_http_chat_streaming_5xx_records_failure() {
let worker_port = 20290;
let config = TestRouterConfig::round_robin(4290);
let ctx = AppTestContext::new_with_config(
config,
vec![TestWorkerConfig::flaky(worker_port, 1.0)],
)
.await;
let app = ctx.create_app().await;
let worker_url = format!("http://127.0.0.1:{}", worker_port);
let worker = pin_worker(&ctx, &worker_url);
let (s_pre, f_pre) = breaker_counts(&worker);
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "x"}],
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let _ = resp.into_body().collect().await;
let (s_post, f_post) = breaker_counts(&worker);
assert_eq!(
s_post - s_pre,
0,
"http chat streaming 5xx must not record success"
);
assert!(
f_post > f_pre,
"http chat streaming 5xx must record at least one failure. \
failures {}→{}",
f_pre,
f_post
);
ctx.shutdown().await;
}
/// OpenAIRouter streaming 5xx must record `record_failure`, not success.
/// Guards the `mark_errored()` pre-tag on the streaming branch of
/// `OpenAIRouter::route_chat_completions`.
#[tokio::test]
async fn test_openai_chat_streaming_5xx_records_failure() {
let worker_port = 20291;
let config = RouterConfig::builder()
.openai_mode(vec![format!("http://127.0.0.1:{}", worker_port)])
.round_robin_policy()
.host("127.0.0.1")
.port(4291)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![TestWorkerConfig::flaky(worker_port, 1.0)],
)
.await;
let app = ctx.create_app().await;
let worker_url = format!("http://127.0.0.1:{}", worker_port);
let worker = pin_worker(&ctx, &worker_url);
let (s_pre, f_pre) = breaker_counts(&worker);
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "x"}],
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let _ = resp.into_body().collect().await;
let (s_post, f_post) = breaker_counts(&worker);
assert_eq!(
s_post - s_pre,
0,
"openai chat streaming 5xx must not record success"
);
assert!(
f_post > f_pre,
"openai chat streaming 5xx must record at least one failure. \
failures {}→{}",
f_pre,
f_post
);
ctx.shutdown().await;
}
/// PD decode 5xx on streaming request: decode breaker records failure,
/// not success. Guards the `mark_errored()` pre-tag in
/// `PDRouter::create_streaming_response` — the synthetic single-Ok
/// SSE envelope built by `handle_decode_error_response` would otherwise
/// terminate cleanly and record success.
#[tokio::test]
async fn test_pd_decode_streaming_5xx_records_failure() {
let prefill_port = 20292;
let decode_port = 20293;
let config = RouterConfig::builder()
.prefill_decode_mode(
vec![(format!("http://127.0.0.1:{}", prefill_port), None)],
vec![format!("http://127.0.0.1:{}", decode_port)],
)
.round_robin_policy()
.host("127.0.0.1")
.port(4292)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![TestWorkerConfig::prefill(prefill_port), {
let mut w = TestWorkerConfig::decode(decode_port);
w.fail_rate = 1.0;
w
}],
)
.await;
let app = ctx.create_app().await;
let decode_url = format!("http://127.0.0.1:{}", decode_port);
let prefill_url = format!("http://127.0.0.1:{}", prefill_port);
let decode = pin_worker(&ctx, &decode_url);
let prefill = pin_worker(&ctx, &prefill_url);
let (s_pre, f_pre) = breaker_counts(&decode);
let (_s_pre_prefill, f_pre_prefill) = breaker_counts(&prefill);
let payload = json!({ "text": "x", "stream": true });
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let _ = resp.into_body().collect().await;
let (s_post, f_post) = breaker_counts(&decode);
assert_eq!(
s_post - s_pre,
0,
"PD decode streaming 5xx must not record success"
);
assert!(
f_post > f_pre,
"PD decode streaming 5xx must record at least one failure. \
failures {}→{}",
f_pre,
f_post
);
// Healthy prefill must not be penalised when only decode returns
// 5xx. The outer dispatcher used to derive prefill's outcome
// from the synthetic 5xx response status returned by
// `handle_decode_error_response`, falsely failing prefill.
let (_s_post_prefill, f_post_prefill) = breaker_counts(&prefill);
assert_eq!(
f_post_prefill - f_pre_prefill,
0,
"PD streaming: healthy prefill must not be penalised when only \
decode returns 5xx. prefill failures {}→{}",
f_pre_prefill,
f_post_prefill
);
ctx.shutdown().await;
}
/// PD non-streaming, decode 4xx: the decode breaker MUST NOT record a
/// failure. 4xx is a client-fault (malformed input, auth, etc.), not a
/// worker fault — the old outer dispatcher used `not_error =
/// is_success() || is_client_error()` and the streaming path's
/// `BreakerTrackedStream` pre-mark in `create_streaming_response`
/// still preserves that distinction. The early-record path added
/// for prefill misattribution must keep the same semantics for
/// decode, otherwise a client sending malformed payloads can open
/// the breaker on a healthy worker.
#[tokio::test]
async fn test_pd_decode_non_streaming_4xx_does_not_penalise_breaker() {
let prefill_port = 20313;
let decode_port = 20314;
// Force decode's failure response to 400 (client error) instead
// of the default 500.
set_fail_status_code(decode_port, 400);
let config = RouterConfig::builder()
.prefill_decode_mode(
vec![(format!("http://127.0.0.1:{}", prefill_port), None)],
vec![format!("http://127.0.0.1:{}", decode_port)],
)
.round_robin_policy()
.host("127.0.0.1")
.port(4313)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![TestWorkerConfig::prefill(prefill_port), {
let mut w = TestWorkerConfig::decode(decode_port);
w.fail_rate = 1.0;
w
}],
)
.await;
let app = ctx.create_app().await;
let decode_url = format!("http://127.0.0.1:{}", decode_port);
let prefill_url = format!("http://127.0.0.1:{}", prefill_port);
let decode = pin_worker(&ctx, &decode_url);
let prefill = pin_worker(&ctx, &prefill_url);
let (_s_pre_decode, f_pre_decode) = breaker_counts(&decode);
let (_s_pre_prefill, f_pre_prefill) = breaker_counts(&prefill);
// Non-streaming /generate request.
let payload = json!({ "text": "x" });
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let _ = resp.into_body().collect().await;
// Legacy semantics (preserved by the streaming path's
// `BreakerTrackedStream` pre-mark and the old outer
// `not_error = is_success() || is_client_error()` rule): a 4xx
// response is recorded as a non-fault outcome — it must NOT
// increment `total_failures`, otherwise repeated client-caused
// 400s could open the breaker on a healthy worker. Whether it
// increments `total_successes` is incidental; we only pin the
// load-bearing invariant (no failure tick).
let (_s_post_decode, f_post_decode) = breaker_counts(&decode);
assert_eq!(
f_post_decode - f_pre_decode,
0,
"PD decode 4xx is a client fault, not a worker fault — the \
decode breaker must not record a failure. failures {}→{}",
f_pre_decode,
f_post_decode,
);
// Prefill stayed healthy and must also not be penalised by a
// client-caused decode 4xx.
let (_s_post_prefill, f_post_prefill) = breaker_counts(&prefill);
assert_eq!(
f_post_prefill - f_pre_prefill,
0,
"PD prefill must not be penalised by a decode 4xx. \
failures {}→{}",
f_pre_prefill,
f_post_prefill,
);
clear_fail_status_code(decode_port);
ctx.shutdown().await;
}
/// /v1/responses simple streaming 5xx must record `record_failure`,
/// not success. Guards the `record_failure()` in the non-success status
/// arm of `handle_simple_streaming_passthrough` and confirms the eager
/// post-status `record_success()` is gone.
#[tokio::test]
async fn test_responses_simple_streaming_5xx_records_failure() {
let worker_port = 20294;
let config = RouterConfig::builder()
.openai_mode(vec![format!("http://127.0.0.1:{}", worker_port)])
.round_robin_policy()
.host("127.0.0.1")
.port(4294)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![TestWorkerConfig::flaky(worker_port, 1.0)],
)
.await;
let app = ctx.create_app().await;
let worker_url = format!("http://127.0.0.1:{}", worker_port);
let worker = pin_worker(&ctx, &worker_url);
let (s_pre, f_pre) = breaker_counts(&worker);
let payload = json!({
"model": "mock-model",
"input": "x",
"stream": true,
"store": false
});
let req = Request::builder()
.method("POST")
.uri("/v1/responses")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let _ = resp.into_body().collect().await;
let (s_post, f_post) = breaker_counts(&worker);
assert_eq!(
s_post - s_pre,
0,
"/responses simple 5xx must not record success"
);
assert!(
f_post > f_pre,
"/responses simple 5xx must record at least one failure. \
failures {}→{}",
f_pre,
f_post
);
ctx.shutdown().await;
}
/// /v1/responses simple, clean stream: must record exactly one
/// success and no failures. Pins the absence of the old eager
/// `record_success()` at status-OK time (which would have produced
/// 2 successes — one eager, one on stream-end).
#[tokio::test]
async fn test_responses_simple_clean_stream_records_one_success() {
let worker_port = 20295;
let total_chunks: usize = 4;
reset_stream_tracker(worker_port);
set_slow_stream_chunks(worker_port, total_chunks);
let config = RouterConfig::builder()
.openai_mode(vec![format!("http://127.0.0.1:{}", worker_port)])
.round_robin_policy()
.host("127.0.0.1")
.port(4295)
.max_payload_size(8 * 1024 * 1024)
.request_timeout_secs(60)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(32)
.queue_timeout_secs(5)
.build_unchecked();
let ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::slow(worker_port, 5)])
.await;
let app = ctx.create_app().await;
let worker_url = format!("http://127.0.0.1:{}", worker_port);
let worker = pin_worker(&ctx, &worker_url);
let (s_pre, f_pre) = breaker_counts(&worker);
let payload = json!({
"model": "mock-model",
"input": "x",
"stream": true,
"store": false
});
let req = Request::builder()
.method("POST")
.uri("/v1/responses")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let _ = resp.into_body().collect().await;
let _ = wait_for_stream_finish(worker_port, STREAM_FINISH_TIMEOUT).await;
let (s_post, f_post) = breaker_counts(&worker);
assert_eq!(
(s_post - s_pre, f_post - f_pre),
(1, 0),
"/responses simple clean stream must record exactly 1 success \
and 0 failures. successes {}→{}, failures {}→{}",
s_pre,
s_post,
f_pre,
f_post
);
clear_slow_stream_chunks(worker_port);
ctx.shutdown().await;
}
/// PD generate, clean stream: decode breaker must record exactly one
/// success, prefill exactly one success, neither failure. Specifically
/// guards the PD streaming loop's `[DONE]` detection — `mark_completed()`
/// must transition the wrapper from Active to Completed so Drop ticks
/// `record_success`, not "Active = no tick".
#[tokio::test]
async fn test_pd_clean_stream_records_one_success() {
let prefill_port = 20296;
let decode_port = 20297;
let total_chunks: usize = 4;
reset_stream_tracker(decode_port);
set_slow_stream_chunks(decode_port, total_chunks);
let config = RouterConfig::builder()
.prefill_decode_mode(
vec![(format!("http://127.0.0.1:{}", prefill_port), None)],
vec![format!("http://127.0.0.1:{}", decode_port)],
)
.round_robin_policy()
.host("127.0.0.1")
.port(4296)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![TestWorkerConfig::prefill(prefill_port), {
let mut w = TestWorkerConfig::decode(decode_port);
w.response_delay_ms = 5;
w
}],
)
.await;
let app = ctx.create_app().await;
let decode_url = format!("http://127.0.0.1:{}", decode_port);
let prefill_url = format!("http://127.0.0.1:{}", prefill_port);
let decode = pin_worker(&ctx, &decode_url);
let prefill = pin_worker(&ctx, &prefill_url);
let (s_pre_decode, f_pre_decode) = breaker_counts(&decode);
let (s_pre_prefill, f_pre_prefill) = breaker_counts(&prefill);
let payload = json!({ "text": "x", "stream": true });
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let _ = resp.into_body().collect().await;
let _ = wait_for_stream_finish(decode_port, STREAM_FINISH_TIMEOUT).await;
let (s_post_decode, f_post_decode) = breaker_counts(&decode);
assert_eq!(
(s_post_decode - s_pre_decode, f_post_decode - f_pre_decode),
(1, 0),
"PD decode clean stream must record exactly 1 success and 0 failures. \
successes {}→{}, failures {}→{}",
s_pre_decode,
s_post_decode,
f_pre_decode,
f_post_decode
);
let (s_post_prefill, f_post_prefill) = breaker_counts(&prefill);
assert_eq!(
(
s_post_prefill - s_pre_prefill,
f_post_prefill - f_pre_prefill
),
(1, 0),
"PD prefill clean stream must record exactly 1 success and 0 failures. \
successes {}→{}, failures {}→{}",
s_pre_prefill,
s_post_prefill,
f_pre_prefill,
f_post_prefill
);
clear_slow_stream_chunks(decode_port);
ctx.shutdown().await;
}
/// PD generate, prefill 5xx (decode never reached): prefill breaker
/// records failure, decode breaker untouched. Guards the prefill-only
/// failure attribution in the PD retry/dispatch path.
#[tokio::test]
async fn test_pd_prefill_5xx_records_failure() {
let prefill_port = 20298;
let decode_port = 20299;
let config = RouterConfig::builder()
.prefill_decode_mode(
vec![(format!("http://127.0.0.1:{}", prefill_port), None)],
vec![format!("http://127.0.0.1:{}", decode_port)],
)
.round_robin_policy()
.host("127.0.0.1")
.port(4298)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.build_unchecked();
let ctx = AppTestContext::new_with_config(
config,
vec![
{
let mut p = TestWorkerConfig::prefill(prefill_port);
p.fail_rate = 1.0;
p
},
TestWorkerConfig::decode(decode_port),
],
)
.await;
let app = ctx.create_app().await;
let prefill_url = format!("http://127.0.0.1:{}", prefill_port);
let decode_url = format!("http://127.0.0.1:{}", decode_port);
let prefill = pin_worker(&ctx, &prefill_url);
let decode = pin_worker(&ctx, &decode_url);
let (s_pre_p, f_pre_p) = breaker_counts(&prefill);
let (s_pre_d, f_pre_d) = breaker_counts(&decode);
let payload = json!({ "text": "x", "stream": true });
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let _ = resp.into_body().collect().await;
let (s_post_p, f_post_p) = breaker_counts(&prefill);
let (s_post_d, f_post_d) = breaker_counts(&decode);
assert!(
f_post_p > f_pre_p,
"PD prefill 5xx must record at least one failure. failures {}→{}",
f_pre_p,
f_post_p
);
assert_eq!(
s_post_p - s_pre_p,
0,
"PD prefill 5xx must not record success"
);
assert_eq!(
(s_post_d - s_pre_d, f_post_d - f_pre_d),
(0, 0),
"PD decode must be untouched when prefill fails. \
successes {}→{}, failures {}→{}",
s_pre_d,
s_post_d,
f_pre_d,
f_post_d
);
ctx.shutdown().await;
}
/// http chat streaming, upstream connect failure BEFORE the
/// `BreakerTrackedStream` is constructed: the breaker MUST still record
/// a failure. Guards the pre-stream error arm in
/// `send_typed_request` — returning `convert_reqwest_error(e)` without
/// ticking the worker breaker would let a worker that's flapping at
/// the TCP layer remain selectable indefinitely (the streaming branch
/// skips the eager `record_outcome` on the assumption that a tracked
/// stream will fire on drop, but no tracked stream was ever installed
/// on this path).
#[tokio::test]
async fn test_http_chat_pre_stream_failure_records_breaker_streaming() {
use smg::config::RetryConfig;
let worker_port = 20310;
// max_retries=1 keeps the assertion exact: one attempt → one
// failure tick. Any larger value just multiplies the count.
let config = TestRouterConfig::round_robin_with_retry(
4310,
RetryConfig {
max_retries: 1,
initial_backoff_ms: 10,
max_backoff_ms: 50,
backoff_multiplier: 1.0,
jitter_factor: 0.0,
},
);
let mut ctx =
AppTestContext::new_with_config(config, vec![TestWorkerConfig::healthy(worker_port)])
.await;
let app = ctx.create_app().await;
let worker_url = format!("http://127.0.0.1:{}", worker_port);
let worker = pin_worker(&ctx, &worker_url);
let (s_pre, f_pre) = breaker_counts(&worker);
// Stop the worker AFTER startup health check has marked it
// healthy. The periodic health checker isn't spawned in
// AppTestContext setups (it's started in `server.rs`), so
// `is_healthy()` stays true and the worker remains selectable.
// The next streaming request will fail at TCP connect → reqwest
// returns Err → `convert_reqwest_error` synthesises a 5xx
// Response without any `BreakerTrackedStream` ever wrapping the
// body.
ctx.workers[0].stop().await;
let payload = json!({
"model": "mock-model",
"messages": [{"role": "user", "content": "x"}],
"stream": true
});
let req = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert!(
resp.status().is_server_error(),
"Expected 5xx after upstream connect failure, got {}",
resp.status()
);
let _ = resp.into_body().collect().await;
let (s_post, f_post) = breaker_counts(&worker);
assert_eq!(
(s_post - s_pre, f_post - f_pre),
(0, 1),
"http chat streaming: pre-stream upstream failure must record \
exactly one breaker failure (no tracked stream was installed, \
so the deferred-record path doesn't fire). \
successes {}→{}, failures {}→{}",
s_pre,
s_post,
f_pre,
f_post
);
ctx.shutdown().await;
}
/// PD streaming, decode connect failure BEFORE the
/// `BreakerTrackedStream` is constructed: decode breaker MUST record
/// a failure. Guards `pd_router.rs`'s pre-stream error arm — returning
/// `error::bad_gateway` without ticking the decode breaker would let a
/// decode worker that's flapping at the TCP layer remain selectable
/// indefinitely (the streaming branch skips the eager `record_outcome`
/// on the assumption that a tracked stream will fire on drop, but no
/// tracked stream was ever installed on this path).
#[tokio::test]
async fn test_pd_decode_pre_stream_failure_records_breaker_streaming() {
use smg::config::RetryConfig;
let prefill_port = 20311;
let decode_port = 20312;
let config = RouterConfig::builder()
.prefill_decode_mode(
vec![(format!("http://127.0.0.1:{}", prefill_port), None)],
vec![format!("http://127.0.0.1:{}", decode_port)],
)
.round_robin_policy()
.host("127.0.0.1")
.port(4311)
.max_payload_size(256 * 1024 * 1024)
.request_timeout_secs(600)
.worker_startup_timeout_secs(5)
.worker_startup_check_interval_secs(1)
.max_concurrent_requests(64)
.queue_timeout_secs(60)
.retry_config(RetryConfig {
max_retries: 1,
initial_backoff_ms: 10,
max_backoff_ms: 50,
backoff_multiplier: 1.0,
jitter_factor: 0.0,
})
.build_unchecked();
let mut ctx = AppTestContext::new_with_config(
config,
vec![
TestWorkerConfig::prefill(prefill_port),
TestWorkerConfig::decode(decode_port),
],
)
.await;
let app = ctx.create_app().await;
let decode_url = format!("http://127.0.0.1:{}", decode_port);
let prefill_url = format!("http://127.0.0.1:{}", prefill_port);
let decode_worker = pin_worker(&ctx, &decode_url);
let prefill_worker = pin_worker(&ctx, &prefill_url);
let (s_pre_decode, f_pre_decode) = breaker_counts(&decode_worker);
let (_s_pre_prefill, f_pre_prefill) = breaker_counts(&prefill_worker);
// Stop ONLY the decode worker (index 1; prefill was registered
// first). Prefill stays up so its half of the tokio::join! send
// succeeds — the test specifically exercises the
// "decode_result is Err" arm in `execute_dual_dispatch_internal`.
ctx.workers[1].stop().await;
let payload = json!({ "text": "x", "stream": true });
let req = Request::builder()
.method("POST")
.uri("/generate")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert!(
resp.status().is_server_error(),
"Expected 5xx after decode connect failure, got {}",
resp.status()
);
let _ = resp.into_body().collect().await;
let (s_post_decode, f_post_decode) = breaker_counts(&decode_worker);
assert!(
f_post_decode > f_pre_decode,
"PD streaming: pre-stream decode failure must record at least \
one breaker failure on the decode worker (no tracked stream \
was installed). failures {}→{}",
f_pre_decode,
f_post_decode
);
assert_eq!(
s_post_decode - s_pre_decode,
0,
"PD streaming pre-stream decode failure must not record a \
success on the decode breaker. successes {}→{}",
s_pre_decode,
s_post_decode
);
// Prefill stayed up and its `send()` returned 2xx. The decode
// connect failure must NOT be misattributed to prefill — the
// outer dispatcher used to record `prefill.record_outcome(false)`
// based on the final 502 response status, penalising a healthy
// worker for its peer's failure.
let (_s_post_prefill, f_post_prefill) = breaker_counts(&prefill_worker);
assert_eq!(
f_post_prefill - f_pre_prefill,
0,
"PD streaming: healthy prefill must not be penalised when only \
decode fails. prefill failures {}→{}",
f_pre_prefill,
f_post_prefill
);
ctx.shutdown().await;
}
}