#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum TransportErrorClass {
StreamInterrupted,
Network,
Adapter,
}
#[allow(clippy::fn_params_excessive_bools)]
#[must_use]
fn classify_flags(
is_connect: bool,
is_timeout: bool,
is_body: bool,
is_decode: bool,
) -> TransportErrorClass {
if is_body || is_decode {
TransportErrorClass::StreamInterrupted
} else if is_connect || is_timeout {
TransportErrorClass::Network
} else {
TransportErrorClass::Adapter
}
}
#[must_use]
pub fn render_source_chain(err: &(dyn std::error::Error + 'static)) -> String {
let mut out = err.to_string();
let mut cursor: Option<&(dyn std::error::Error + 'static)> = err.source();
while let Some(next) = cursor {
let msg = next.to_string();
if !out.ends_with(&msg) {
out.push_str(": ");
out.push_str(&msg);
}
cursor = next.source();
}
out
}
#[must_use]
pub fn classify_reqwest_error(provider: &str, err: &reqwest::Error) -> TransportErrorClass {
let class = classify_flags(
err.is_connect(),
err.is_timeout(),
err.is_body(),
err.is_decode(),
);
tracing::warn!(
target: "caliban_provider::transport",
provider,
is_connect = err.is_connect(),
is_timeout = err.is_timeout(),
is_body = err.is_body(),
is_decode = err.is_decode(),
is_request = err.is_request(),
url = err.url().map(reqwest::Url::as_str),
source_chain = %render_source_chain(err),
class = ?class,
"provider transport error"
);
class
}
#[cfg(test)]
mod tests {
use super::{TransportErrorClass, classify_flags, render_source_chain};
#[test]
fn body_error_classifies_as_stream_interrupted() {
assert_eq!(
classify_flags(false, false, true, false),
TransportErrorClass::StreamInterrupted
);
}
#[test]
fn decode_error_classifies_as_stream_interrupted() {
assert_eq!(
classify_flags(false, false, false, true),
TransportErrorClass::StreamInterrupted
);
}
#[test]
fn body_read_timeout_prefers_stream_interrupted_over_network() {
assert_eq!(
classify_flags(false, true, true, false),
TransportErrorClass::StreamInterrupted
);
}
#[test]
fn connect_error_classifies_as_network() {
assert_eq!(
classify_flags(true, false, false, false),
TransportErrorClass::Network
);
}
#[test]
fn timeout_without_body_classifies_as_network() {
assert_eq!(
classify_flags(false, true, false, false),
TransportErrorClass::Network
);
}
#[test]
fn other_http_error_classifies_as_adapter() {
assert_eq!(
classify_flags(false, false, false, false),
TransportErrorClass::Adapter
);
}
#[test]
fn source_chain_joins_nested_causes() {
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct Inner;
impl fmt::Display for Inner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "connection reset by peer")
}
}
impl Error for Inner {}
#[derive(Debug)]
struct Outer;
impl fmt::Display for Outer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "error decoding response body")
}
}
impl Error for Outer {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&Inner)
}
}
assert_eq!(
render_source_chain(&Outer),
"error decoding response body: connection reset by peer"
);
}
}