[Router] Treat an upstream 503/429 as backpressure, not a breaker fault (2/3) (#39464)

Co-authored-by: Kangyan Zhou <kangyan.zhou@radixark.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kangyan-Zhou
2026-09-19 00:46:29 +08:00
committed by GitHub
co-authored by Kangyan Zhou Claude Opus 5
parent 191172fa74
commit 4e0b56c811
2 changed files with 400 additions and 26 deletions
@@ -172,6 +172,35 @@ impl CircuitBreaker {
}
}
}
/// Record a backpressure response (HTTP 503 / 429): the worker answered, so
/// it is responsive — busy, not faulty.
///
/// - **Closed:** no-op. A busy worker must not open the breaker, and —
/// unlike [`record_success`](Self::record_success) — backpressure must
/// NOT reset an in-progress failure streak, so a worker interleaving real
/// 5xx faults with 503s still trips.
/// - **HalfOpen:** close. Any response observed here proves the worker is
/// answering, which is what the probe exists to find out. Leaving HalfOpen
/// unresolved would wedge the breaker permanently — the probe slot is
/// released only by a success or failure, and backpressure is neither —
/// shutting a recovered-but-busy worker out forever (a worse false-shed
/// than the one ignoring 503 removes). The responder is not necessarily
/// the probe: [`allow`](Self::allow) gates admission, not completion, so a
/// request admitted while Closed can land here. [`record_success`] has the
/// same property.
/// - **Open:** no-op, and reachable — `allow` gates admission, not
/// completion, so a request admitted while Closed can return after
/// concurrent failures have opened the breaker. A late backpressure answer
/// must not reset a breaker that has already tripped, exactly as
/// [`record_failure`](Self::record_failure) ignores failures while Open.
pub fn record_backpressure(&self) {
let mut g = self.inner.lock().unwrap();
if matches!(g.state, State::HalfOpen { .. }) {
g.consecutive_failures = 0;
g.state = State::Closed;
}
}
}
impl Default for CircuitBreaker {
@@ -245,4 +274,67 @@ mod tests {
assert!(s.admit, "open past cooldown admits a probe");
assert_eq!(s.state_code, 1, "...but is still reported as open");
}
#[tokio::test(start_paused = true)]
async fn backpressure_resolves_half_open_probe() {
// Regression guard: a backpressure (503/429) answer to a half-open
// probe must RESOLVE the probe, not wedge the breaker. The probe slot
// is otherwise released only by success/failure; without
// record_backpressure handling HalfOpen, a recovered-but-busy worker
// would be shut out forever.
let b = cb(1, 10);
b.record_failure(); // Open
assert_eq!(b.snapshot().state_code, 1);
tokio::time::advance(Duration::from_secs(11)).await;
assert!(
b.allow(),
"cooldown elapsed → claims the probe slot (HalfOpen)"
);
assert_eq!(b.snapshot().state_code, 2);
b.record_backpressure();
assert_eq!(
b.snapshot().state_code,
0,
"a 503 probe answer must close the breaker, not leave it wedged half-open",
);
assert!(
b.would_allow(),
"worker must admit again after the probe resolves"
);
}
#[test]
fn backpressure_in_closed_state_preserves_failure_streak() {
// Unlike record_success, record_backpressure must NOT reset an
// in-progress streak: 2 faults + a 503 + 1 fault still hits threshold 3.
let b = cb(3, 30);
b.record_failure();
b.record_failure();
b.record_backpressure();
assert_eq!(
b.snapshot().state_code,
0,
"2 faults < threshold 3, still closed"
);
b.record_failure();
assert_eq!(
b.snapshot().state_code,
1,
"the 503 must not have reset the streak; the 3rd fault opens the breaker",
);
}
#[test]
fn backpressure_alone_never_opens_a_closed_breaker() {
let b = cb(3, 30);
for _ in 0..10 {
b.record_backpressure();
}
assert_eq!(
b.snapshot().state_code,
0,
"backpressure alone must never open the breaker, regardless of volume",
);
}
}
+308 -26
View File
@@ -31,6 +31,56 @@ fn parse_worker_url(worker_url: &str, breaker: &CircuitBreaker) -> Result<Url, A
})
}
/// How an upstream HTTP response status should affect the worker's circuit
/// breaker, at the dispatch sites (`forward_json_to` / `forward_streaming_to`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BreakerOutcome {
/// The worker was responsive — a 2xx, or a 4xx it answered cleanly (a
/// client's bad request says nothing about worker health). The non-streaming
/// arm records success immediately; the streaming arm defers to the pump's
/// completion hook, which records success or failure by
/// [`sse::StreamEnd::transport_ok`], since a 2xx head can still be followed
/// by a body that never completes.
Success,
/// A real fault (5xx other than backpressure) → `record_failure`: count
/// toward opening.
Failure,
/// Backpressure (the worker is responsive but at capacity) →
/// `record_backpressure`: never opens the breaker and, while Closed, leaves
/// an in-progress failure streak intact — but still resolves a half-open
/// probe so a recovered-but-busy worker isn't wedged shut.
Neutral,
}
/// Classify an upstream status for circuit-breaker accounting.
///
/// A backpressure status — `503 Service Unavailable` or `429 Too Many
/// Requests` — is the worker signalling "responsive but at capacity", not a
/// fault. Counting it as a breaker failure is actively harmful: a saturated
/// worker trips the breaker on its own queue-full 503s, and with a single
/// worker the router then sheds *every* request for the whole cool-down —
/// including after the engine has drained and gone idle. So backpressure is
/// [`Neutral`](BreakerOutcome::Neutral) (see [`CircuitBreaker::record_backpressure`]
/// for its exact effect per breaker state). Genuine 5xx faults (500 / 502 /
/// 504 / …) still count as failures, and transport errors / timeouts /
/// mid-body drops are recorded as failures at the call sites — as are a
/// malformed discovery URL (`parse_worker_url`) and a stream whose body dies
/// after a 2xx head.
///
/// Tradeoff: because 503 never opens the breaker, a worker stuck returning 503
/// indefinitely (a wedged engine, not transient load) is NOT detected here —
/// HTTP status alone can't distinguish "busy" from "broken-and-saying-503", and
/// counting it caused the worse fleet-wide false-shed above. Detecting a
/// chronically-backpressuring worker is left to higher-level signals.
fn breaker_outcome(status: reqwest::StatusCode) -> BreakerOutcome {
use reqwest::StatusCode;
match status {
StatusCode::SERVICE_UNAVAILABLE | StatusCode::TOO_MANY_REQUESTS => BreakerOutcome::Neutral,
s if s.is_server_error() => BreakerOutcome::Failure,
_ => BreakerOutcome::Success,
}
}
#[derive(Debug)]
pub struct Proxy {
/// The negotiating client: HTTP/1.1 in cleartext, and ALPN `h2, http/1.1`
@@ -120,9 +170,10 @@ impl Proxy {
}
}
/// Breaker-gated JSON POST: checks `breaker.allow()` first, records
/// success/failure based on response status, and returns
/// `ApiError::BreakerOpen` immediately when the breaker is Open.
/// Breaker-gated JSON POST: checks `breaker.allow()` first, classifies the
/// response status through [`breaker_outcome`] (success / failure /
/// backpressure), and returns `ApiError::BreakerOpen` immediately when the
/// breaker is Open.
///
/// `worker_url` is the discovery-emitted worker URL string. It's parsed
/// to [`reqwest::Url`] internally so we can use [`Url::join`] for clean
@@ -185,10 +236,14 @@ impl Proxy {
return Err(ApiError::UpstreamStatus { status });
}
};
if status.is_server_error() {
breaker.record_failure();
} else {
breaker.record_success();
match breaker_outcome(status) {
BreakerOutcome::Failure => breaker.record_failure(),
BreakerOutcome::Success => breaker.record_success(),
// Backpressure (503/429): the engine is healthy but busy. This never
// opens the breaker and (in Closed) leaves the failure streak
// intact, but it DOES resolve a half-open probe so a recovered
// worker that answers a probe with 503 isn't wedged shut.
BreakerOutcome::Neutral => breaker.record_backpressure(),
}
let mut out = Response::new(Body::from(bytes));
*out.status_mut() = status;
@@ -199,8 +254,9 @@ impl Proxy {
Ok(out)
}
/// Breaker-gated streaming POST: checks `breaker.allow()` first, records
/// success/failure, and returns `ApiError::BreakerOpen` when Open.
/// Breaker-gated streaming POST: checks `breaker.allow()` first, classifies
/// the response status through [`breaker_outcome`], and returns
/// `ApiError::BreakerOpen` when Open.
///
/// `stream_guards` — when `Some`, the value is threaded into the SSE
/// pump task and held for the entire body lifetime (headers → last byte
@@ -263,30 +319,42 @@ impl Proxy {
};
// Breaker recording is deferred to the pump's completion hook so
// an upstream that returns 2xx headers and then drops mid-stream
// is recorded as a failure. For 5xx headers we record_failure
// is recorded as a failure. For a genuine 5xx fault we record_failure
// up front and skip the pump hook (the body we surface is the
// error response — its stream completing is not a worker win).
// error response — its stream completing is not a worker win). For a
// backpressure status (503/429) we record_backpressure up front and
// skip the hook: a busy-but-healthy engine's queue-full responses can't
// open the breaker, but a half-open probe answered with 503 is still
// resolved rather than wedged (see `breaker_outcome` /
// `record_backpressure`).
let caller_end_hook = if status.is_success() {
on_stream_end
} else {
None
};
let on_complete: Option<Box<dyn FnOnce(sse::StreamEnd) + Send + 'static>> =
if status.is_server_error() {
breaker.record_failure();
None
} else {
let breaker_for_hook = Arc::clone(breaker);
Some(Box::new(move |end| {
if end.transport_ok {
breaker_for_hook.record_success();
} else {
breaker_for_hook.record_failure();
}
if let Some(hook) = caller_end_hook {
hook(end);
}
}))
match breaker_outcome(status) {
BreakerOutcome::Failure => {
breaker.record_failure();
None
}
BreakerOutcome::Neutral => {
breaker.record_backpressure();
None
}
BreakerOutcome::Success => {
let breaker_for_hook = Arc::clone(breaker);
Some(Box::new(move |end| {
if end.transport_ok {
breaker_for_hook.record_success();
} else {
breaker_for_hook.record_failure();
}
if let Some(hook) = caller_end_hook {
hook(end);
}
}))
}
};
// Only record TTFT for successful streams; error-body chunks are not
// generated tokens.
@@ -315,7 +383,14 @@ impl Proxy {
#[cfg(test)]
mod tests {
use super::*;
use crate::health::circuit_breaker::CircuitBreakerConfig;
use axum::routing::post;
use axum::Router;
use reqwest::StatusCode;
use std::num::NonZeroU32;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
#[tokio::test]
async fn new_returns_result_not_panic() {
@@ -342,4 +417,211 @@ mod tests {
p.admin_client()
));
}
#[test]
fn breaker_outcome_treats_backpressure_as_neutral() {
// Backpressure: healthy but busy — must not touch the breaker.
assert_eq!(
breaker_outcome(StatusCode::SERVICE_UNAVAILABLE),
BreakerOutcome::Neutral,
);
assert_eq!(
breaker_outcome(StatusCode::TOO_MANY_REQUESTS),
BreakerOutcome::Neutral,
);
// Genuine faults: still failures.
for s in [
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::BAD_GATEWAY,
StatusCode::GATEWAY_TIMEOUT,
] {
assert_eq!(breaker_outcome(s), BreakerOutcome::Failure, "{s}");
}
// Non-5xx (incl. 4xx client errors): treated as success.
for s in [
StatusCode::OK,
StatusCode::BAD_REQUEST,
StatusCode::NOT_FOUND,
] {
assert_eq!(breaker_outcome(s), BreakerOutcome::Success, "{s}");
}
}
/// A fake upstream that answers every POST with a fixed status + tiny body.
async fn spawn_status_worker(status: u16) -> (String, oneshot::Sender<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let code = StatusCode::from_u16(status).unwrap();
let app = Router::new().route(
"/v1/chat/completions",
post(move || async move { (code, "{\"error\":\"x\"}") }),
);
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
(format!("http://127.0.0.1:{port}"), tx)
}
/// A saturated engine's own queue-full 503s must not trip the router's
/// circuit breaker. Dispatch far past any plausible failure threshold and
/// assert the breaker stays Closed and admitting.
#[tokio::test]
async fn engine_503_does_not_trip_breaker() {
let (url, _shutdown) = spawn_status_worker(503).await;
let proxy = Proxy::new(Duration::from_secs(5)).unwrap();
let breaker = CircuitBreaker::new();
let headers = HeaderMap::new();
for i in 0..50 {
let resp = proxy
.forward_json_to(
&url,
WireProtocol::Http1,
&breaker,
"/v1/chat/completions",
&headers,
Bytes::from_static(b"{}"),
)
.await
.expect("dispatch should reach the worker (breaker must stay closed)");
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"iter {i}: client must still see the engine's 503",
);
assert_eq!(
breaker.snapshot().state_code,
0,
"iter {i}: 503 backpressure must leave the breaker Closed",
);
}
assert!(
breaker.would_allow(),
"breaker must keep admitting after a burst of engine 503s",
);
}
/// Contrast guard, so the backpressure carve-out cannot disable fault
/// detection: a genuine 5xx fault (500) MUST still open the breaker. Loops
/// on `would_allow()` rather than a fixed count so the test stays correct if
/// the default `CircuitBreakerConfig` threshold changes.
#[tokio::test]
async fn engine_500_still_trips_breaker() {
let (url, _shutdown) = spawn_status_worker(500).await;
let proxy = Proxy::new(Duration::from_secs(5)).unwrap();
let breaker = CircuitBreaker::new();
let headers = HeaderMap::new();
for _ in 0..50 {
if !breaker.would_allow() {
break;
}
let _ = proxy
.forward_json_to(
&url,
WireProtocol::Http1,
&breaker,
"/v1/chat/completions",
&headers,
Bytes::from_static(b"{}"),
)
.await;
}
assert_eq!(
breaker.snapshot().state_code,
1,
"a run of 500s must open the breaker (fault detection still works)",
);
}
/// End-to-end wedge guard: a breaker that opened on real faults, then has
/// its half-open probe answered with a 503, must RECOVER — not stay shut
/// out forever. Exercises the `Neutral => record_backpressure` wiring in
/// `forward_json_to` through the half-open path.
#[tokio::test]
async fn engine_503_recovers_a_half_open_breaker() {
let (url, _shutdown) = spawn_status_worker(503).await;
let proxy = Proxy::new(Duration::from_secs(5)).unwrap();
// threshold=1 so one prior fault opens it; a short cooldown so the probe
// is admitted quickly. The wait below is an order of magnitude longer
// than the cooldown rather than a thin margin, since this test needs a
// real socket and so cannot pause the clock.
let breaker = CircuitBreaker::with_config(CircuitBreakerConfig {
threshold: NonZeroU32::new(1).unwrap(),
cool_down: Duration::from_millis(20),
});
let headers = HeaderMap::new();
// Simulate a prior genuine fault (e.g. a 500 / timeout) that tripped it.
breaker.record_failure();
assert_eq!(breaker.snapshot().state_code, 1, "breaker should be Open");
// Let the cooldown elapse so the next dispatch claims the half-open probe.
tokio::time::sleep(Duration::from_millis(200)).await;
let resp = proxy
.forward_json_to(
&url,
WireProtocol::Http1,
&breaker,
"/v1/chat/completions",
&headers,
Bytes::from_static(b"{}"),
)
.await
.expect("the half-open probe must be admitted and reach the worker");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
breaker.snapshot().state_code,
0,
"a 503 answer to the probe must close the breaker, not wedge it half-open",
);
assert!(
breaker.would_allow(),
"worker must admit traffic again after recovering from the probe",
);
}
/// Streaming path parity: the engine's 503 on the streaming arm must also
/// leave the breaker untouched (no up-front failure, no completion hook).
#[tokio::test]
async fn engine_503_does_not_trip_breaker_streaming() {
use http_body_util::BodyExt;
let (url, _shutdown) = spawn_status_worker(503).await;
let proxy = Proxy::new(Duration::from_secs(5)).unwrap();
let breaker = Arc::new(CircuitBreaker::new());
let headers = HeaderMap::new();
for i in 0..6 {
let resp = proxy
.forward_streaming_to(
&url,
WireProtocol::Http1,
&breaker,
"/v1/chat/completions",
&headers,
Bytes::from_static(b"{}"),
None,
None,
None,
)
.await
.expect("streaming dispatch should reach the worker");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE, "iter {i}");
// Drain the body so the pump task runs to completion (would fire any
// completion hook). For a 503 there is none, but draining proves it.
let _ = resp.into_body().collect().await;
assert_eq!(
breaker.snapshot().state_code,
0,
"iter {i}: streaming 503 must leave the breaker Closed",
);
}
}
}