litellm/litellm-rust/crates/http/src/error.rs
yujonglee 7b60fd061a
refactor(rust): adopt one error discipline across litellm-rust with source chains and Report (#48042)
* build(rust): upgrade reqwest to 0.13 and move MCP out of litellm-http

The workspace moves to reqwest 0.13, so litellm-http no longer carries a
second reqwest just for rmcp. The mcp feature, its pool and its proxy
conversion go away, and the gateway hands rmcp a pooled NoRedirect client
that already carries the TLS, proxy and timeout settings

The Client test helpers collapse into for_test, a no-redirect client
without TLS or proxy settings, and wrap_for_test for the few tests that
need a hand-built reqwest client

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(http): honor transport settings for Rust WebSockets

The Responses WebSocket connected through tokio-tungstenite with its own
native-roots TLS, so it ignored the configured CA bundle, verify flag,
client certificate, proxies and timeouts. The handshake now goes through
a pooled reqwest client (ClientVariant::WebSocket, HTTP/1.1, no
redirects) and upgrades the response, so it shares the settings every
other outbound request uses

The upgrade response is checked for the Sec-WebSocket-Accept key, the
Upgrade and Connection headers and a subprotocol that was offered

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(http): build outbound requests as reqwest::Request

OutboundRequest was a hand-rolled copy of reqwest::Request. Routes now build
a reqwest::Request directly and RequestSigner reads the body bytes back out
of it, so the signer still covers exactly what is sent. SigV4 signing moves
onto HeaderMap and takes the method, which drops the BTreeMap plumbing and
the header casing table

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(http): make litellm_http::Error the one opaque error

transport::Error and media::Error fold into litellm_http::Error, an
Arc<Repr> newtype whose private variants keep the typed reqwest, io,
rustls and tungstenite causes and own every message. Callers can no
longer build an error with a message of their own. They ask through
dispatched, is_request, status, upstream_response and tls_source

One From<reqwest::Error> now classifies connect, network and timeout
and strips the URL. The WebSocket handshake reports each failed check
as its own variant and owns its connect timeout. OCR drops the media
variants it mirrored, and inference::Error maps a header the signer
computes to Unsupported for every route, not only chat

The error rules move out of AGENTS.md into the rust-error skill, which
also records the decisions behind this design

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(rust): split rust-error skill into thin SKILL.md with references

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(rust-error): render causes once and ask predicates at the boundaries

The four WebSocket handshake variants collapse into Handshake(HandshakeFault), and media download failures no longer answer dispatched() since they happen before the provider call

ErrorDetail in llms and auth-types, the inference error and the OCR error now store their cause in a field named cause with no #[source], so to_string() carries it exactly once and source() is None. Tests that walked the chain now destructure the variant and downcast the cause

The gateway asks is_unsupported() and is_missing_credential() on the inference and OCR errors instead of matching through nested enums, and litellm_auth::Error answers is_missing_credential() for both

The rust-error skill gains axum and pyo3 references, boundaries.md describes the real predicate order and the known divergences, the testing example asserts dispatched() correctly, and shape, classification, conversions, messages-and-causes and recipes agree with the code again

* docs(rust): focus the rust-error gateway page on the error flow and add a rust-gateway skill

The former axum page toured gateway-inference: its status match, the envelope
table, an extractor, the unsupported route and the Sse closure. Those details
belong to the gateway crates and go stale with them. The page is now gateway.md
and follows an error from the provider failure through each From into
IntoResponse, where it is classified once and rendered by surface and framing.

The gateway-structure material moves to a new rust-gateway skill: envelopes per
API surface, extractors and path registration, SSE relay, and the router test
harness.

* test(inference): pin provider URL redaction on every route's transport error

Chat, Messages, Responses and transcription each get a route-level test that
fails if a reqwest error reaches the caller with its URL. The Messages stream
decoder also takes body errors through litellm_http::Error like every other
reqwest error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(rust-error): adopt std source chains and add litellm-error Report

Messages describe their own layer and the cause is the source, boundaries
render the chain once with litellm_error::Report. Corrects skill claims
the audit found stale against the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(rust-error): keep causes as sources across http, auth, secrets, cache and utility crates

Messages describe their own layer and every cause is a typed source,
predicates match exhaustively, and boundaries render with
litellm_error::Report. Media downloads no longer count as dispatched,
Error::upstream bounds the provider body itself, and a forwarded
header SigV4 cannot sign is the caller's fault.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(rust-error): keep causes as sources in llms and the gateway crates

ErrorDetail loses its string variant, OCR provider variants become data,
host faults stop counting as the caller's, a tunnelled transport error
classifies the same on every stream path, and each gateway crate has one
Error with one status() and extractors that reject in its envelope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(rust-error): nest llms errors in inference and ask predicates in route tests

litellm_inference::Error nests litellm_llms::Error instead of mirroring
its variants, host driver faults get their own variant, every predicate
matches exhaustively, and route tests ask predicates instead of matching
transport variants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(rust-error): map bridge and gateway-inference errors once and render them with Report

RustUpstreamError has one constructor that always sets headers, argument
checks raise typed errors mapped once, OCR asks upstream_response(), the
Responses stream error event comes from the error type, gateway-inference
loses its string variants, and auth faults classify through one
litellm_auth predicate on every route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

* docs(rust-error): correct skill claims against the aligned code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(rust-error): fix the remaining sweep findings in gateway, llms, auth and cache crates

Auth rejections hide their backend cause on every inference renderer,
path parameters reject in the surface envelope, Textract's unsupported
document and Azure DI's unparseable poll URL get typed variants, the
credential chain exposes its last failure as the source, gateway-mcp
asks predicates over rmcp errors, and the S3 and Azure blob transports
classify through litellm_http::Error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(rust-error): fix the remaining sweep findings on the Python side

host-python keeps a native error's chain and returns blocking-pool
cancellation instead of panicking, Vault transport failures convert
through litellm_http::Error so no URL reaches Python, the bridge asks
predicates instead of matching three levels into Vault errors, and the
last string-built PyErrs become typed errors mapped once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(deps): refresh compatible dependencies flagged by OSV

* refactor(rust): remove redundant client construction comment

* test(rust): cover configured MCP proxy and CA bundle

* fix(http): preserve native WebSocket trust and optional protocols

* fix(http): allow plain WebSockets without native roots

* fix(http): normalize provider URLs before building outbound requests

* fix(rust): preserve OCR download status and reduce error size

* fix(rust): preserve upstream bodies and bound diagnostics only

* test(rust): preserve bridge serialization boundary in body regression

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-28 09:22:11 -07:00

677 lines
22 KiB
Rust

use std::{borrow::Cow, fmt, io, path::PathBuf, sync::Arc};
use tokio::time::error::Elapsed;
use tokio_tungstenite::tungstenite;
const UPSTREAM_BODY_MAX_CHARS: usize = 256;
const TRUNCATION_MARKER: &str = "... (truncated)";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TlsSource {
CaBundle,
ClientIdentity,
}
#[derive(Clone, Debug, thiserror::Error)]
#[error(transparent)]
pub struct Error(Arc<Repr>);
#[derive(Debug, thiserror::Error)]
pub(crate) enum Repr {
#[error("could not read {}", path.display())]
Read {
path: PathBuf,
tls_source: TlsSource,
source: io::Error,
},
#[error("could not load {}", path.display())]
InvalidPem {
path: PathBuf,
tls_source: TlsSource,
source: PemFault,
},
#[error("could not build the HTTP client")]
Client(#[source] reqwest::Error),
#[error("could not build the TLS configuration")]
TlsConfig(#[source] rustls::Error),
#[error("request body could not be serialized")]
RequestBody(#[source] serde_json::Error),
#[error("invalid request")]
Request(#[source] http::Error),
#[error("invalid request")]
RequestUrl(#[source] reqwest::Error),
#[error("invalid request URL")]
ParseUrl(#[source] url::ParseError),
#[error("request forwards the header {0:?}, which the signer computes")]
ComputedHeader(String),
#[error("request signing failed: a streaming body cannot be signed")]
UnsignableBody,
#[error("request forwards a header that cannot be signed")]
UnsignableHeader(#[source] litellm_error::Source),
#[error("request signing failed")]
Signature(#[source] litellm_error::Source),
#[error("could not reach the provider")]
Connect(#[source] reqwest::Error),
#[error("upstream network error")]
Network(#[source] reqwest::Error),
#[error("upstream request timed out")]
Timeout(#[source] reqwest::Error),
#[error("upstream request failed with status {status}: {body}")]
Status { status: u16, body: UpstreamBody },
#[error("invalid WebSocket URL")]
WebSocketUrl(#[source] url::ParseError),
#[error("unsupported WebSocket URL scheme {0:?}")]
WebSocketScheme(String),
#[error("could not rewrite the WebSocket URL scheme to {0}")]
WebSocketSchemeRewrite(&'static str),
#[error("WebSocket handshake returned {0}")]
Handshake(HandshakeFault),
#[error("WebSocket connection timed out")]
HandshakeTimeout(#[source] Elapsed),
#[error("upstream WebSocket error")]
WebSocket(#[source] tungstenite::Error),
#[error("the WebSocket connection is closed")]
WebSocketClosed,
#[error("media URL rejected by network policy")]
BlockedUrl,
#[error("could not resolve the media host")]
Resolve(#[source] io::Error),
#[error("media download is disabled")]
DownloadDisabled,
#[error("media download exceeds the maximum size")]
DownloadTooLarge,
#[error("too many redirects while fetching media")]
TooManyRedirects,
#[error("media redirect is missing a Location header")]
MissingRedirectLocation,
#[error("invalid media redirect")]
InvalidRedirect(#[source] RedirectFault),
#[error("media download failed with status {0}")]
DownloadStatus(u16),
#[error("media download failed")]
Download(#[source] reqwest::Error),
#[error("media download timed out")]
DownloadTimeout(#[source] Elapsed),
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum HandshakeFault {
#[error("a wrong Sec-WebSocket-Accept")]
Accept,
#[error("an invalid Upgrade header")]
Upgrade,
#[error("an invalid Connection header")]
Connection,
#[error("an invalid subprotocol")]
Subprotocol,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum PemFault {
#[error("no certificates found")]
NoCertificates,
#[error("could not parse PEM")]
Parse(#[from] rustls::pki_types::pem::Error),
#[error("rustls rejected the certificate or key")]
Rejected(#[from] rustls::Error),
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum RedirectFault {
#[error("Location header is not valid text")]
Location(#[from] http::header::ToStrError),
#[error("Location is not a valid URL")]
Url(#[from] url::ParseError),
}
impl Repr {
pub(crate) fn download(error: reqwest::Error) -> Self {
Self::Download(error.without_url())
}
}
impl Error {
pub fn upstream(status: u16, body: String) -> Self {
Repr::Status {
status,
body: UpstreamBody(body),
}
.into()
}
pub fn computed_header(name: impl Into<String>) -> Self {
Repr::ComputedHeader(name.into()).into()
}
pub fn unsignable_body() -> Self {
Repr::UnsignableBody.into()
}
pub fn unsignable_header(cause: impl std::error::Error + Send + Sync + 'static) -> Self {
Repr::UnsignableHeader(litellm_error::Source::new(cause)).into()
}
pub fn websocket_closed() -> Self {
Repr::WebSocketClosed.into()
}
pub fn signature(cause: impl std::error::Error + Send + Sync + 'static) -> Self {
Repr::Signature(litellm_error::Source::new(cause)).into()
}
/// The request may have reached the provider, so retrying elsewhere could
/// repeat a billed call.
pub fn dispatched(&self) -> bool {
match &*self.0 {
Repr::Network(_)
| Repr::Timeout(_)
| Repr::Status { .. }
| Repr::Handshake(_)
| Repr::HandshakeTimeout(_)
| Repr::WebSocket(_) => true,
Repr::WebSocketClosed
| Repr::Read { .. }
| Repr::InvalidPem { .. }
| Repr::Client(_)
| Repr::TlsConfig(_)
| Repr::RequestBody(_)
| Repr::Request(_)
| Repr::RequestUrl(_)
| Repr::ParseUrl(_)
| Repr::ComputedHeader(_)
| Repr::UnsignableBody
| Repr::UnsignableHeader(_)
| Repr::Signature(_)
| Repr::Connect(_)
| Repr::WebSocketUrl(_)
| Repr::WebSocketScheme(_)
| Repr::WebSocketSchemeRewrite(_)
| Repr::BlockedUrl
| Repr::Resolve(_)
| Repr::DownloadDisabled
| Repr::DownloadTooLarge
| Repr::TooManyRedirects
| Repr::MissingRedirectLocation
| Repr::InvalidRedirect(_)
| Repr::DownloadStatus(_)
| Repr::Download(_)
| Repr::DownloadTimeout(_) => false,
}
}
pub fn is_request(&self) -> bool {
match &*self.0 {
Repr::Request(_)
| Repr::RequestUrl(_)
| Repr::ParseUrl(_)
| Repr::ComputedHeader(_)
| Repr::UnsignableHeader(_)
| Repr::WebSocketUrl(_)
| Repr::WebSocketScheme(_)
| Repr::WebSocketSchemeRewrite(_)
| Repr::BlockedUrl
| Repr::DownloadDisabled
| Repr::DownloadTooLarge
| Repr::TooManyRedirects => true,
Repr::Read { .. }
| Repr::InvalidPem { .. }
| Repr::Client(_)
| Repr::TlsConfig(_)
| Repr::RequestBody(_)
| Repr::UnsignableBody
| Repr::Signature(_)
| Repr::Connect(_)
| Repr::Network(_)
| Repr::Timeout(_)
| Repr::Status { .. }
| Repr::Handshake(_)
| Repr::HandshakeTimeout(_)
| Repr::WebSocket(_)
| Repr::WebSocketClosed
| Repr::Resolve(_)
| Repr::MissingRedirectLocation
| Repr::InvalidRedirect(_)
| Repr::DownloadStatus(_)
| Repr::Download(_)
| Repr::DownloadTimeout(_) => false,
}
}
pub fn is_response(&self) -> bool {
match &*self.0 {
Repr::MissingRedirectLocation | Repr::InvalidRedirect(_) => true,
Repr::Read { .. }
| Repr::InvalidPem { .. }
| Repr::Client(_)
| Repr::TlsConfig(_)
| Repr::RequestBody(_)
| Repr::Request(_)
| Repr::RequestUrl(_)
| Repr::ParseUrl(_)
| Repr::ComputedHeader(_)
| Repr::UnsignableBody
| Repr::UnsignableHeader(_)
| Repr::Signature(_)
| Repr::Connect(_)
| Repr::Network(_)
| Repr::Timeout(_)
| Repr::Status { .. }
| Repr::WebSocketUrl(_)
| Repr::WebSocketScheme(_)
| Repr::WebSocketSchemeRewrite(_)
| Repr::Handshake(_)
| Repr::HandshakeTimeout(_)
| Repr::WebSocket(_)
| Repr::WebSocketClosed
| Repr::BlockedUrl
| Repr::Resolve(_)
| Repr::DownloadDisabled
| Repr::DownloadTooLarge
| Repr::TooManyRedirects
| Repr::DownloadStatus(_)
| Repr::Download(_)
| Repr::DownloadTimeout(_) => false,
}
}
pub fn is_computed_header(&self) -> bool {
match &*self.0 {
Repr::ComputedHeader(_) => true,
Repr::Read { .. }
| Repr::InvalidPem { .. }
| Repr::Client(_)
| Repr::TlsConfig(_)
| Repr::RequestBody(_)
| Repr::Request(_)
| Repr::RequestUrl(_)
| Repr::ParseUrl(_)
| Repr::UnsignableBody
| Repr::UnsignableHeader(_)
| Repr::Signature(_)
| Repr::Connect(_)
| Repr::Network(_)
| Repr::Timeout(_)
| Repr::Status { .. }
| Repr::WebSocketUrl(_)
| Repr::WebSocketScheme(_)
| Repr::WebSocketSchemeRewrite(_)
| Repr::Handshake(_)
| Repr::HandshakeTimeout(_)
| Repr::WebSocket(_)
| Repr::WebSocketClosed
| Repr::BlockedUrl
| Repr::Resolve(_)
| Repr::DownloadDisabled
| Repr::DownloadTooLarge
| Repr::TooManyRedirects
| Repr::MissingRedirectLocation
| Repr::InvalidRedirect(_)
| Repr::DownloadStatus(_)
| Repr::Download(_)
| Repr::DownloadTimeout(_) => false,
}
}
pub fn is_connect(&self) -> bool {
match &*self.0 {
Repr::Connect(_) => true,
Repr::ComputedHeader(_)
| Repr::Read { .. }
| Repr::InvalidPem { .. }
| Repr::Client(_)
| Repr::TlsConfig(_)
| Repr::RequestBody(_)
| Repr::Request(_)
| Repr::RequestUrl(_)
| Repr::ParseUrl(_)
| Repr::UnsignableBody
| Repr::UnsignableHeader(_)
| Repr::Signature(_)
| Repr::Network(_)
| Repr::Timeout(_)
| Repr::Status { .. }
| Repr::WebSocketUrl(_)
| Repr::WebSocketScheme(_)
| Repr::WebSocketSchemeRewrite(_)
| Repr::Handshake(_)
| Repr::HandshakeTimeout(_)
| Repr::WebSocket(_)
| Repr::WebSocketClosed
| Repr::BlockedUrl
| Repr::Resolve(_)
| Repr::DownloadDisabled
| Repr::DownloadTooLarge
| Repr::TooManyRedirects
| Repr::MissingRedirectLocation
| Repr::InvalidRedirect(_)
| Repr::DownloadStatus(_)
| Repr::Download(_)
| Repr::DownloadTimeout(_) => false,
}
}
pub fn is_timeout(&self) -> bool {
match &*self.0 {
Repr::Timeout(_) | Repr::HandshakeTimeout(_) | Repr::DownloadTimeout(_) => true,
Repr::Read { .. }
| Repr::InvalidPem { .. }
| Repr::Client(_)
| Repr::TlsConfig(_)
| Repr::RequestBody(_)
| Repr::Request(_)
| Repr::RequestUrl(_)
| Repr::ParseUrl(_)
| Repr::ComputedHeader(_)
| Repr::UnsignableBody
| Repr::UnsignableHeader(_)
| Repr::Signature(_)
| Repr::Connect(_)
| Repr::Network(_)
| Repr::Status { .. }
| Repr::WebSocketUrl(_)
| Repr::WebSocketScheme(_)
| Repr::WebSocketSchemeRewrite(_)
| Repr::Handshake(_)
| Repr::WebSocket(_)
| Repr::WebSocketClosed
| Repr::BlockedUrl
| Repr::Resolve(_)
| Repr::DownloadDisabled
| Repr::DownloadTooLarge
| Repr::TooManyRedirects
| Repr::MissingRedirectLocation
| Repr::InvalidRedirect(_)
| Repr::DownloadStatus(_)
| Repr::Download(_) => false,
}
}
pub fn upstream_response(&self) -> Option<(u16, &str)> {
match &*self.0 {
Repr::Status { status, body } => Some((*status, &body.0)),
Repr::Read { .. }
| Repr::InvalidPem { .. }
| Repr::Client(_)
| Repr::TlsConfig(_)
| Repr::RequestBody(_)
| Repr::Request(_)
| Repr::RequestUrl(_)
| Repr::ParseUrl(_)
| Repr::ComputedHeader(_)
| Repr::UnsignableBody
| Repr::UnsignableHeader(_)
| Repr::Signature(_)
| Repr::Connect(_)
| Repr::Network(_)
| Repr::Timeout(_)
| Repr::WebSocketUrl(_)
| Repr::WebSocketScheme(_)
| Repr::WebSocketSchemeRewrite(_)
| Repr::Handshake(_)
| Repr::HandshakeTimeout(_)
| Repr::WebSocket(_)
| Repr::WebSocketClosed
| Repr::BlockedUrl
| Repr::Resolve(_)
| Repr::DownloadDisabled
| Repr::DownloadTooLarge
| Repr::TooManyRedirects
| Repr::MissingRedirectLocation
| Repr::InvalidRedirect(_)
| Repr::DownloadStatus(_)
| Repr::Download(_)
| Repr::DownloadTimeout(_) => None,
}
}
pub fn status(&self) -> Option<u16> {
match &*self.0 {
Repr::Status { status, .. } | Repr::DownloadStatus(status) => Some(*status),
Repr::Timeout(_) | Repr::DownloadTimeout(_) => Some(408),
Repr::Read { .. }
| Repr::InvalidPem { .. }
| Repr::Client(_)
| Repr::TlsConfig(_)
| Repr::RequestBody(_)
| Repr::Request(_)
| Repr::RequestUrl(_)
| Repr::ParseUrl(_)
| Repr::ComputedHeader(_)
| Repr::UnsignableBody
| Repr::UnsignableHeader(_)
| Repr::Signature(_)
| Repr::Connect(_)
| Repr::Network(_)
| Repr::WebSocketUrl(_)
| Repr::WebSocketScheme(_)
| Repr::WebSocketSchemeRewrite(_)
| Repr::Handshake(_)
| Repr::HandshakeTimeout(_)
| Repr::WebSocket(_)
| Repr::WebSocketClosed
| Repr::BlockedUrl
| Repr::Resolve(_)
| Repr::DownloadDisabled
| Repr::DownloadTooLarge
| Repr::TooManyRedirects
| Repr::MissingRedirectLocation
| Repr::InvalidRedirect(_)
| Repr::Download(_) => None,
}
}
pub fn tls_source(&self) -> Option<TlsSource> {
match &*self.0 {
Repr::Read { tls_source, .. } | Repr::InvalidPem { tls_source, .. } => {
Some(*tls_source)
}
Repr::Client(_)
| Repr::TlsConfig(_)
| Repr::RequestBody(_)
| Repr::Request(_)
| Repr::RequestUrl(_)
| Repr::ParseUrl(_)
| Repr::ComputedHeader(_)
| Repr::UnsignableBody
| Repr::UnsignableHeader(_)
| Repr::Signature(_)
| Repr::Connect(_)
| Repr::Network(_)
| Repr::Timeout(_)
| Repr::Status { .. }
| Repr::WebSocketUrl(_)
| Repr::WebSocketScheme(_)
| Repr::WebSocketSchemeRewrite(_)
| Repr::Handshake(_)
| Repr::HandshakeTimeout(_)
| Repr::WebSocket(_)
| Repr::WebSocketClosed
| Repr::BlockedUrl
| Repr::Resolve(_)
| Repr::DownloadDisabled
| Repr::DownloadTooLarge
| Repr::TooManyRedirects
| Repr::MissingRedirectLocation
| Repr::InvalidRedirect(_)
| Repr::DownloadStatus(_)
| Repr::Download(_)
| Repr::DownloadTimeout(_) => None,
}
}
#[cfg(test)]
pub(crate) fn repr(&self) -> &Repr {
&self.0
}
}
pub(crate) struct UpstreamBody(String);
impl fmt::Display for UpstreamBody {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&bounded(&self.0), f)
}
}
impl fmt::Debug for UpstreamBody {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&bounded(&self.0), f)
}
}
fn bounded(body: &str) -> Cow<'_, str> {
match body.char_indices().nth(UPSTREAM_BODY_MAX_CHARS) {
None => Cow::Borrowed(body),
Some((cut, _)) => Cow::Owned(format!("{}{TRUNCATION_MARKER}", &body[..cut])),
}
}
impl PartialEq for Error {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
}
impl Eq for Error {}
impl From<Repr> for Error {
fn from(repr: Repr) -> Self {
Self(Arc::new(repr))
}
}
impl From<reqwest::Error> for Error {
fn from(error: reqwest::Error) -> Self {
let error = error.without_url();
if error.is_timeout() {
Repr::Timeout(error).into()
} else if error.is_connect() || error.is_builder() {
Repr::Connect(error).into()
} else {
Repr::Network(error).into()
}
}
}
impl From<tungstenite::Error> for Error {
fn from(error: tungstenite::Error) -> Self {
Repr::WebSocket(error).into()
}
}
#[cfg(test)]
mod tests {
use litellm_error::Report;
use rstest::rstest;
use super::{TRUNCATION_MARKER, UPSTREAM_BODY_MAX_CHARS};
#[tokio::test]
async fn transport_errors_remove_urls_and_keep_dispatch_context() {
let error = reqwest::Client::builder()
.no_proxy()
.build()
.expect("client")
.get("http://localhost:invalid/private?api_key=secret")
.send()
.await
.expect_err("invalid port");
let error = crate::Error::from(error);
let report = Report::new(&error).to_string();
assert!(!error.dispatched());
assert!(!report.contains("secret"), "{report}");
assert!(!report.contains("private"), "{report}");
}
fn root_cause<'a>(
error: &'a (dyn std::error::Error + 'static),
) -> &'a (dyn std::error::Error + 'static) {
error.source().map_or(error, root_cause)
}
#[tokio::test]
async fn connect_failure_reports_its_root_cause_once() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let address = listener.local_addr().expect("address");
drop(listener);
let error = reqwest::Client::builder()
.no_proxy()
.build()
.expect("client")
.get(format!("http://{address}/private?api_key=secret"))
.send()
.await
.expect_err("nothing listens on the port");
let error = crate::Error::from(error);
let root_cause = root_cause(&error).to_string();
let report = Report::new(&error).to_string();
assert_eq!(report.matches(&root_cause).count(), 1, "{report}");
assert!(!error.to_string().contains(&root_cause));
assert!(error.is_connect());
assert!(!error.is_timeout());
assert!(!report.contains("secret"), "{report}");
assert!(!report.contains("private"), "{report}");
}
#[tokio::test]
async fn request_timeout_is_not_safe_to_retry_as_a_connect_failure() {
use std::time::Duration;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let request = reqwest::Client::builder()
.no_proxy()
.build()
.expect("client")
.get(format!("http://{address}"))
.timeout(Duration::from_millis(200))
.send();
let (response, accepted) = tokio::join!(
request,
tokio::time::timeout(Duration::from_secs(2), listener.accept())
);
let _connection = accepted
.expect("accept deadline")
.expect("accepted connection");
let error = response.expect_err("server does not respond");
assert!(error.is_timeout());
let error = crate::Error::from(error);
assert!(error.dispatched());
assert!(error.is_timeout());
assert!(!error.is_connect());
assert_eq!(error.status(), Some(408));
}
#[rstest]
#[case::empty(String::new(), String::new())]
#[case::short("slow down".to_owned(), "slow down".to_owned())]
#[case::ascii_over_the_limit(
format!("{}private-tail", "a".repeat(UPSTREAM_BODY_MAX_CHARS)),
format!("{}{TRUNCATION_MARKER}", "a".repeat(UPSTREAM_BODY_MAX_CHARS))
)]
#[case::at_the_limit("\u{00e9}".repeat(UPSTREAM_BODY_MAX_CHARS), "\u{00e9}".repeat(UPSTREAM_BODY_MAX_CHARS))]
#[case::over_the_limit(
format!("{}private-tail", "\u{00e9}".repeat(UPSTREAM_BODY_MAX_CHARS)),
format!("{}{TRUNCATION_MARKER}", "\u{00e9}".repeat(UPSTREAM_BODY_MAX_CHARS))
)]
fn upstream_preserves_the_body_and_bounds_only_diagnostics(
#[case] body: String,
#[case] expected: String,
) {
let error = crate::Error::upstream(429, body.clone());
let expected_message = format!("upstream request failed with status 429: {expected}");
assert_eq!(error.to_string(), expected_message);
assert_eq!(Report::new(&error).to_string(), expected_message);
let debug = format!("{error:?}");
assert!(debug.contains(&format!("{expected:?}")), "{debug}");
assert!(!debug.contains("private-tail"), "{debug}");
assert!(!format!("{error:#?}").contains("private-tail"));
assert_eq!(error.upstream_response(), Some((429, body.as_str())));
assert_eq!(error.status(), Some(429));
}
}