From f4104179d2ee456df864dd0c081104cc636b7a52 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 23 Oct 2026 14:20:08 -0700 Subject: [PATCH] fix(rust-error): carry OCR provider headers as a HeaderMap and keep outages off the caller The OCR provider error and get_error_class take reqwest's HeaderMap, the bridge turns it into Python's (name, value) pairs once. Credential acquisition and provider SDK failures are no longer the caller's fault, the UI hides auth and backend causes, and chat keeps a provider answer as is. Co-Authored-By: Claude Opus 5.5 --- litellm-rust/crates/auth-types/src/error.rs | 9 ++++-- .../crates/gateway-inference/src/error.rs | 2 +- litellm-rust/crates/gateway-ui/Cargo.toml | 2 +- litellm-rust/crates/gateway-ui/src/error.rs | 28 ++++++++++++++++++- .../crates/inference-chat/src/handler.rs | 11 +++++++- .../crates/inference-ocr/tests/ocr/mistral.rs | 2 +- .../ocr/analyze_transformation.rs | 2 +- .../llms/src/aws_textract/ocr/common_utils.rs | 7 +++-- .../src/aws_textract/ocr/transformation.rs | 4 +-- .../crates/llms/src/base_llm/ocr/error.rs | 9 +++--- .../crates/llms/src/base_llm/ocr/handler.rs | 4 +-- .../llms/src/base_llm/ocr/transformation.rs | 2 +- .../crates/python-bridge/src/errors.rs | 25 +++++++++++++---- .../python-bridge/src/routes/ocr/errors.rs | 7 +++-- 14 files changed, 87 insertions(+), 27 deletions(-) diff --git a/litellm-rust/crates/auth-types/src/error.rs b/litellm-rust/crates/auth-types/src/error.rs index 8a6f4115e3a..b0fc43a91b7 100644 --- a/litellm-rust/crates/auth-types/src/error.rs +++ b/litellm-rust/crates/auth-types/src/error.rs @@ -68,11 +68,9 @@ impl Error { pub fn is_request(&self) -> bool { match self { Self::InvalidConfiguration(_) - | Self::CredentialAcquisition(_) | Self::EmptyCallerCredential { .. } - | Self::Provider(_) - | Self::CredentialChain(_) | Self::InvalidHeader => true, + Self::CredentialAcquisition(_) | Self::Provider(_) | Self::CredentialChain(_) => false, Self::MissingParam { spec, .. } => match spec.kind { ParamKind::Setting => true, ParamKind::Credential => false, @@ -273,6 +271,11 @@ mod tests { #[case::api_key(Error::MissingApiKey { provider: "Example", environment_variable: "EXAMPLE_API_KEY" }, false)] #[case::invalid_configuration(Error::InvalidConfiguration(ErrorDetail::PlanNotAllowed), true)] #[case::invalid_header(Error::InvalidHeader, true)] + #[case::identity_provider_outage( + Error::CredentialAcquisition(ErrorDetail::MissingResolver), + false + )] + #[case::provider_sdk_failure(Error::provider(denied()), false)] fn a_setting_the_caller_can_pass_is_theirs_to_fix_but_credentials_and_api_bases_are_not( #[case] error: Error, #[case] expected: bool, diff --git a/litellm-rust/crates/gateway-inference/src/error.rs b/litellm-rust/crates/gateway-inference/src/error.rs index 75fe23a7035..117c5f5eda7 100644 --- a/litellm-rust/crates/gateway-inference/src/error.rs +++ b/litellm-rust/crates/gateway-inference/src/error.rs @@ -320,7 +320,7 @@ mod tests { StatusCode::INTERNAL_SERVER_ERROR )] #[case::ocr_provider_answer( - Error::Ocr(OcrError::Provider { status: 429, body: "slow".into(), headers: vec![] }), + Error::Ocr(OcrError::Provider { status: 429, body: "slow".into(), headers: Default::default() }), StatusCode::TOO_MANY_REQUESTS )] #[case::ocr_missing_credential( diff --git a/litellm-rust/crates/gateway-ui/Cargo.toml b/litellm-rust/crates/gateway-ui/Cargo.toml index 8a8c6328712..1c65665be72 100644 --- a/litellm-rust/crates/gateway-ui/Cargo.toml +++ b/litellm-rust/crates/gateway-ui/Cargo.toml @@ -6,12 +6,12 @@ license.workspace = true repository.workspace = true [dependencies] +litellm-error.workspace = true axum = { workspace = true, features = ["json", "original-uri", "query"] } axum-login.workspace = true base64.workspace = true governor = { version = "0.10.4", default-features = false, features = ["std"] } jsonwebtoken = { workspace = true, features = ["rust_crypto"] } -litellm-error.workspace = true litellm-gateway-auth.workspace = true rand.workspace = true serde.workspace = true diff --git a/litellm-rust/crates/gateway-ui/src/error.rs b/litellm-rust/crates/gateway-ui/src/error.rs index 99940e03cd7..cce01ba76ce 100644 --- a/litellm-rust/crates/gateway-ui/src/error.rs +++ b/litellm-rust/crates/gateway-ui/src/error.rs @@ -48,12 +48,26 @@ impl Error { } } +impl Error { + fn message(&self) -> String { + match self { + Self::Body(_) | Self::Query(_) => litellm_error::Report::new(self).to_string(), + Self::InvalidCredentials + | Self::RateLimited + | Self::Login(_) + | Self::Session(_) + | Self::Token(_) + | Self::Auth(_) => self.to_string(), + } + } +} + impl IntoResponse for Error { fn into_response(self) -> Response { let status = self.status(); let body = Json(ErrorBody { error: ErrorMessage { - message: litellm_error::Report::new(&self).to_string(), + message: self.message(), }, }); match status { @@ -99,4 +113,16 @@ mod tests { retry_after ); } + + #[tokio::test] + async fn a_body_never_shows_what_the_backend_said() { + let backend = std::io::Error::other("vault token rejected"); + let response = Error::Auth(AuthError::Unavailable(litellm_error::Source::new(backend))) + .into_response(); + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!(!body.contains("vault token rejected"), "{body}"); + } } diff --git a/litellm-rust/crates/inference-chat/src/handler.rs b/litellm-rust/crates/inference-chat/src/handler.rs index 73df38df6b7..c34d60e1b8a 100644 --- a/litellm-rust/crates/inference-chat/src/handler.rs +++ b/litellm-rust/crates/inference-chat/src/handler.rs @@ -144,7 +144,7 @@ pub(super) async fn execute( /// implementation must not retry those, so collapse them to one variant that /// can only mean the provider was already called. pub(super) fn as_response_error(err: Error) -> Error { - if err.is_response() { + if err.is_response() || err.upstream_response().is_some() { return err; } Error::invalid_response(litellm_llms::ErrorDetail::invalid( @@ -340,4 +340,13 @@ mod tests { let report = litellm_error::Report::new(&as_response_error(original)).to_string(); assert!(!report.contains("chat completions response"), "{report}"); } + + #[test] + fn a_provider_answer_keeps_its_status_and_body() { + let upstream = Error::from(litellm_http::Error::upstream(429, "slow down".into())); + assert_eq!( + as_response_error(upstream).upstream_response(), + Some((429, "slow down")) + ); + } } diff --git a/litellm-rust/crates/inference-ocr/tests/ocr/mistral.rs b/litellm-rust/crates/inference-ocr/tests/ocr/mistral.rs index a5d40496ab9..21cf4ce455d 100644 --- a/litellm-rust/crates/inference-ocr/tests/ocr/mistral.rs +++ b/litellm-rust/crates/inference-ocr/tests/ocr/mistral.rs @@ -95,7 +95,7 @@ async fn an_upstream_error_keeps_its_status_whole_body_and_headers( let (status, body, headers) = error.upstream_response().expect("the provider answered"); assert_eq!(status, 422); assert_eq!(body, expected_body); - let header = |name: &str| litellm_http::request::header_value(headers, name); + let header = |name: &str| headers.get(name).and_then(|value| value.to_str().ok()); assert_eq!(header("retry-after"), Some("17")); assert_eq!(header("x-request-id"), Some("request-123")); assert_eq!(header("x-future-header"), Some("retained")); diff --git a/litellm-rust/crates/llms/src/aws_textract/ocr/analyze_transformation.rs b/litellm-rust/crates/llms/src/aws_textract/ocr/analyze_transformation.rs index e813d4ac8cc..82119cecfa5 100644 --- a/litellm-rust/crates/llms/src/aws_textract/ocr/analyze_transformation.rs +++ b/litellm-rust/crates/llms/src/aws_textract/ocr/analyze_transformation.rs @@ -123,7 +123,7 @@ impl BaseOcrConfig for TextractAnalyzeDocumentConfig { &self, error_message: String, status_code: u16, - headers: Vec<(String, String)>, + headers: reqwest::header::HeaderMap, ) -> Error { error_class(error_message, status_code, headers) } diff --git a/litellm-rust/crates/llms/src/aws_textract/ocr/common_utils.rs b/litellm-rust/crates/llms/src/aws_textract/ocr/common_utils.rs index bc199f40938..112e9cf299b 100644 --- a/litellm-rust/crates/llms/src/aws_textract/ocr/common_utils.rs +++ b/litellm-rust/crates/llms/src/aws_textract/ocr/common_utils.rs @@ -321,7 +321,7 @@ struct AwsError { /// Textract answers both an unsupported format and a multi-page PDF or TIFF /// with a bare "unsupported document format", which reads like a corrupt file. /// Say what the synchronous API accepts. -pub(super) fn error_class(body: String, status: u16, headers: Vec<(String, String)>) -> Error { +pub(super) fn error_class(body: String, status: u16, headers: reqwest::header::HeaderMap) -> Error { let unsupported = serde_json::from_str::(&body) .ok() .filter(|error| error.kind.ends_with(UNSUPPORTED_DOCUMENT)); @@ -559,7 +559,10 @@ mod tests { #[case] body: &str, #[case] hinted_message: Option<&str>, ) { - let response_headers = vec![("x-amzn-requestid".to_string(), "abc".to_string())]; + let response_headers = reqwest::header::HeaderMap::from_iter([( + reqwest::header::HeaderName::from_static("x-amzn-requestid"), + reqwest::header::HeaderValue::from_static("abc"), + )]); let Error::Provider { status, diff --git a/litellm-rust/crates/llms/src/aws_textract/ocr/transformation.rs b/litellm-rust/crates/llms/src/aws_textract/ocr/transformation.rs index 4b27a191734..34213926942 100644 --- a/litellm-rust/crates/llms/src/aws_textract/ocr/transformation.rs +++ b/litellm-rust/crates/llms/src/aws_textract/ocr/transformation.rs @@ -104,7 +104,7 @@ impl BaseOcrConfig for TextractDetectTextConfig { &self, error_message: String, status_code: u16, - headers: Vec<(String, String)>, + headers: reqwest::header::HeaderMap, ) -> Error { error_class(error_message, status_code, headers) } @@ -237,7 +237,7 @@ mod tests { let error = TextractDetectTextConfig.get_error_class( r#"{"__type":"UnsupportedDocumentException","Message":"Request has unsupported document format"}"#.into(), 400, - Vec::new(), + reqwest::header::HeaderMap::new(), ); assert!( diff --git a/litellm-rust/crates/llms/src/base_llm/ocr/error.rs b/litellm-rust/crates/llms/src/base_llm/ocr/error.rs index 682e3045a7a..40302251320 100644 --- a/litellm-rust/crates/llms/src/base_llm/ocr/error.rs +++ b/litellm-rust/crates/llms/src/base_llm/ocr/error.rs @@ -2,8 +2,9 @@ use std::{io, path::PathBuf, sync::Arc}; use litellm_core_utils::call_arguments::ArgumentError; use litellm_error::Source; +use reqwest::header::HeaderMap; -pub type Headers = [(String, String)]; +static NO_HEADERS: std::sync::LazyLock = std::sync::LazyLock::new(HeaderMap::new); #[derive(Clone, Debug, thiserror::Error)] pub enum Error { @@ -11,7 +12,7 @@ pub enum Error { Provider { status: u16, body: String, - headers: Vec<(String, String)>, + headers: HeaderMap, }, #[error("File is empty or could not be read")] EmptyFile, @@ -146,7 +147,7 @@ impl Error { } } - pub fn upstream_response(&self) -> Option<(u16, &str, &Headers)> { + pub fn upstream_response(&self) -> Option<(u16, &str, &HeaderMap)> { match self { Self::Provider { status, @@ -155,7 +156,7 @@ impl Error { } => Some((*status, body, headers)), Self::Http(error) => error .upstream_response() - .map(|(status, body)| (status, body, &[][..])), + .map(|(status, body)| (status, body, &*NO_HEADERS)), Self::EmptyFile | Self::FileRead { .. } | Self::DocumentTask(_) diff --git a/litellm-rust/crates/llms/src/base_llm/ocr/handler.rs b/litellm-rust/crates/llms/src/base_llm/ocr/handler.rs index 036c3c44ccb..4ea6f07e446 100644 --- a/litellm-rust/crates/llms/src/base_llm/ocr/handler.rs +++ b/litellm-rust/crates/llms/src/base_llm/ocr/handler.rs @@ -136,7 +136,7 @@ pub async fn ocr( .map_err(litellm_http::Error::from)?; if !response.status().is_success() { let status = response.status().as_u16(); - let headers = header_pairs(response.headers()); + let headers = response.headers().clone(); let body = read_error_body(response, request.connection.max_response_bytes).await?; return Err(config.get_error_class(body, status, headers)); } @@ -168,7 +168,7 @@ pub async fn read_response_bytes( ) -> Result { if !response.status().is_success() { let status = response.status().as_u16(); - let headers = header_pairs(response.headers()); + let headers = response.headers().clone(); let body = read_error_body(response, limit).await?; return Err(Error::Provider { status, diff --git a/litellm-rust/crates/llms/src/base_llm/ocr/transformation.rs b/litellm-rust/crates/llms/src/base_llm/ocr/transformation.rs index 42332ab8bc5..f04998434b9 100644 --- a/litellm-rust/crates/llms/src/base_llm/ocr/transformation.rs +++ b/litellm-rust/crates/llms/src/base_llm/ocr/transformation.rs @@ -361,7 +361,7 @@ pub trait BaseOcrConfig: Send + Sync + Sized + 'static { &self, error_message: String, status_code: u16, - headers: Vec<(String, String)>, + headers: reqwest::header::HeaderMap, ) -> Error { Error::Provider { status: status_code, diff --git a/litellm-rust/crates/python-bridge/src/errors.rs b/litellm-rust/crates/python-bridge/src/errors.rs index 2d3125049a1..7d325266178 100644 --- a/litellm-rust/crates/python-bridge/src/errors.rs +++ b/litellm-rust/crates/python-bridge/src/errors.rs @@ -6,6 +6,7 @@ use pyo3::{ exceptions::{PyRuntimeError, PyValueError}, prelude::*, }; +use reqwest::header::HeaderMap; pyo3::create_exception!( _native, @@ -27,10 +28,19 @@ pub(crate) fn upstream_error( py: Python<'_>, status: u16, body: &str, - headers: &[(String, String)], + headers: &HeaderMap, ) -> PyResult { let error = RustUpstreamError::new_err((status, body.to_owned())); - error.value(py).setattr("headers", headers.to_vec())?; + let pairs: Vec<(&str, String)> = headers + .iter() + .map(|(name, value)| { + ( + name.as_str(), + String::from_utf8_lossy(value.as_bytes()).into_owned(), + ) + }) + .collect(); + error.value(py).setattr("headers", pairs)?; Ok(error) } @@ -64,7 +74,7 @@ pub(crate) fn route_failure( return Ok(raised); } if let Some((status, body)) = error.upstream_response() { - return public(upstream_error(py, status, body, &[])?); + return public(upstream_error(py, status, body, &HeaderMap::new())?); } let native = match (marker, error.rejected_request()) { (RequestMarker::Messages, Some(rejection)) => { @@ -87,6 +97,8 @@ pub(crate) fn route_error_to_pyerr(error: Error) -> PyErr { #[cfg(test)] mod tests { + use reqwest::header::{HeaderValue, RETRY_AFTER}; + use super::*; fn upstream_payload(py: Python<'_>, failure: &PyErr) -> ((u16, String), Vec<(String, String)>) { @@ -155,11 +167,14 @@ mod tests { fn upstream_error_keeps_the_provider_headers() { Python::initialize(); Python::attach(|py| { - let headers = [("Retry-After".to_string(), "17".to_string())]; + let headers = HeaderMap::from_iter([(RETRY_AFTER, HeaderValue::from_static("17"))]); let upstream = upstream_error(py, 503, "busy", &headers).unwrap(); assert_eq!( upstream_payload(py, &upstream), - ((503, "busy".into()), headers.to_vec()) + ( + (503, "busy".into()), + vec![("retry-after".into(), "17".into())] + ) ); }); } diff --git a/litellm-rust/crates/python-bridge/src/routes/ocr/errors.rs b/litellm-rust/crates/python-bridge/src/routes/ocr/errors.rs index e889aa5d43a..76601dcd706 100644 --- a/litellm-rust/crates/python-bridge/src/routes/ocr/errors.rs +++ b/litellm-rust/crates/python-bridge/src/routes/ocr/errors.rs @@ -80,9 +80,12 @@ mod tests { Error::Provider { status: 429, body: r#"{"message":"rate limited"}"#.into(), - headers: vec![("Retry-After".into(), "17".into())], + headers: reqwest::header::HeaderMap::from_iter([( + reqwest::header::RETRY_AFTER, + reqwest::header::HeaderValue::from_static("17"), + )]), }, - vec![("Retry-After".into(), "17".into())] + vec![("retry-after".into(), "17".into())] )] #[case::transport(litellm_http::Error::upstream(429, r#"{"message":"rate limited"}"#.into()).into(), vec![])] fn a_provider_answer_keeps_its_status_body_and_headers(