mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
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:
parent
b99aa6b687
commit
f4104179d2
14 changed files with 87 additions and 27 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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(_)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())]
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue