[model-gateway] add retry support to OpenAI router chat endpoint (#15589)

This commit is contained in:
Simo Lin
2025-12-21 20:28:51 -08:00
committed by GitHub
parent ad7f35fb9d
commit 1167867e8d
+134 -89
View File
@@ -34,7 +34,11 @@ use super::{
}; };
use crate::{ use crate::{
app_context::AppContext, app_context::AppContext,
core::{model_type::Endpoint, ModelCard, ProviderType, RuntimeType, Worker, WorkerRegistry}, config::types::RetryConfig,
core::{
is_retryable_status, model_type::Endpoint, ModelCard, ProviderType, RetryExecutor,
RuntimeType, Worker, WorkerRegistry,
},
data_connector::{ConversationId, ListParams, ResponseId, SortOrder}, data_connector::{ConversationId, ListParams, ResponseId, SortOrder},
observability::metrics::{bool_to_static_str, metrics_labels, Metrics}, observability::metrics::{bool_to_static_str, metrics_labels, Metrics},
protocols::{ protocols::{
@@ -53,6 +57,7 @@ pub struct OpenAIRouter {
healthy: AtomicBool, healthy: AtomicBool,
shared_components: Arc<SharedComponents>, shared_components: Arc<SharedComponents>,
responses_components: Arc<ResponsesComponents>, responses_components: Arc<ResponsesComponents>,
retry_config: RetryConfig,
} }
impl std::fmt::Debug for OpenAIRouter { impl std::fmt::Debug for OpenAIRouter {
@@ -176,6 +181,7 @@ impl OpenAIRouter {
healthy: AtomicBool::new(true), healthy: AtomicBool::new(true),
shared_components, shared_components,
responses_components, responses_components,
retry_config: ctx.router_config.effective_retry_config(),
}) })
} }
@@ -659,77 +665,129 @@ impl crate::routers::RouterTrait for OpenAIRouter {
previous_response_id: None, previous_response_id: None,
}); });
// Wrap values in Arc to avoid cloning large objects on each retry attempt
let payload_ref = ctx.payload().expect("Payload not prepared"); let payload_ref = ctx.payload().expect("Payload not prepared");
let mut req = ctx.components.client().post(&url).json(&payload_ref.json); let payload_json = Arc::new(payload_ref.json.clone());
let auth_header = extract_auth_header(ctx.headers(), worker.api_key()); let client = ctx.components.client().clone();
req = apply_provider_headers(req, &url, auth_header.as_ref()); let headers_cloned = Arc::new(ctx.headers().cloned());
let worker_api_key = Arc::new(worker.api_key().clone());
let is_streaming = ctx.is_streaming();
if ctx.is_streaming() { let response = RetryExecutor::execute_response_with_retry(
req = req.header("Accept", "text/event-stream"); &self.retry_config,
} |_attempt| {
let client = client.clone();
let url = url.clone();
let payload = Arc::clone(&payload_json);
let headers = Arc::clone(&headers_cloned);
let worker_api_key = Arc::clone(&worker_api_key);
let worker = Arc::clone(&worker);
let resp = match req.send().await { async move {
Ok(r) => r, let mut req = client.post(&url).json(&*payload);
Err(e) => { let auth_header = extract_auth_header((*headers).as_ref(), &worker_api_key);
worker.circuit_breaker().record_failure(); req = apply_provider_headers(req, &url, auth_header.as_ref());
Metrics::record_router_error(
metrics_labels::ROUTER_OPENAI,
metrics_labels::BACKEND_EXTERNAL,
metrics_labels::CONNECTION_HTTP,
model,
metrics_labels::ENDPOINT_CHAT,
metrics_labels::ERROR_BACKEND,
);
return (
StatusCode::SERVICE_UNAVAILABLE,
format!("Failed to contact upstream: {}", e),
)
.into_response();
}
};
let status = StatusCode::from_u16(resp.status().as_u16()) if is_streaming {
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); req = req.header("Accept", "text/event-stream");
}
if !ctx.is_streaming() { let resp = match req.send().await {
let content_type = resp.headers().get(CONTENT_TYPE).cloned(); Ok(r) => r,
match resp.bytes().await { Err(e) => {
Ok(body) => { worker.circuit_breaker().record_failure();
worker.circuit_breaker().record_success(); return (
Metrics::record_router_duration( StatusCode::SERVICE_UNAVAILABLE,
metrics_labels::ROUTER_OPENAI, format!("Failed to contact upstream: {}", e),
metrics_labels::BACKEND_EXTERNAL, )
metrics_labels::CONNECTION_HTTP, .into_response();
model, }
metrics_labels::ENDPOINT_CHAT, };
start.elapsed(),
); let status = StatusCode::from_u16(resp.status().as_u16())
let mut response = Response::new(Body::from(body)); .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
*response.status_mut() = status;
if let Some(ct) = content_type { // Record circuit breaker failure for error status codes
response.headers_mut().insert(CONTENT_TYPE, ct); if !status.is_success() {
worker.circuit_breaker().record_failure();
}
if !is_streaming {
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();
}
let mut response = Response::new(Body::from(body));
*response.status_mut() = status;
if let Some(ct) = content_type {
response.headers_mut().insert(CONTENT_TYPE, ct);
}
response
}
Err(e) => {
worker.circuit_breaker().record_failure();
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read response: {}", e),
)
.into_response()
}
}
} else {
// Streaming response - record success when stream starts
if status.is_success() {
worker.circuit_breaker().record_success();
}
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)));
*response.status_mut() = status;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
response
} }
response
} }
Err(e) => { },
worker.circuit_breaker().record_failure(); |res, _attempt| is_retryable_status(res.status()),
Metrics::record_router_error( |delay, attempt| {
metrics_labels::ROUTER_OPENAI, Metrics::record_worker_retry(
metrics_labels::BACKEND_EXTERNAL, metrics_labels::BACKEND_EXTERNAL,
metrics_labels::CONNECTION_HTTP, metrics_labels::ENDPOINT_CHAT,
model, );
metrics_labels::ENDPOINT_CHAT, Metrics::record_worker_retry_backoff(attempt, delay);
metrics_labels::ERROR_BACKEND, },
); || {
( Metrics::record_worker_retries_exhausted(
StatusCode::INTERNAL_SERVER_ERROR, metrics_labels::BACKEND_EXTERNAL,
format!("Failed to read response: {}", e), metrics_labels::ENDPOINT_CHAT,
) );
.into_response() },
} )
} .await;
} else {
// For streaming, record duration at start since we can't track completion // Record duration/error metrics after retry completes
if response.status().is_success() {
Metrics::record_router_duration( Metrics::record_router_duration(
metrics_labels::ROUTER_OPENAI, metrics_labels::ROUTER_OPENAI,
metrics_labels::BACKEND_EXTERNAL, metrics_labels::BACKEND_EXTERNAL,
@@ -738,31 +796,18 @@ impl crate::routers::RouterTrait for OpenAIRouter {
metrics_labels::ENDPOINT_CHAT, metrics_labels::ENDPOINT_CHAT,
start.elapsed(), start.elapsed(),
); );
let stream = resp.bytes_stream(); } else {
let (tx, rx) = mpsc::unbounded_channel(); Metrics::record_router_error(
tokio::spawn(async move { metrics_labels::ROUTER_OPENAI,
let mut s = stream; metrics_labels::BACKEND_EXTERNAL,
while let Some(chunk) = s.next().await { metrics_labels::CONNECTION_HTTP,
match chunk { model,
Ok(bytes) => { metrics_labels::ENDPOINT_CHAT,
if tx.send(Ok(bytes)).is_err() { metrics_labels::ERROR_BACKEND,
break; );
}
}
Err(e) => {
let _ = tx.send(Err(format!("Stream error: {}", e)));
break;
}
}
}
});
let mut response = Response::new(Body::from_stream(UnboundedReceiverStream::new(rx)));
*response.status_mut() = status;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
response
} }
response
} }
async fn route_responses( async fn route_responses(