feature: upstream cancel (#19524)

Co-authored-by: Kangyan Zhou <zky314343421@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Douglas Yang
2026-05-18 16:20:09 +08:00
committed by GitHub
co-authored by Kangyan Zhou Claude Opus 4.7
parent 54eb2904a4
commit e5589843a3
13 changed files with 4206 additions and 209 deletions
+6
View File
@@ -101,6 +101,12 @@ repos:
pass_filenames: false
always_run: true
stages: [pre-commit]
- id: rustfmt-sgl-model-gateway
name: rustfmt sgl-model-gateway (nightly)
entry: bash -c 'rustup component add --toolchain nightly rustfmt >/dev/null && cd sgl-model-gateway && cargo +nightly fmt -- --check'
language: system
files: ^sgl-model-gateway/.*\.rs$
pass_filenames: false
- repo: https://github.com/lycheeverse/lychee.git
rev: lychee-v0.22.0
hooks:
+5
View File
@@ -164,6 +164,11 @@ name = "manual_policy_benchmark"
harness = false
path = "benches/manual_policy_benchmark.rs"
[[bench]]
name = "streaming_utils_bench"
harness = false
path = "benches/streaming_utils_bench.rs"
[profile.release]
opt-level = "z" # Optimize for size
lto = "fat" # Full LTO for smaller binaries
@@ -0,0 +1,125 @@
//! Microbench for `BreakerTrackedStream` per-poll overhead.
//!
//! The cancel feature wraps every upstream streaming response body in
//! `BreakerTrackedStream`, which sits in the per-chunk hot path. This bench
//! drains a synthetic in-memory chunk stream both bare and wrapped so the
//! delta isolates the wrapper's cost (state machine + Pin dispatch +
//! terminal-state bookkeeping) with no network noise.
//!
//! Run via `cargo bench --bench streaming_utils_bench`. For A/B against
//! main, use Criterion's baseline machinery:
//! `cargo bench --bench streaming_utils_bench -- --save-baseline main`
//! on main, then `--baseline main` on the feature branch.
use std::{fmt, sync::Arc};
use bytes::Bytes;
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use futures_util::StreamExt;
use smg::{
core::{BasicWorkerBuilder, Worker},
routers::streaming_utils::BreakerTrackedStream,
};
use tokio::runtime::Runtime;
/// Minimal `Display`-able error type — keeps the wrapper generic so we
/// don't have to fabricate `reqwest::Error` instances.
#[derive(Debug)]
struct BenchErr;
impl fmt::Display for BenchErr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("bench")
}
}
/// Build `n` `Ok` chunks of `chunk_size` zero bytes. `Bytes::clone` is
/// cheap (refcount bump), so each call reuses the same allocation.
fn make_chunks(n: usize, chunk_size: usize) -> Vec<Result<Bytes, BenchErr>> {
let chunk = Bytes::from(vec![0u8; chunk_size]);
(0..n).map(|_| Ok::<_, BenchErr>(chunk.clone())).collect()
}
fn make_worker() -> Arc<dyn Worker> {
Arc::new(BasicWorkerBuilder::new("http://bench-worker").build())
}
/// Drain a bare `stream::iter` — the reference cost without the
/// `BreakerTrackedStream` wrapper. Whatever delta the `wrapped` bench
/// shows on top of this is the per-chunk overhead of the wrapper.
fn bench_iter_baseline(c: &mut Criterion) {
let rt = Runtime::new().expect("runtime");
let mut group = c.benchmark_group("streaming_utils_baseline_iter");
const CHUNK_SIZE: usize = 1024;
for &n in &[16usize, 64, 256] {
group.throughput(Throughput::Elements(n as u64));
group.bench_function(BenchmarkId::from_parameter(n), |b| {
b.iter(|| {
rt.block_on(async {
let chunks = make_chunks(n, CHUNK_SIZE);
let mut stream = futures_util::stream::iter(chunks);
while let Some(item) = stream.next().await {
black_box(item.expect("bench chunk"));
}
});
});
});
}
group.finish();
}
/// Drain a `BreakerTrackedStream` over the same synthetic chunks. This is
/// the cancel-feature hot path: every upstream byte chunk on a streaming
/// request is polled through this wrapper.
fn bench_tracked_clean(c: &mut Criterion) {
let rt = Runtime::new().expect("runtime");
let mut group = c.benchmark_group("streaming_utils_tracked_clean");
const CHUNK_SIZE: usize = 1024;
for &n in &[16usize, 64, 256] {
group.throughput(Throughput::Elements(n as u64));
group.bench_function(BenchmarkId::from_parameter(n), |b| {
b.iter(|| {
rt.block_on(async {
let chunks = make_chunks(n, CHUNK_SIZE);
let worker = make_worker();
let mut stream = BreakerTrackedStream::new(
futures_util::stream::iter(chunks),
worker,
"http://bench".to_string(),
);
while let Some(item) = stream.next().await {
black_box(item.expect("bench chunk"));
}
});
});
});
}
group.finish();
}
/// `mark_completed` is the PD-streaming `[DONE]`-sentinel fast-path: the
/// caller pre-marks the wrapper completed and stops polling. Times the
/// allocation + mark + drop sequence so changes to the `Terminal`
/// state-machine or `Drop` impl surface here too.
fn bench_tracked_mark_completed_drop(c: &mut Criterion) {
c.bench_function("streaming_utils_tracked_mark_completed_drop", |b| {
b.iter(|| {
let worker = make_worker();
let inner = futures_util::stream::pending::<Result<Bytes, BenchErr>>();
let mut tracked = BreakerTrackedStream::new(inner, worker, "http://bench".to_string());
tracked.mark_completed();
black_box(&tracked);
drop(tracked);
});
});
}
criterion_group!(
benches,
bench_iter_baseline,
bench_tracked_clean,
bench_tracked_mark_completed_drop,
);
criterion_main!(benches);
+1 -1
View File
@@ -28,7 +28,7 @@ pub mod worker_registry;
pub mod worker_service;
// Re-export commonly used types for convenience
pub use circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
pub use circuit_breaker::{CircuitBreaker, CircuitBreakerConfig, CircuitState};
pub use error::{WorkerError, WorkerResult};
pub use job_queue::{Job, JobQueue, JobQueueConfig};
pub use model_card::{ModelCard, ProviderType};
+173 -37
View File
@@ -40,7 +40,9 @@ use crate::{
routers::{
error,
grpc::utils::{error_type_from_status, route_to_endpoint},
header_utils, RouterTrait,
header_utils,
streaming_utils::BreakerTrackedStream,
RouterTrait,
},
};
@@ -65,6 +67,15 @@ struct PDRequestContext<'a> {
headers: Option<HeaderMap>,
}
/// Marker placed on a `Response` by paths inside
/// `execute_dual_dispatch_internal` that have already recorded prefill and
/// decode breaker outcomes against the workers' actual per-side results
/// (rather than the final response status). The outer dispatcher reads this
/// and skips its own status-based `record_outcome` calls so a decode-only
/// transport failure can't be misattributed to a healthy prefill.
#[derive(Clone, Copy)]
struct BreakerOutcomesRecorded;
impl PDRouter {
async fn proxy_to_first_prefill_worker(
&self,
@@ -341,6 +352,7 @@ impl PDRouter {
Err(e) => return Self::handle_serialization_error(e),
};
let ctx_is_stream = context.is_stream;
let response = self
.execute_dual_dispatch_internal(
headers,
@@ -353,9 +365,24 @@ impl PDRouter {
.await;
let status = response.status();
let not_error = status.is_success() || status.is_client_error();
prefill.record_outcome(not_error);
decode.record_outcome(not_error);
let outcomes_already_recorded = response
.extensions()
.get::<BreakerOutcomesRecorded>()
.is_some();
if !outcomes_already_recorded {
let not_error = status.is_success() || status.is_client_error();
// Prefill is always non-streaming and fully read before
// we get here, so its outcome is final.
prefill.record_outcome(not_error);
// Decode for a streaming request is still mid-flight at
// this point; the `BreakerTrackedStream` wrapped around
// its byte stream records the outcome on drop. Skip the
// eager success record to avoid masking "200-then-broken"
// decode workers.
if !ctx_is_stream {
decode.record_outcome(not_error);
}
}
// Record worker errors for server errors (5xx)
if status.is_server_error() {
@@ -428,13 +455,24 @@ impl PDRouter {
// Handle streaming error response
let response_headers = header_utils::preserve_response_headers(res.headers());
let error_payload = match res.bytes().await {
Ok(error_body) => {
if let Ok(error_json) = serde_json::from_slice::<Value>(&error_body) {
Ok(error_body) => match serde_json::from_slice::<Value>(&error_body) {
Ok(error_json) => {
json!({ "message": error_json, "status": status.as_u16() })
} else {
json!({ "message": String::from_utf8_lossy(&error_body).to_string(), "status": status.as_u16() })
}
}
Err(parse_err) => {
let body_text = String::from_utf8_lossy(&error_body).to_string();
let preview: String = body_text.chars().take(256).collect();
tracing::warn!(
"Failed to parse decode error body as JSON from {}: {} \
(status={}, body preview: {:?})",
decode.url(),
parse_err,
status.as_u16(),
preview
);
json!({ "message": body_text, "status": status.as_u16() })
}
},
Err(e) => {
json!({ "message": format!("Decode server error: {}", e), "status": status.as_u16() })
}
@@ -446,13 +484,11 @@ impl PDRouter {
);
let error_stream = tokio_stream::once(Ok(axum::body::Bytes::from(sse_data)));
let decode_url = decode.url().to_string();
self.create_streaming_response(
error_stream,
status,
None,
context.return_logprob,
Some(decode_url),
Some(response_headers),
prefill,
decode,
@@ -595,9 +631,39 @@ impl PDRouter {
status
);
return self
// Per-worker breaker attribution before the synthetic 5xx
// response takes over. Prefill ran concurrently in the
// `tokio::join!`: tick it based on its actual response
// status, not on the decode-driven failure. For
// non-streaming the response carries no tracked stream
// so record decode's outcome here too — but treat 4xx
// as a client fault rather than a worker fault, matching
// the legacy outer-dispatcher rule and the streaming
// `BreakerTrackedStream` pre-mark in
// `create_streaming_response`. For streaming
// `handle_decode_error_response` wraps the synthetic
// error SSE in a `BreakerTrackedStream` that ticks
// decode on drop, so skip to avoid double-counting.
// Mark the response so the outer dispatcher skips its
// status-derived `record_outcome`.
let prefill_ok = match &prefill_result {
Ok(r) => {
let s = r.status();
s.is_success() || s.is_client_error()
}
Err(_) => false,
};
prefill.record_outcome(prefill_ok);
if !context.is_stream {
let decode_ok = status.is_success() || status.is_client_error();
decode.record_outcome(decode_ok);
}
let mut response = self
.handle_decode_error_response(res, &context, prefill, decode)
.await;
response.extensions_mut().insert(BreakerOutcomesRecorded);
return response;
}
// Process prefill response
@@ -644,7 +710,6 @@ impl PDRouter {
status,
prefill_logprobs,
context.return_logprob,
None,
Some(response_headers),
prefill,
decode,
@@ -688,7 +753,33 @@ impl PDRouter {
error = %e,
"Decode request failed"
);
error::bad_gateway("decode_server_error", format!("Decode server error: {}", e))
// Decode failed at TCP/transport level. No tracked
// stream will ever wrap a response (streaming path) and
// we shortcut past the outer non-streaming
// `record_outcome` too — so record decode failure
// directly. Prefill ran concurrently in the
// `tokio::join!`: record its real per-worker outcome
// (success on a 2xx/4xx send, failure on transport
// error) so the decode-driven 502 doesn't penalise a
// healthy prefill. Mark the response so the outer
// dispatcher skips its status-derived `record_outcome`
// and we don't double-count.
decode.record_outcome(false);
let prefill_ok = match &prefill_result {
Ok(res) => {
let s = res.status();
s.is_success() || s.is_client_error()
}
Err(_) => false,
};
prefill.record_outcome(prefill_ok);
let mut response = error::bad_gateway(
"decode_server_error",
format!("Decode server error: {}", e),
);
response.extensions_mut().insert(BreakerOutcomesRecorded);
response
}
}
}
@@ -837,7 +928,6 @@ impl PDRouter {
status: StatusCode,
prefill_logprobs: Option<Value>,
return_logprob: bool,
decode_url: Option<String>,
headers: Option<HeaderMap>,
prefill: Arc<dyn Worker>,
decode: Arc<dyn Worker>,
@@ -846,33 +936,80 @@ impl PDRouter {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
// Uses select! to race stream.next() against tx.closed() so that
// when the client disconnects the upstream HTTP connection is dropped
// promptly, allowing the engine to abort the request.
// `biased;` drains a ready upstream chunk before observing client
// disconnect, so a chunk already produced by reqwest reaches the
// client (and the logprob merger) before we tear the loop down.
//
// The upstream stream is wrapped in `BreakerTrackedStream` so the
// decode worker's circuit breaker is updated once on drop: success
// on clean completion (`[DONE]` sentinel or `None`), failure on
// stream error, neither on client disconnect. PD's pre-PR semantics
// treated 4xx (client error) as not-a-worker-fault, so we only
// pre-mark the wrapper as Errored on 5xx — `handle_decode_error_response`
// synthesizes a single-chunk SSE error envelope that would otherwise
// stream cleanly to None and record a spurious success.
let mut tracked =
BreakerTrackedStream::new(stream, Arc::clone(&decode), decode.url().to_string());
if !(status.is_success() || status.is_client_error()) {
tracked.mark_errored();
}
let decode_for_log = decode.clone();
tokio::spawn(async move {
futures_util::pin_mut!(stream);
while let Some(chunk_result) = stream.next().await {
match chunk_result {
Ok(chunk) => {
let is_done = memmem::find(&chunk, b"data: [DONE]").is_some();
loop {
tokio::select! {
biased;
chunk_result = tracked.next() => {
match chunk_result {
Some(Ok(chunk)) => {
let is_done = memmem::find(&chunk, b"data: [DONE]").is_some();
let result = if return_logprob && prefill_logprobs.is_some() {
Self::merge_streaming_logprobs(prefill_logprobs.clone(), &chunk)
.unwrap_or(chunk)
} else {
chunk
};
let result = if return_logprob && prefill_logprobs.is_some() {
Self::merge_streaming_logprobs(prefill_logprobs.clone(), &chunk)
.unwrap_or(chunk)
} else {
chunk
};
if tx.send(Ok(result)).is_err() {
break;
}
// Mark the wrapper completed before the client
// send: upstream finished cleanly regardless of
// whether the client is still listening, and
// the worker deserves the success tick either
// way. `mark_completed` is a no-op once Errored
// is set, so the synthetic-error path is unaffected.
if is_done {
tracked.mark_completed();
}
if is_done {
break;
if tx.send(Ok(result)).is_err() {
tracing::debug!(
"Receiver dropped (likely client disconnect), \
cancelling upstream PD stream"
);
break;
}
if is_done {
break;
}
}
Some(Err(e)) => {
// BreakerTrackedStream already logged the error
// and marked the terminal state as Errored so
// the worker's circuit breaker will tick on drop.
let _ = tx.send(Err(format!("Stream error: {}", e)));
break;
}
None => break,
}
}
Err(e) => {
if let Some(ref url) = decode_url {
error!("Stream error from decode server {}: {}", url, e);
}
let _ = tx.send(Err(format!("Stream error: {}", e)));
_ = tx.closed() => {
tracing::info!(
"Client disconnected, cancelling upstream PD stream from {}",
decode_for_log.url()
);
break;
}
}
@@ -1549,7 +1686,6 @@ mod tests {
None,
false,
None,
None,
prefill_ref.clone(),
decode_ref.clone(),
);
+49 -38
View File
@@ -9,7 +9,6 @@ use axum::{
};
use futures_util::{stream, StreamExt};
use reqwest::Client;
use tokio_stream::wrappers::UnboundedReceiverStream;
use tracing::{debug, error};
use crate::{
@@ -38,7 +37,9 @@ use crate::{
routers::{
error::{self, extract_error_code_from_response},
grpc::utils::{error_type_from_status, route_to_endpoint},
header_utils, RouterTrait,
header_utils,
streaming_utils::BreakerTrackedStream,
RouterTrait,
},
};
@@ -307,20 +308,21 @@ impl Router {
let headers = Some(&headers_with_trace);
let response = self
.send_typed_request(
headers,
typed_req,
route,
worker.url(),
is_stream,
load_guard,
)
.send_typed_request(headers, typed_req, route, &worker, is_stream, load_guard)
.await;
events::RequestReceivedEvent {}.emit();
let status = response.status();
worker.record_outcome(status.is_success());
// For streaming responses, the wrapped body (`BreakerTrackedStream`)
// records the circuit-breaker outcome once the stream actually
// terminates (success on clean end, failure on mid-stream error).
// Recording it eagerly here based on the initial status code would
// mask "200-then-broken" workers — every request would tick a
// success before the stream had a chance to error out.
if !is_stream {
worker.record_outcome(status.is_success());
}
// Record worker errors for server errors (5xx)
if status.is_server_error() {
@@ -487,13 +489,12 @@ impl Router {
headers: Option<&HeaderMap>,
typed_req: &T,
route: &'static str,
worker_url: &str,
worker: &Arc<dyn Worker>,
is_stream: bool,
load_guard: Option<WorkerLoadGuard>,
) -> Response {
// Get the worker once and reuse for API key and load tracking
let worker = self.worker_registry.get_by_url(worker_url);
let api_key = worker.as_ref().and_then(|w| w.api_key().clone());
let worker_url = worker.url();
let api_key = worker.api_key().clone();
// Static key string to avoid per-request allocations
const DP_RANK_KEY: &str = "data_parallel_rank";
@@ -570,6 +571,19 @@ impl Router {
worker_url, route, e
);
// For streaming requests the caller skips the eager
// `record_outcome` on the assumption that a
// `BreakerTrackedStream` will tick the breaker on drop —
// but no tracked stream is installed when send() fails
// before any response stream exists. Record the failure
// here so a worker flapping at the TCP layer doesn't
// stay permanently selectable. Non-streaming requests
// are already covered by the caller's
// `worker.record_outcome(status.is_success())`, so
// gating on `is_stream` avoids double-counting.
if is_stream {
worker.record_outcome(false);
}
return convert_reqwest_error(e);
}
};
@@ -602,29 +616,26 @@ impl Router {
// Ensure we set the correct content-type for SSE
response_headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
let stream = res.bytes_stream();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
// Spawn task to forward stream
tokio::spawn(async move {
let mut stream = stream;
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
if tx.send(Ok(bytes)).is_err() {
break;
}
}
Err(e) => {
let _ = tx.send(Err(format!("Stream error: {}", e)));
break;
}
}
}
});
let stream = UnboundedReceiverStream::new(rx);
let body = Body::from_stream(stream);
// Pass the reqwest byte stream straight through as the response body.
// Dropping the response body drops this stream, which closes the
// upstream HTTP connection and lets the engine abort generation —
// no spawned task or channel needed. `BreakerTrackedStream`
// updates the worker's circuit breaker exactly once on drop:
// success on clean end, failure on stream error, neither on
// client disconnect. For non-2xx responses we pre-mark the
// wrapper as Errored — otherwise the small error body would
// stream cleanly to `None` and Drop would record a spurious
// success (and the streaming branch also skips the eager
// `record_outcome` above).
let mut tracked = BreakerTrackedStream::new(
res.bytes_stream(),
worker.clone(),
worker_url.to_string(),
);
if !status.is_success() {
tracked.mark_errored();
}
let body = Body::from_stream(tracked);
let mut response = Response::new(body);
*response.status_mut() = status;
+1
View File
@@ -32,6 +32,7 @@ pub mod openai;
pub mod parse;
pub mod persistence_utils;
pub mod router_manager;
pub mod streaming_utils;
pub mod tokenize;
pub use factory::RouterFactory;
@@ -14,7 +14,7 @@ use axum::{
http::{header::CONTENT_TYPE, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
};
use bytes::Bytes;
use bytes::{Bytes, BytesMut};
use futures_util::StreamExt;
use serde_json::{json, Value};
use tokio::sync::mpsc;
@@ -44,6 +44,7 @@ use crate::{
mcp_utils::{ensure_request_mcp_client, McpLoopConfig},
openai::context::{RequestContext, StreamingEventContext, StreamingRequest},
persistence_utils::persist_conversation_items,
streaming_utils::BreakerTrackedStream,
},
};
@@ -191,6 +192,18 @@ fn send_sse_event(
tx.send(Ok(Bytes::from(block))).is_ok()
}
/// Append a fully-formed SSE block to a `\n\n` terminator without going
/// through `format!()` — the `/responses` streaming path forwards a chunk
/// for every model token, so avoiding the intermediate `String` allocation
/// matters under load.
#[inline]
fn sse_block_to_bytes(block: &str) -> Bytes {
let mut buf = BytesMut::with_capacity(block.len() + 2);
buf.extend_from_slice(block.as_bytes());
buf.extend_from_slice(b"\n\n");
buf.freeze()
}
/// Transform fc_* item IDs to mcp_* format
#[inline]
fn transform_fc_to_mcp_id(item_id: &str) -> String {
@@ -484,7 +497,7 @@ pub(super) fn send_final_response_event(
/// Simple pass-through streaming without MCP interception
pub(super) async fn handle_simple_streaming_passthrough(
client: &reqwest::Client,
circuit_breaker: &crate::core::CircuitBreaker,
worker: Arc<dyn crate::core::Worker>,
headers: Option<&HeaderMap>,
req: StreamingRequest,
) -> Response {
@@ -499,7 +512,7 @@ pub(super) async fn handle_simple_streaming_passthrough(
let response = match request_builder.send().await {
Ok(resp) => resp,
Err(err) => {
circuit_breaker.record_failure();
worker.circuit_breaker().record_failure();
return (
StatusCode::BAD_GATEWAY,
format!("Failed to forward request to OpenAI: {}", err),
@@ -513,18 +526,28 @@ pub(super) async fn handle_simple_streaming_passthrough(
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
if !status.is_success() {
circuit_breaker.record_failure();
worker.circuit_breaker().record_failure();
let error_body = response
.text()
.await
.unwrap_or_else(|err| format!("Failed to read upstream error body: {}", err));
return (status_code, error_body).into_response();
}
circuit_breaker.record_success();
// Do NOT record_success here at status-OK time — the spawned forwarder
// below records the actual stream outcome on termination (success on
// clean end / `[DONE]`, failure on mid-stream error). Recording success
// eagerly here would mask 200-then-broken workers and double-count when
// the stream completes normally.
let preserved_headers = preserve_response_headers(response.headers());
let mut upstream_stream = response.bytes_stream();
// Wrap upstream in `BreakerTrackedStream` so the breaker tick is decided
// once on drop: success on clean `None`, failure on `Some(Err)`, neither
// on early drop (client disconnected before upstream terminated).
let upstream_stream = BreakerTrackedStream::new(
response.bytes_stream(),
Arc::clone(&worker),
req.url.clone(),
);
let (tx, rx) = mpsc::unbounded_channel::<Result<Bytes, io::Error>>();
@@ -533,86 +556,168 @@ pub(super) async fn handle_simple_streaming_passthrough(
let persist_needed = original_request.conversation.is_some();
let previous_response_id = req.previous_response_id;
let storage = req.storage;
let upstream_url = req.url;
// When persistence is needed (should_store || persist_needed), we must
// continue consuming upstream even after client disconnect to accumulate
// the full response — the upstream HTTP request is intentionally NOT
// cancelled in that branch. When neither is needed, we use select! to
// race stream.next() against tx.closed() so the upstream HTTP connection
// is dropped promptly when the client disconnects.
let need_persistence = should_store || persist_needed;
tokio::spawn(async move {
let mut accumulator = StreamingResponseAccumulator::new();
let mut upstream_failed = false;
let mut receiver_connected = true;
let mut chunk_processor = ChunkProcessor::new();
while let Some(chunk_result) = upstream_stream.next().await {
match chunk_result {
Ok(chunk) => {
chunk_processor.push_chunk(&chunk);
if need_persistence {
// Persistence path: keep consuming upstream even after client
// disconnect. The wrapper's terminal state at drop encodes the
// breaker outcome; `upstream_failed` is just a local flag used
// to skip persistence on stream error.
let mut upstream_stream = upstream_stream;
let mut accumulator = StreamingResponseAccumulator::new();
let mut upstream_failed = false;
let mut receiver_connected = true;
while let Some(chunk_result) = upstream_stream.next().await {
match chunk_result {
Ok(chunk) => {
chunk_processor.push_chunk(&chunk);
while let Some(raw_block) = chunk_processor.next_block() {
let block_cow = match rewrite_streaming_block(
&raw_block,
&original_request,
previous_response_id.as_deref(),
) {
Some(modified) => Cow::Owned(modified),
None => Cow::Borrowed(raw_block.as_str()),
};
while let Some(raw_block) = chunk_processor.next_block() {
let block_cow = match rewrite_streaming_block(
&raw_block,
&original_request,
previous_response_id.as_deref(),
) {
Some(modified) => Cow::Owned(modified),
None => Cow::Borrowed(raw_block.as_str()),
};
if should_store || persist_needed {
accumulator.ingest_block(&block_cow);
}
if receiver_connected {
let chunk_to_send = format!("{}\n\n", block_cow);
if tx.send(Ok(Bytes::from(chunk_to_send))).is_err() {
if receiver_connected
&& tx.send(Ok(sse_block_to_bytes(&block_cow))).is_err()
{
receiver_connected = false;
tracing::debug!(
"Client disconnected during /responses persistence \
stream from {}; continuing to drain upstream for \
storage",
upstream_url
);
}
}
if !receiver_connected && !should_store {
break;
}
}
if !receiver_connected && !should_store {
Err(err) => {
upstream_failed = true;
// BreakerTrackedStream already marked terminal=Errored
// and logged; just forward the error to the client.
let io_err = io::Error::other(err);
let _ = tx.send(Err(io_err));
break;
}
}
Err(err) => {
upstream_failed = true;
let io_err = io::Error::other(err);
let _ = tx.send(Err(io_err));
break;
}
}
}
if (should_store || persist_needed) && !upstream_failed {
if chunk_processor.has_remaining() {
accumulator.ingest_block(&chunk_processor.take_remaining());
}
let encountered_error = accumulator.encountered_error().cloned();
if let Some(mut response_json) = accumulator.into_final_response() {
patch_response_with_request_metadata(
&mut response_json,
&original_request,
previous_response_id.as_deref(),
if upstream_failed {
warn!(
"Skipping /responses persistence due to upstream stream error from {}: \
store={} conversation={:?}",
upstream_url, should_store, original_request.conversation
);
// Always persist conversation items and response (even without conversation)
if let Err(err) = persist_conversation_items(
storage.conversation.clone(),
storage.conversation_item.clone(),
storage.response.clone(),
&response_json,
&original_request,
)
.await
{
warn!("Failed to persist conversation items (stream): {}", err);
}
} else if let Some(error_payload) = encountered_error {
warn!("Upstream streaming error payload: {}", error_payload);
} else {
warn!("Streaming completed without a final response payload");
if chunk_processor.has_remaining() {
accumulator.ingest_block(&chunk_processor.take_remaining());
}
let encountered_error = accumulator.encountered_error().cloned();
if let Some(mut response_json) = accumulator.into_final_response() {
patch_response_with_request_metadata(
&mut response_json,
&original_request,
previous_response_id.as_deref(),
);
// Always persist conversation items and response (even without conversation)
if let Err(err) = persist_conversation_items(
storage.conversation.clone(),
storage.conversation_item.clone(),
storage.response.clone(),
&response_json,
&original_request,
)
.await
{
warn!("Failed to persist conversation items (stream): {}", err);
}
} else if let Some(error_payload) = encountered_error {
warn!("Upstream streaming error payload: {}", error_payload);
} else {
warn!("Streaming completed without a final response payload");
}
}
// upstream_stream dropped here → breaker tick fires (success or
// failure depending on terminal state set during the loop).
} else {
// No persistence: use select! to cancel upstream on client disconnect.
// `biased;` drains a ready upstream chunk before observing client
// disconnect, so a chunk already produced by reqwest reaches the
// client before we tear the loop down. The `BreakerTrackedStream`
// wrapper records the breaker outcome on drop based on the
// terminal it observed: clean `None` → success, `Some(Err)` →
// failure, dropped while still active (client disconnect or
// `tx.send` failure) → neither.
futures_util::pin_mut!(upstream_stream);
'outer: loop {
tokio::select! {
biased;
chunk_result = upstream_stream.next() => {
match chunk_result {
Some(Ok(chunk)) => {
chunk_processor.push_chunk(&chunk);
while let Some(raw_block) = chunk_processor.next_block() {
let block_cow = match rewrite_streaming_block(
&raw_block,
&original_request,
previous_response_id.as_deref(),
) {
Some(modified) => Cow::Owned(modified),
None => Cow::Borrowed(raw_block.as_str()),
};
if tx.send(Ok(sse_block_to_bytes(&block_cow))).is_err() {
tracing::debug!(
"Receiver dropped (likely client disconnect), \
cancelling upstream /responses stream from {}",
upstream_url
);
break 'outer;
}
}
}
Some(Err(err)) => {
// BreakerTrackedStream already marked terminal=Errored.
let io_err = io::Error::other(err);
let _ = tx.send(Err(io_err));
break 'outer;
}
None => {
// BreakerTrackedStream already marked terminal=Completed.
break 'outer;
}
}
}
_ = tx.closed() => {
tracing::info!(
"Client disconnected, cancelling upstream /responses stream from {}",
upstream_url
);
break 'outer;
}
}
}
// upstream_stream dropped here → breaker tick fires based on
// terminal state.
}
});
@@ -632,9 +737,20 @@ pub(super) async fn handle_simple_streaming_passthrough(
response
}
/// Handle streaming WITH MCP tool call interception and execution
/// Handle streaming WITH MCP tool call interception and execution.
///
/// Note on cancellation vs persistence:
/// Unlike `handle_simple_streaming_passthrough`, this path does *not* keep
/// consuming the upstream after a client disconnect even when `should_store`
/// or `persist_needed` is set. Tool interception is multi-iteration and
/// drives MCP tool execution between iterations, which is unbounded work.
/// We deliberately give up on persistence when the client is gone so we
/// don't keep workers and external MCP services busy on results no one
/// will read. Callers that need guaranteed persistence on disconnect
/// should not enable MCP tools for that request.
pub(super) async fn handle_streaming_with_tool_interception(
client: &reqwest::Client,
worker: Arc<dyn crate::core::Worker>,
headers: Option<&HeaderMap>,
req: StreamingRequest,
active_mcp: &Arc<smg_mcp::McpManager>,
@@ -658,6 +774,7 @@ pub(super) async fn handle_streaming_with_tool_interception(
let payload_clone = payload.clone();
let active_mcp_clone = Arc::clone(active_mcp);
let server_keys_clone = server_keys.clone();
let worker_for_breaker = worker;
// Spawn the streaming loop task
tokio::spawn(async move {
@@ -695,6 +812,18 @@ pub(super) async fn handle_streaming_with_tool_interception(
};
loop {
// Check if the client has already disconnected before making a new
// upstream request (e.g. between tool-call iterations). This avoids
// issuing a fresh upstream HTTP request whose response we'd just
// discard. Mid-stream disconnects are caught by the inner select!.
if tx.is_closed() {
tracing::info!(
"Client disconnected before next tool-call iteration to {}, cancelling",
url_clone
);
return;
}
// Make streaming request
let mut request_builder = client_clone.post(&url_clone).json(&current_payload);
if let Some(ref h) = headers_opt {
@@ -702,9 +831,30 @@ pub(super) async fn handle_streaming_with_tool_interception(
}
request_builder = request_builder.header("Accept", "text/event-stream");
let response = match request_builder.send().await {
// Race send() against tx.closed() so that a client disconnect
// while we're still waiting for upstream response headers also
// cancels this in-flight HTTP request (not just mid-stream).
let response = tokio::select! {
biased;
res = request_builder.send() => res,
_ = tx.closed() => {
tracing::info!(
"Client disconnected before /responses tool-interception \
upstream response from {}, cancelling",
url_clone
);
return;
}
};
let response = match response {
Ok(r) => r,
Err(e) => {
worker_for_breaker.circuit_breaker().record_failure();
tracing::error!(
"Failed to send /responses tool-interception request to {}: {}",
url_clone,
e
);
let error_event = format!(
"event: error\ndata: {{\"error\": {{\"message\": \"{}\"}}}}\n\n",
e
@@ -715,15 +865,33 @@ pub(super) async fn handle_streaming_with_tool_interception(
};
if !response.status().is_success() {
worker_for_breaker.circuit_breaker().record_failure();
let status = response.status();
let body = response.text().await.unwrap_or_default();
let body = response
.text()
.await
.unwrap_or_else(|e| format!("<body read failed: {e}>"));
tracing::error!(
"Upstream /responses tool-interception non-success from {}: status={} \
body={}",
url_clone,
status,
body
);
let error_event = format!("event: error\ndata: {{\"error\": {{\"message\": \"Upstream error {}: {}\"}}}}\n\n", status, body);
let _ = tx.send(Ok(Bytes::from(error_event)));
return;
}
// Stream events and check for tool calls
let mut upstream_stream = response.bytes_stream();
// Stream events and check for tool calls.
// Uses select! to race stream.next() against tx.closed() so that
// when the client disconnects the upstream HTTP connection is dropped
// promptly, allowing the engine to abort the request.
// `biased;` drains a ready upstream chunk before observing client
// disconnect, so a chunk already produced by reqwest reaches both
// the client and the chunk_processor before we tear the loop down.
let upstream_stream = response.bytes_stream();
futures_util::pin_mut!(upstream_stream);
let mut handler = StreamingToolHandler::with_starting_index(next_output_index);
if let Some(ref id) = preserved_response_id {
handler.original_response_id = Some(id.clone());
@@ -732,9 +900,23 @@ pub(super) async fn handle_streaming_with_tool_interception(
let mut tool_calls_detected = false;
let mut seen_in_progress = false;
while let Some(chunk_result) = upstream_stream.next().await {
loop {
let chunk_result = tokio::select! {
biased;
chunk = upstream_stream.next() => chunk,
_ = tx.closed() => {
// Client disconnected — drop the stream and exit
tracing::info!(
"Client disconnected, cancelling upstream /responses \
tool-interception stream from {}",
url_clone
);
return;
}
};
match chunk_result {
Ok(chunk) => {
Some(Ok(chunk)) => {
chunk_processor.push_chunk(&chunk);
while let Some(raw_block) = chunk_processor.next_block() {
@@ -838,11 +1020,25 @@ pub(super) async fn handle_streaming_with_tool_interception(
break;
}
}
Err(e) => {
Some(Err(e)) => {
worker_for_breaker.circuit_breaker().record_failure();
tracing::error!(
"Upstream /responses tool-interception stream error from {}: {}",
url_clone,
e
);
if should_store || persist_needed {
warn!(
"Skipping /responses persistence (tool-interception) due to \
upstream stream error from {}: store={} conversation={:?}",
url_clone, should_store, original_request.conversation
);
}
let error_event = format!("event: error\ndata: {{\"error\": {{\"message\": \"Stream error: {}\"}}}}\n\n", e);
let _ = tx.send(Ok(Bytes::from(error_event)));
return;
}
None => break,
}
}
@@ -906,8 +1102,15 @@ pub(super) async fn handle_streaming_with_tool_interception(
err
);
}
} else if should_store || persist_needed {
warn!(
"/responses tool-interception stream ended with no final \
response payload; persistence skipped (store={} conversation={:?})",
should_store, original_request.conversation
);
}
worker_for_breaker.circuit_breaker().record_success();
let _ = tx.send(Ok(Bytes::from("data: [DONE]\n\n")));
return;
}
@@ -925,10 +1128,24 @@ pub(super) async fn handle_streaming_with_tool_interception(
};
if state.total_calls > effective_limit {
// Reaching the tool-call iteration limit is a request-level
// shape problem (the user asked for unbounded work or the
// model is in a tight tool-call loop); the upstream worker
// is not unhealthy. Record a success to reflect that the
// last upstream call returned cleanly.
worker_for_breaker.circuit_breaker().record_success();
warn!(
"Reached tool call limit during streaming: {}",
effective_limit
);
if should_store || persist_needed {
warn!(
"Skipping /responses persistence (tool-interception): \
max_tool_calls limit reached, no final response built. \
store={} conversation={:?}",
should_store, original_request.conversation
);
}
let error_event = "event: error\ndata: {\"error\": {\"message\": \"Exceeded max_tool_calls limit\"}}\n\n".to_string();
let _ = tx.send(Ok(Bytes::from(error_event)));
let _ = tx.send(Ok(Bytes::from("data: [DONE]\n\n")));
@@ -965,6 +1182,18 @@ pub(super) async fn handle_streaming_with_tool_interception(
// Continue loop to make next streaming request
}
Err(e) => {
// Upstream stream finished cleanly (we got pending tool
// calls out of it); the gateway-side payload build failed.
// Credit the worker for the upstream call, mirroring the
// tool-iteration-limit path above.
worker_for_breaker.circuit_breaker().record_success();
if should_store || persist_needed {
warn!(
"Skipping /responses persistence (tool-interception): \
build_resume_payload failed ({}); store={} conversation={:?}",
e, should_store, original_request.conversation
);
}
let error_event = format!("event: error\ndata: {{\"error\": {{\"message\": \"Failed to build resume payload: {}\"}}}}\n\n", e);
let _ = tx.send(Ok(Bytes::from(error_event)));
let _ = tx.send(Ok(Bytes::from("data: [DONE]\n\n")));
@@ -986,7 +1215,6 @@ pub(super) async fn handle_streaming_with_tool_interception(
/// Main entry point for streaming responses
pub async fn handle_streaming_response(ctx: RequestContext) -> Response {
let worker = ctx.worker().expect("Worker not selected").clone();
let circuit_breaker = worker.circuit_breaker();
let headers = ctx.headers().cloned();
let original_body = ctx.responses_request();
let mcp_manager = ctx.components.mcp_manager().expect("MCP manager required");
@@ -1011,7 +1239,7 @@ pub async fn handle_streaming_response(ctx: RequestContext) -> Response {
if active_mcp.is_none() {
return handle_simple_streaming_passthrough(
&client,
circuit_breaker,
Arc::clone(&worker),
headers.as_ref(),
req,
)
@@ -1023,6 +1251,7 @@ pub async fn handle_streaming_response(ctx: RequestContext) -> Response {
// MCP is active - transform tools and set up interception
handle_streaming_with_tool_interception(
&client,
Arc::clone(&worker),
headers.as_ref(),
req,
&active_mcp,
+26 -33
View File
@@ -13,10 +13,8 @@ use axum::{
Json,
};
use data_connector::{ConversationId, ListParams, ResponseId, SortOrder};
use futures_util::{future::join_all, StreamExt};
use futures_util::future::join_all;
use serde_json::{json, to_value, Value};
use tokio::sync::mpsc;
use tokio_stream::wrappers::UnboundedReceiverStream;
use tracing::warn;
use super::{
@@ -42,7 +40,10 @@ use crate::{
ResponsesGetParams, ResponsesRequest,
},
},
routers::header_utils::{apply_provider_headers, extract_auth_header},
routers::{
header_utils::{apply_provider_headers, extract_auth_header},
streaming_utils::BreakerTrackedStream,
},
};
pub struct OpenAIRouter {
@@ -596,16 +597,15 @@ impl crate::routers::RouterTrait for OpenAIRouter {
let status = StatusCode::from_u16(resp.status().as_u16())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
// Record circuit breaker failure for error status codes
if !status.is_success() {
worker.circuit_breaker().record_failure();
}
if !is_streaming {
// Non-streaming: record the breaker outcome inline
// once the body is fully read.
if !status.is_success() {
worker.circuit_breaker().record_failure();
}
let content_type = resp.headers().get(CONTENT_TYPE).cloned();
match resp.bytes().await {
Ok(body) => {
// Only record success after body is fully read
if status.is_success() {
worker.circuit_breaker().record_success();
}
@@ -626,30 +626,23 @@ impl crate::routers::RouterTrait for OpenAIRouter {
}
}
} else {
// Streaming response - record success when stream starts
if status.is_success() {
worker.circuit_breaker().record_success();
// Streaming response: pass the reqwest byte stream
// through `BreakerTrackedStream`, which records the
// circuit-breaker outcome exactly once on drop (success
// on clean end, failure on stream error, neither on
// client disconnect). For non-2xx responses we pre-mark
// the wrapper as Errored — otherwise the small error
// body would stream cleanly to `None` and Drop would
// record a spurious success.
let mut tracked = BreakerTrackedStream::new(
resp.bytes_stream(),
Arc::clone(&worker),
url.clone(),
);
if !status.is_success() {
tracked.mark_errored();
}
let stream = resp.bytes_stream();
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
let mut s = stream;
while let Some(chunk) = s.next().await {
match chunk {
Ok(bytes) => {
if tx.send(Ok(bytes)).is_err() {
break;
}
}
Err(e) => {
let _ = tx.send(Err(format!("Stream error: {}", e)));
break;
}
}
}
});
let mut response =
Response::new(Body::from_stream(UnboundedReceiverStream::new(rx)));
let mut response = Response::new(Body::from_stream(tracked));
*response.status_mut() = status;
response
.headers_mut()
@@ -0,0 +1,271 @@
//! Shared streaming helpers used by the HTTP / OpenAI / PD routers.
//!
//! The main type here is [`BreakerTrackedStream`], a `Stream` adapter that
//! records circuit-breaker outcomes based on how the upstream stream
//! terminates:
//!
//! - **Clean end** (`Poll::Ready(None)`) → `record_success`.
//! - **Upstream transport error** (`Poll::Ready(Some(Err(_)))`) → `record_failure`.
//! - **Caller drops the stream while still active** (client disconnect) → no
//! breaker call. The HTTP response already shipped a 200 status; whether
//! the worker is healthy is unknown from this signal alone.
//!
//! Callers can override the terminal state in two cases:
//!
//! - [`BreakerTrackedStream::mark_completed`] — for routers that detect
//! end-of-stream via an in-band sentinel (e.g. PD's `data: [DONE]`)
//! before the underlying byte stream returns `None`. Note that
//! `Completed` is *not* absorbing — a subsequent `poll_next` returning
//! `Err` will still escalate the terminal to `Errored`. Callers should
//! stop polling after `mark_completed`.
//! - [`BreakerTrackedStream::mark_errored`] — for routers wrapping content
//! that already represents a worker failure (e.g. a non-2xx response
//! body or a synthetic SSE error envelope built from a 5xx). `Errored`
//! is absorbing: once set, it persists regardless of later polls.
use std::{
fmt::Display,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use bytes::Bytes;
use futures_util::Stream;
use tracing::error;
use crate::core::Worker;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Terminal {
/// Stream is still in flight, or was dropped before terminating.
Active,
/// Stream returned `None` or the caller marked it complete.
Completed,
/// Stream yielded an `Err` item.
Errored,
}
/// Wraps a `Stream<Item = Result<Bytes, E>>` so that the circuit breaker on
/// `worker` is updated exactly once on drop:
/// - completed → `record_success`
/// - errored → `record_failure`
/// - dropped while still active → neither (client disconnected; the worker
/// is innocent from our point of view).
///
/// `E` defaults to `reqwest::Error` to match the common producer
/// (`response.bytes_stream()`), but can be any `Display + Send + 'static`
/// error type — useful for tests that construct streams with simpler
/// error types.
#[must_use = "BreakerTrackedStream must be polled to completion (or pre-marked) \
and then dropped for the circuit breaker to record an outcome; \
discarding it immediately records nothing"]
pub struct BreakerTrackedStream<E = reqwest::Error> {
inner: Pin<Box<dyn Stream<Item = Result<Bytes, E>> + Send + 'static>>,
worker: Arc<dyn Worker>,
log_url: String,
terminal: Terminal,
}
impl<E> BreakerTrackedStream<E> {
pub fn new<S>(inner: S, worker: Arc<dyn Worker>, log_url: String) -> Self
where
S: Stream<Item = Result<Bytes, E>> + Send + 'static,
{
Self {
inner: Box::pin(inner),
worker,
log_url,
terminal: Terminal::Active,
}
}
/// Mark the stream as cleanly completed. Use this from callers that
/// detect end-of-stream via an in-band sentinel (e.g. `data: [DONE]`)
/// before the underlying byte stream returns `None`.
///
/// Has no effect once the wrapper is in any non-Active state. `Completed`
/// is *not* absorbing — a later `poll_next` returning `Err` will still
/// escalate the terminal to `Errored`. Callers should stop polling
/// after calling this.
pub fn mark_completed(&mut self) {
if self.terminal == Terminal::Active {
self.terminal = Terminal::Completed;
}
}
/// Pre-tag the stream as terminally errored. Use this from callers
/// constructing a wrapper around content that already represents a
/// failed worker outcome (e.g. a non-2xx response body or a
/// synthetic error envelope) so Drop records `record_failure` even
/// if the underlying stream terminates cleanly. `Errored` is
/// absorbing — once set it stays set regardless of later events.
pub fn mark_errored(&mut self) {
self.terminal = Terminal::Errored;
}
}
impl<E: Display> Stream for BreakerTrackedStream<E> {
type Item = Result<Bytes, E>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
match self.inner.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(b))) => Poll::Ready(Some(Ok(b))),
Poll::Ready(Some(Err(e))) => {
error!("Upstream stream error from worker {}: {}", self.log_url, e);
self.terminal = Terminal::Errored;
Poll::Ready(Some(Err(e)))
}
Poll::Ready(None) => {
if self.terminal == Terminal::Active {
self.terminal = Terminal::Completed;
}
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
}
}
impl<E> Drop for BreakerTrackedStream<E> {
fn drop(&mut self) {
match self.terminal {
Terminal::Completed => self.worker.circuit_breaker().record_success(),
Terminal::Errored => self.worker.circuit_breaker().record_failure(),
// Client disconnected before we knew the worker's verdict.
// Leaving the breaker untouched is the correct default — we
// got a 200 header and some bytes; nothing said the worker
// is unhealthy.
Terminal::Active => {}
}
}
}
#[cfg(test)]
mod tests {
use std::{fmt, sync::Arc};
use bytes::Bytes;
use futures_util::StreamExt;
use super::BreakerTrackedStream;
use crate::core::{BasicWorkerBuilder, Worker};
/// Lightweight error type for tests — keeps the wrapper generic so we
/// don't need to fabricate `reqwest::Error` instances.
#[derive(Debug)]
struct TestErr(&'static str);
impl fmt::Display for TestErr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}
fn worker() -> Arc<dyn Worker> {
Arc::new(BasicWorkerBuilder::new("http://test-worker").build())
}
fn breaker_counters(w: &Arc<dyn Worker>) -> (u64, u64) {
let cb = w.circuit_breaker();
(cb.total_successes(), cb.total_failures())
}
#[tokio::test]
async fn drop_while_active_records_nothing() {
let w = worker();
let inner = futures_util::stream::pending::<Result<Bytes, TestErr>>();
let tracked = BreakerTrackedStream::new(inner, Arc::clone(&w), "u".into());
drop(tracked);
assert_eq!(breaker_counters(&w), (0, 0));
}
#[tokio::test]
async fn clean_stream_records_one_success() {
let w = worker();
let inner = futures_util::stream::iter(vec![
Ok::<_, TestErr>(Bytes::from_static(b"a")),
Ok(Bytes::from_static(b"b")),
]);
let mut tracked = BreakerTrackedStream::new(inner, Arc::clone(&w), "u".into());
while tracked.next().await.is_some() {}
drop(tracked);
assert_eq!(breaker_counters(&w), (1, 0));
}
#[tokio::test]
async fn stream_error_records_one_failure() {
let w = worker();
let inner =
futures_util::stream::iter(vec![Ok(Bytes::from_static(b"a")), Err(TestErr("boom"))]);
let mut tracked = BreakerTrackedStream::new(inner, Arc::clone(&w), "u".into());
while tracked.next().await.is_some() {}
drop(tracked);
assert_eq!(breaker_counters(&w), (0, 1));
}
#[tokio::test]
async fn errored_is_absorbing_across_subsequent_polls() {
let w = worker();
let inner = futures_util::stream::iter(vec![
Err::<Bytes, _>(TestErr("boom")),
Ok(Bytes::from_static(b"after")),
]);
let mut tracked = BreakerTrackedStream::new(inner, Arc::clone(&w), "u".into());
while tracked.next().await.is_some() {}
drop(tracked);
assert_eq!(breaker_counters(&w), (0, 1));
}
#[tokio::test]
async fn mark_completed_then_drop_records_success() {
let w = worker();
let inner = futures_util::stream::pending::<Result<Bytes, TestErr>>();
let mut tracked = BreakerTrackedStream::new(inner, Arc::clone(&w), "u".into());
tracked.mark_completed();
drop(tracked);
assert_eq!(breaker_counters(&w), (1, 0));
}
#[tokio::test]
async fn mark_errored_then_clean_end_still_records_failure() {
let w = worker();
let inner = futures_util::stream::iter(vec![Ok::<_, TestErr>(Bytes::from_static(b"a"))]);
let mut tracked = BreakerTrackedStream::new(inner, Arc::clone(&w), "u".into());
tracked.mark_errored();
while tracked.next().await.is_some() {}
drop(tracked);
assert_eq!(breaker_counters(&w), (0, 1));
}
#[tokio::test]
async fn mark_completed_does_not_overwrite_errored() {
let w = worker();
let inner = futures_util::stream::iter(vec![Err::<Bytes, _>(TestErr("boom"))]);
let mut tracked = BreakerTrackedStream::new(inner, Arc::clone(&w), "u".into());
while tracked.next().await.is_some() {}
tracked.mark_completed();
drop(tracked);
assert_eq!(breaker_counters(&w), (0, 1));
}
// PD's [DONE] handler calls mark_completed before the underlying byte
// stream finishes; a trailing transport error must still flip the
// terminal to Errored so the breaker records failure.
#[tokio::test]
async fn mark_completed_then_later_err_escalates_to_failure() {
let w = worker();
let inner = futures_util::stream::iter(vec![
Ok::<_, TestErr>(Bytes::from_static(b"data: [DONE]\n\n")),
Err(TestErr("trailing transport error")),
]);
let mut tracked = BreakerTrackedStream::new(inner, Arc::clone(&w), "u".into());
// Caller observes the [DONE] chunk and pre-marks completed...
assert!(tracked.next().await.is_some());
tracked.mark_completed();
// ...but a trailing poll surfaces a transport error.
assert!(matches!(tracked.next().await, Some(Err(_))));
drop(tracked);
assert_eq!(breaker_counters(&w), (0, 1));
}
}
+528 -23
View File
@@ -20,7 +20,7 @@ use axum::{
};
use futures_util::stream::{self, StreamExt};
use serde_json::json;
use tokio::sync::RwLock;
use tokio::sync::{Notify, RwLock};
use uuid::Uuid;
/// Configuration for mock worker behavior
@@ -158,6 +158,15 @@ async fn should_fail(config: &MockWorkerConfig) -> bool {
rand::random::<f32>() < config.fail_rate
}
/// Pick the HTTP status used when `should_fail` triggers. Defaults to 500
/// for backwards compatibility; tests can override via
/// [`set_fail_status_code`] to exercise 4xx/non-5xx failure paths.
fn fail_status_code(port: u16) -> StatusCode {
get_fail_status_code_for_port(port)
.and_then(|s| StatusCode::from_u16(s).ok())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
}
async fn health_handler(State(config): State<Arc<RwLock<MockWorkerConfig>>>) -> Response {
let config = config.read().await;
@@ -303,7 +312,7 @@ async fn generate_handler(
if should_fail(&config).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
fail_status_code(config.port),
[("x-worker-id", worker_id)],
Json(json!({
"error": "Random failure for testing"
@@ -324,6 +333,71 @@ async fn generate_handler(
if is_stream {
let stream_delay = config.response_delay_ms;
if let Some(num_chunks) = get_slow_stream_chunks_for_port(config.port) {
let port = config.port;
let delay_ms = stream_delay;
let error_after = get_stream_error_after_for_port(port);
init_stream_tracking(port, num_chunks);
let (tx, rx) =
tokio::sync::mpsc::channel::<Result<Event, std::io::Error>>(MOCK_STREAM_BUFFER);
tokio::spawn(async move {
let _exit_guard = install_stream_exit_notifier(port);
let timestamp_start = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs_f64();
for i in 0..num_chunks {
if let Some(n) = error_after {
if i == n {
let _ = tx
.send(Err(std::io::Error::other(
"simulated upstream worker crash",
)))
.await;
return;
}
}
if delay_ms > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
}
let data = json!({
"text": format!("chunk-{} ", i),
"meta_info": {
"prompt_tokens": 10,
"completion_tokens": (i + 1) as u64,
"completion_tokens_wo_jump_forward": (i + 1) as u64,
"input_token_logprobs": null,
"output_token_logprobs": null,
"first_token_latency": delay_ms as f64 / 1000.0,
"time_to_first_token": delay_ms as f64 / 1000.0,
"time_per_output_token": 0.01,
"start_time": timestamp_start,
"finish_reason": null
},
"stage": "mid"
});
if tx
.send(Ok(Event::default().data(data.to_string())))
.await
.is_err()
{
return;
}
record_chunk_sent(port);
}
let _ = tx.send(Ok(Event::default().data("[DONE]"))).await;
mark_stream_completed(port);
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
return (
[("x-worker-id", worker_id)],
Sse::new(stream).keep_alive(KeepAlive::default()),
)
.into_response();
}
// Check if it's a batch request
let is_batch = payload.get("text").and_then(|t| t.as_array()).is_some();
@@ -442,28 +516,99 @@ async fn chat_completions_handler(
if is_stream {
let request_id = format!("chatcmpl-{}", Uuid::new_v4());
let stream = stream::once(async move {
let chunk = json!({
"id": request_id,
"object": "chat.completion.chunk",
"created": timestamp,
"model": "mock-model",
"choices": [{
"index": 0,
"delta": {
"content": "This is a mock chat response."
},
"finish_reason": null
}]
// Check for slow streaming mode (used by upstream cancel tests).
// Reads from the global SLOW_STREAM_CONFIG (set via set_slow_stream_chunks)
// rather than the payload, because the gateway deserializes/re-serializes
// the request body and drops unknown fields.
let slow_chunks = get_slow_stream_chunks_for_port(config.port);
if let Some(num_chunks) = slow_chunks {
let port = config.port;
let delay_ms = config.response_delay_ms;
let error_after = get_stream_error_after_for_port(port);
init_stream_tracking(port, num_chunks);
// Small bounded capacity gives a bit of slack between the producer
// task and the SSE consumer; on receiver drop, send().await
// returns Err and the loop exits regardless of capacity.
let (tx, rx) =
tokio::sync::mpsc::channel::<Result<Event, std::io::Error>>(MOCK_STREAM_BUFFER);
tokio::spawn(async move {
let _exit_guard = install_stream_exit_notifier(port);
for i in 0..num_chunks {
if let Some(n) = error_after {
if i == n {
// Inject a transport-level error to exercise the
// gateway's `Some(Err(_))` arm.
let _ = tx
.send(Err(std::io::Error::other(
"simulated upstream worker crash",
)))
.await;
return;
}
}
if delay_ms > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
}
let chunk = json!({
"id": &request_id,
"object": "chat.completion.chunk",
"created": timestamp,
"model": "mock-model",
"choices": [{
"index": 0,
"delta": {
"content": format!("chunk-{} ", i)
},
"finish_reason": null
}]
});
if tx
.send(Ok(Event::default().data(chunk.to_string())))
.await
.is_err()
{
// Client disconnected, stream was cancelled
return;
}
record_chunk_sent(port);
}
// Send [DONE]
let _ = tx.send(Ok(Event::default().data("[DONE]"))).await;
mark_stream_completed(port);
});
Ok::<_, Infallible>(Event::default().data(chunk.to_string()))
})
.chain(stream::once(async { Ok(Event::default().data("[DONE]")) }));
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Sse::new(stream)
.keep_alive(KeepAlive::default())
.into_response()
} else {
let stream = stream::once(async move {
let chunk = json!({
"id": request_id,
"object": "chat.completion.chunk",
"created": timestamp,
"model": "mock-model",
"choices": [{
"index": 0,
"delta": {
"content": "This is a mock chat response."
},
"finish_reason": null
}]
});
Sse::new(stream)
.keep_alive(KeepAlive::default())
.into_response()
Ok::<_, Infallible>(Event::default().data(chunk.to_string()))
})
.chain(stream::once(async { Ok(Event::default().data("[DONE]")) }));
Sse::new(stream)
.keep_alive(KeepAlive::default())
.into_response()
}
} else {
Json(json!({
"id": format!("chatcmpl-{}", Uuid::new_v4()),
@@ -793,8 +938,13 @@ async fn responses_handler(
Sse::new(stream)
.keep_alive(KeepAlive::default())
.into_response()
} else if has_tools && has_function_output {
// Second turn: emit streaming text response
} else if has_tools
&& has_function_output
&& get_slow_stream_chunks_for_port(config.port).is_none()
{
// Second turn: emit streaming text response.
// If slow-stream is configured, fall through to the slow-stream
// branch below so cancel tests can disconnect mid second-turn.
let rid = request_id.clone();
let msg_id = format!(
"msg_{}",
@@ -941,6 +1091,126 @@ async fn responses_handler(
Sse::new(stream)
.keep_alive(KeepAlive::default())
.into_response()
} else if let Some(num_chunks) = get_slow_stream_chunks_for_port(config.port) {
// Slow-stream mode for /responses cancel tests. Mirrors the
// chat-completions slow-stream path so the same set_slow_stream_chunks
// helper drives both endpoints.
let port = config.port;
let delay_ms = config.response_delay_ms;
let error_after = get_stream_error_after_for_port(port);
let rid = request_id.clone();
let msg_id = format!(
"msg_{}",
Uuid::new_v4().to_string().split('-').next().unwrap()
);
init_stream_tracking(port, num_chunks);
let (tx, rx) =
tokio::sync::mpsc::channel::<Result<Event, std::io::Error>>(MOCK_STREAM_BUFFER);
tokio::spawn(async move {
let _exit_guard = install_stream_exit_notifier(port);
// Emit response.created and response.in_progress so the
// gateway's /responses persistence accumulator has the
// structural events it expects.
let created = Event::default().event("response.created").data(
json!({
"type": "response.created",
"response": {
"id": rid.clone(),
"object": "response",
"created_at": timestamp,
"model": "mock-model",
"status": "in_progress"
}
})
.to_string(),
);
if tx.send(Ok(created)).await.is_err() {
return;
}
let in_progress = Event::default().event("response.in_progress").data(
json!({
"type": "response.in_progress",
"response": {
"id": rid.clone(),
"object": "response",
"created_at": timestamp,
"model": "mock-model",
"status": "in_progress"
}
})
.to_string(),
);
if tx.send(Ok(in_progress)).await.is_err() {
return;
}
for i in 0..num_chunks {
if let Some(n) = error_after {
if i == n {
let _ = tx
.send(Err(std::io::Error::other(
"simulated upstream worker crash",
)))
.await;
return;
}
}
if delay_ms > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
}
let delta = Event::default().event("response.output_text.delta").data(
json!({
"type": "response.output_text.delta",
"output_index": 0,
"content_index": 0,
"item_id": msg_id.clone(),
"delta": format!("chunk-{} ", i)
})
.to_string(),
);
if tx.send(Ok(delta)).await.is_err() {
return;
}
record_chunk_sent(port);
}
let aggregated_text = (0..num_chunks)
.map(|i| format!("chunk-{} ", i))
.collect::<String>();
let completed = Event::default().event("response.completed").data(
json!({
"type": "response.completed",
"response": {
"id": rid,
"object": "response",
"created_at": timestamp,
"model": "mock-model",
"status": "completed",
"output": [{
"id": msg_id,
"type": "message",
"role": "assistant",
"content": [{
"type": "output_text",
"text": aggregated_text
}]
}]
}
})
.to_string(),
);
let _ = tx.send(Ok(completed)).await;
let _ = tx.send(Ok(Event::default().data("[DONE]"))).await;
mark_stream_completed(port);
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Sse::new(stream)
.keep_alive(KeepAlive::default())
.into_response()
} else {
// Default streaming response
let stream = stream::once(async move {
@@ -1195,6 +1465,241 @@ async fn responses_cancel_handler(
}
}
// --- Slow-stream configuration (for upstream cancel tests) ---
// Configured via a global map keyed by worker port so that tests
// can enable slow streaming WITHOUT relying on the request payload
// (the gateway deserializes/re-serializes the body, dropping unknown fields).
static SLOW_STREAM_CONFIG: OnceLock<Mutex<HashMap<u16, usize>>> = OnceLock::new();
fn get_slow_stream_config() -> &'static Mutex<HashMap<u16, usize>> {
SLOW_STREAM_CONFIG.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Configure a worker (by port) to send `num_chunks` chunks with
/// `response_delay_ms` between each when handling a streaming request.
/// Call this before making the request through the gateway.
pub fn set_slow_stream_chunks(port: u16, num_chunks: usize) {
let mut map = get_slow_stream_config().lock().unwrap();
map.insert(port, num_chunks);
}
/// Clear slow-stream configuration for a worker port.
pub fn clear_slow_stream_chunks(port: u16) {
let mut map = get_slow_stream_config().lock().unwrap();
map.remove(&port);
}
fn get_slow_stream_chunks_for_port(port: u16) -> Option<usize> {
let map = get_slow_stream_config().lock().unwrap();
map.get(&port).copied()
}
// --- Stream error injection (for upstream cancel + error tests) ---
// When set for `port`, the slow-stream producer emits an io::Error to the
// SSE stream after the configured number of successfully-sent chunks.
// reqwest will surface this as a transport error, which exercises the
// gateway's `Some(Err(_))` arm.
static STREAM_ERROR_AFTER: OnceLock<Mutex<HashMap<u16, usize>>> = OnceLock::new();
fn get_stream_error_after_config() -> &'static Mutex<HashMap<u16, usize>> {
STREAM_ERROR_AFTER.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Configure a worker (by port) to abort its SSE stream with an error
/// after sending `n` chunks. Must be combined with
/// [`set_slow_stream_chunks`] to take effect.
pub fn set_stream_error_after_chunks(port: u16, n: usize) {
let mut map = get_stream_error_after_config().lock().unwrap();
map.insert(port, n);
}
/// Clear error-injection configuration for a worker port.
pub fn clear_stream_error_after_chunks(port: u16) {
let mut map = get_stream_error_after_config().lock().unwrap();
map.remove(&port);
}
fn get_stream_error_after_for_port(port: u16) -> Option<usize> {
let map = get_stream_error_after_config().lock().unwrap();
map.get(&port).copied()
}
// --- Failure-status override (for breaker attribution tests) ---
// When set for `port`, `should_fail`-triggered failures return this HTTP
// status instead of the default 500. Lets a test pin breaker semantics for
// the 4xx-from-worker case (the gateway treats 4xx as "not a worker fault")
// without having to fabricate a separate mock worker.
static FAIL_STATUS_CODE: OnceLock<Mutex<HashMap<u16, u16>>> = OnceLock::new();
fn get_fail_status_code_config() -> &'static Mutex<HashMap<u16, u16>> {
FAIL_STATUS_CODE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Configure a worker (by port) to return `status` when `fail_rate`
/// triggers a failure response, instead of the default 500.
pub fn set_fail_status_code(port: u16, status: u16) {
let mut map = get_fail_status_code_config().lock().unwrap();
map.insert(port, status);
}
/// Clear failure-status override for a worker port.
pub fn clear_fail_status_code(port: u16) {
let mut map = get_fail_status_code_config().lock().unwrap();
map.remove(&port);
}
fn get_fail_status_code_for_port(port: u16) -> Option<u16> {
let map = get_fail_status_code_config().lock().unwrap();
map.get(&port).copied()
}
// --- Stream cancellation tracking (for upstream cancel tests) ---
/// Tracks the state of a streaming response for cancel verification.
#[derive(Clone, Debug)]
pub struct StreamTrackingState {
pub total_chunks: usize,
pub chunks_sent: usize,
pub completed: bool,
}
static STREAM_CANCEL_TRACKER: OnceLock<Mutex<HashMap<u16, StreamTrackingState>>> = OnceLock::new();
fn get_stream_tracker() -> &'static Mutex<HashMap<u16, StreamTrackingState>> {
STREAM_CANCEL_TRACKER.get_or_init(|| Mutex::new(HashMap::new()))
}
// Per-port `Notify` fired when the worker's producer task exits (either
// because its outbound `send().await` failed — i.e. the gateway dropped
// the upstream connection — or because the stream completed naturally).
// Tests await this notification instead of polling counters, so cancel
// assertions don't depend on timing windows.
static STREAM_FINISH_NOTIFIERS: OnceLock<Mutex<HashMap<u16, Arc<Notify>>>> = OnceLock::new();
fn get_stream_finish_notifier_map() -> &'static Mutex<HashMap<u16, Arc<Notify>>> {
STREAM_FINISH_NOTIFIERS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn get_stream_finish_notifier(port: u16) -> Arc<Notify> {
let mut map = get_stream_finish_notifier_map().lock().unwrap();
map.entry(port)
.or_insert_with(|| Arc::new(Notify::new()))
.clone()
}
/// Bound on the per-stream mpsc buffer used by every slow-stream producer
/// task in this mock worker. Tests assert that `chunks_sent` after a cancel
/// grew by at most this many over the pre-drop snapshot, on the theory that
/// anything more means the gateway did not propagate the disconnect
/// upstream.
pub const MOCK_STREAM_BUFFER: usize = 4;
/// RAII guard that fires the per-port finish notifier on drop, so the
/// notification fires whether the producer task exits normally or returns
/// early on `tx.send(...).await.is_err()`.
#[must_use = "StreamExitNotifier must be bound to a local (typically `_exit_guard`) \
and held until the producer task ends — dropping it immediately fires \
the notifier early, causing `wait_for_stream_finish` to return before \
the producer has actually exited"]
pub struct StreamExitNotifier(Arc<Notify>);
impl Drop for StreamExitNotifier {
fn drop(&mut self) {
self.0.notify_one();
}
}
/// Install the exit notifier inside a producer task. Hold the returned
/// guard until the task ends (typically by binding it to `_exit_guard`).
#[must_use = "the returned guard fires the exit notifier on drop; bind it to a local \
(e.g. `let _exit_guard = install_stream_exit_notifier(port);`) so it lives \
for the producer task's lifetime"]
pub fn install_stream_exit_notifier(port: u16) -> StreamExitNotifier {
StreamExitNotifier(get_stream_finish_notifier(port))
}
/// Reset the stream tracker for a given port before starting a new test.
/// Also replaces the finish notifier so any unconsumed permit from a
/// previous test doesn't satisfy this test's wait immediately.
pub fn reset_stream_tracker(port: u16) {
let mut map = get_stream_tracker().lock().unwrap();
map.remove(&port);
let mut nmap = get_stream_finish_notifier_map().lock().unwrap();
nmap.insert(port, Arc::new(Notify::new()));
}
/// Get the stream tracking state for a given port.
pub fn get_stream_tracking_state(port: u16) -> Option<StreamTrackingState> {
let map = get_stream_tracker().lock().unwrap();
map.get(&port).cloned()
}
/// Wait until the worker's producer task for `port` exits — either because
/// the gateway dropped the upstream connection (`send().await` failed) or
/// because the stream finished naturally. Returns the final tracking state.
/// The `timeout` is a safety net for hung tests; a healthy run returns the
/// instant the producer task drops its exit guard.
///
/// **Precondition:** call [`reset_stream_tracker`] before issuing the
/// gateway request whose producer you intend to wait on. The reset
/// installs a fresh `Notify` so a stale permit left by a previous test
/// on the same port can't satisfy this wait immediately.
pub async fn wait_for_stream_finish(
port: u16,
timeout: tokio::time::Duration,
) -> Option<StreamTrackingState> {
let notifier = get_stream_finish_notifier(port);
if tokio::time::timeout(timeout, notifier.notified())
.await
.is_err()
{
// A hung producer would silently look like a successful cancel
// (chunks_sent < total_chunks, completed=false) if we just
// returned what we have. Panic instead so the test fails loudly.
panic!(
"wait_for_stream_finish timed out after {:?} for port {}\
producer task never fired its exit notifier. Last tracker \
state: {:?}",
timeout,
port,
get_stream_tracking_state(port)
);
}
get_stream_tracking_state(port)
}
// Initialize tracking for a new stream. `map.insert` overwrites any prior
// entry for this port, so callers don't need to reset first; we still expose
// `reset_stream_tracker` so tests can opt into removing the entry entirely.
fn init_stream_tracking(port: u16, total_chunks: usize) {
let mut map = get_stream_tracker().lock().unwrap();
map.insert(
port,
StreamTrackingState {
total_chunks,
chunks_sent: 0,
completed: false,
},
);
}
fn record_chunk_sent(port: u16) {
let mut map = get_stream_tracker().lock().unwrap();
if let Some(state) = map.get_mut(&port) {
state.chunks_sent += 1;
}
}
fn mark_stream_completed(port: u16) {
let mut map = get_stream_tracker().lock().unwrap();
if let Some(state) = map.get_mut(&port) {
state.completed = true;
}
}
// --- Simple in-memory response store per worker port (for tests) ---
static RESP_STORE: OnceLock<Mutex<HashMap<u16, HashSet<String>>>> = OnceLock::new();
@@ -4,3 +4,4 @@ pub mod circuit_breaker_test;
pub mod fault_tolerance_test;
pub mod rate_limiting_test;
pub mod retries_test;
pub mod upstream_cancel_test;
File diff suppressed because it is too large Load Diff