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 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-10-23 14:20:08 -07:00
parent b99aa6b687
commit f4104179d2
14 changed files with 87 additions and 27 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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

View file

@ -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}");
}
}

View file

@ -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"))
);
}
}

View file

@ -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"));

View file

@ -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)
}

View file

@ -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::<AwsError>(&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,

View file

@ -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!(

View file

@ -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<HeaderMap> = 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(_)

View file

@ -136,7 +136,7 @@ pub async fn ocr<C: BaseOcrConfig>(
.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<Bytes, Error> {
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,

View file

@ -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,

View file

@ -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<PyErr> {
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())]
)
);
});
}

View file

@ -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(