use std::fmt;
use std::time::Duration;
pub const MAX_ERROR_BODY_DISPLAY_BYTES: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GuardrailPhase {
Input,
Output,
}
#[derive(Clone)]
pub enum LLMError {
HttpError(String),
AuthError {
message: String,
status_code: Option<u16>,
response_body: Option<Box<str>>,
},
RateLimitError {
status_code: u16,
message: String,
response_body: Box<str>,
retry_after: Option<Duration>,
provider_code: Option<Box<str>>,
},
HttpStatusError {
status_code: u16,
message: String,
response_body: Box<str>,
retry_after: Option<Duration>,
provider_code: Option<Box<str>>,
},
InvalidRequest {
message: String,
status_code: Option<u16>,
response_body: Option<Box<str>>,
},
ProviderError(String),
ResponseFormatError {
message: String,
raw_response: String,
},
Generic(String),
JsonError(String),
ToolConfigError(String),
NoToolSupport(String),
GuardrailBlocked {
phase: GuardrailPhase,
guard: Box<str>,
rule_id: Box<str>,
category: Box<str>,
severity: Box<str>,
message: Box<str>,
},
GuardrailExecutionFailed { guard: String, message: String },
}
impl LLMError {
pub fn missing_api_key(message: impl Into<String>) -> Self {
Self::AuthError {
message: message.into(),
status_code: None,
response_body: None,
}
}
pub fn invalid_request(message: impl Into<String>) -> Self {
Self::InvalidRequest {
message: message.into(),
status_code: None,
response_body: None,
}
}
pub fn is_retryable(&self) -> bool {
is_retryable(self)
}
pub fn http_status_code(&self) -> Option<u16> {
match self {
Self::AuthError { status_code, .. } => *status_code,
Self::RateLimitError { status_code, .. } => Some(*status_code),
Self::HttpStatusError { status_code, .. } => Some(*status_code),
Self::InvalidRequest { status_code, .. } => *status_code,
_ => None,
}
}
pub fn response_body(&self) -> Option<&str> {
match self {
Self::AuthError { response_body, .. } => response_body.as_deref(),
Self::RateLimitError { response_body, .. } => Some(response_body),
Self::HttpStatusError { response_body, .. } => Some(response_body),
Self::InvalidRequest { response_body, .. } => response_body.as_deref(),
Self::ResponseFormatError { raw_response, .. } => Some(raw_response),
_ => None,
}
}
pub fn is_transport_retryable(&self) -> bool {
match self {
Self::HttpError(msg) => is_transport_retryable_message(msg),
_ => false,
}
}
}
pub fn is_transport_retryable_message(message: &str) -> bool {
let m = message.to_ascii_lowercase();
m.starts_with("request timed out:")
|| m.starts_with("connection failed:")
|| m.contains("connection reset")
|| m.contains("broken pipe")
|| m.contains("dns error")
|| m.contains("dns lookup")
|| m.contains("name or service not known")
}
pub fn truncate_for_display(body: &str) -> String {
if body.len() <= MAX_ERROR_BODY_DISPLAY_BYTES {
return body.to_string();
}
let mut end = MAX_ERROR_BODY_DISPLAY_BYTES;
while end > 0 && !body.is_char_boundary(end) {
end -= 1;
}
format!(
"{}... [truncated, {} bytes total]",
&body[..end],
body.len()
)
}
fn write_truncated_body(f: &mut fmt::Formatter<'_>, label: &str, body: &str) -> fmt::Result {
write!(f, ". {label}: {}", truncate_for_display(body))
}
fn debug_optional_body(body: &Option<Box<str>>) -> Option<String> {
body.as_deref().map(truncate_for_display)
}
impl fmt::Debug for LLMError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::HttpError(message) => f.debug_tuple("HttpError").field(message).finish(),
Self::AuthError {
message,
status_code,
response_body,
} => f
.debug_struct("AuthError")
.field("message", message)
.field("status_code", status_code)
.field("response_body", &debug_optional_body(response_body))
.finish(),
Self::RateLimitError {
status_code,
message,
response_body,
retry_after,
provider_code,
} => f
.debug_struct("RateLimitError")
.field("status_code", status_code)
.field("message", message)
.field("response_body", &truncate_for_display(response_body))
.field("retry_after", retry_after)
.field("provider_code", provider_code)
.finish(),
Self::HttpStatusError {
status_code,
message,
response_body,
retry_after,
provider_code,
} => f
.debug_struct("HttpStatusError")
.field("status_code", status_code)
.field("message", message)
.field("response_body", &truncate_for_display(response_body))
.field("retry_after", retry_after)
.field("provider_code", provider_code)
.finish(),
Self::InvalidRequest {
message,
status_code,
response_body,
} => f
.debug_struct("InvalidRequest")
.field("message", message)
.field("status_code", status_code)
.field("response_body", &debug_optional_body(response_body))
.finish(),
Self::ProviderError(message) => f.debug_tuple("ProviderError").field(message).finish(),
Self::ResponseFormatError {
message,
raw_response,
} => f
.debug_struct("ResponseFormatError")
.field("message", message)
.field("raw_response", &truncate_for_display(raw_response))
.finish(),
Self::Generic(message) => f.debug_tuple("Generic").field(message).finish(),
Self::JsonError(message) => f.debug_tuple("JsonError").field(message).finish(),
Self::ToolConfigError(message) => {
f.debug_tuple("ToolConfigError").field(message).finish()
}
Self::NoToolSupport(message) => f.debug_tuple("NoToolSupport").field(message).finish(),
Self::GuardrailBlocked {
phase,
guard,
rule_id,
category,
severity,
message,
} => f
.debug_struct("GuardrailBlocked")
.field("phase", phase)
.field("guard", guard)
.field("rule_id", rule_id)
.field("category", category)
.field("severity", severity)
.field("message", message)
.finish(),
Self::GuardrailExecutionFailed { guard, message } => f
.debug_struct("GuardrailExecutionFailed")
.field("guard", guard)
.field("message", message)
.finish(),
}
}
}
pub fn is_http_status_retryable(status_code: u16) -> bool {
matches!(status_code, 408 | 500..=599)
}
pub fn is_retryable(err: &LLMError) -> bool {
match err {
LLMError::RateLimitError { .. } => true,
LLMError::HttpStatusError { status_code, .. } => is_http_status_retryable(*status_code),
LLMError::HttpError(msg) => is_transport_retryable_message(msg),
LLMError::Generic(_)
| LLMError::AuthError { .. }
| LLMError::InvalidRequest { .. }
| LLMError::GuardrailBlocked { .. }
| LLMError::GuardrailExecutionFailed { .. }
| LLMError::ResponseFormatError { .. }
| LLMError::JsonError(_)
| LLMError::ToolConfigError(_)
| LLMError::NoToolSupport(_)
| LLMError::ProviderError(_) => false,
}
}
pub fn is_fallbackable(err: &LLMError) -> bool {
match err {
LLMError::RateLimitError { .. } => true,
LLMError::HttpStatusError { status_code, .. } => is_http_status_retryable(*status_code),
LLMError::HttpError(_) => true,
LLMError::ProviderError(_)
| LLMError::ResponseFormatError { .. }
| LLMError::NoToolSupport(_)
| LLMError::Generic(_) => true,
LLMError::AuthError { .. }
| LLMError::InvalidRequest { .. }
| LLMError::JsonError(_)
| LLMError::ToolConfigError(_)
| LLMError::GuardrailBlocked { .. }
| LLMError::GuardrailExecutionFailed { .. } => false,
}
}
fn write_status_prefixed_error(
f: &mut fmt::Formatter<'_>,
label: &str,
message: &str,
status_code: Option<u16>,
response_body: Option<&str>,
) -> fmt::Result {
if let Some(status) = status_code {
write!(f, "{label} ({status}): {message}")?;
} else {
write!(f, "{label}: {message}")?;
}
if let Some(body) = response_body {
write_truncated_body(f, "Response", body)?;
}
Ok(())
}
fn display_auth_error(
f: &mut fmt::Formatter<'_>,
message: &str,
status_code: Option<u16>,
response_body: Option<&str>,
) -> fmt::Result {
write_status_prefixed_error(f, "Auth Error", message, status_code, response_body)
}
fn display_rate_limit_error(
f: &mut fmt::Formatter<'_>,
status_code: u16,
message: &str,
response_body: &str,
retry_after: Option<Duration>,
provider_code: Option<&str>,
) -> fmt::Result {
write!(f, "Rate Limit Error ({status_code}): {message}")?;
write_truncated_body(f, "Response", response_body)?;
if let Some(code) = provider_code {
write!(f, ". Provider code: {code}")?;
}
if let Some(retry_after) = retry_after {
write!(f, ". Retry-After: {}s", retry_after.as_secs())?;
}
Ok(())
}
fn display_http_status_error(
f: &mut fmt::Formatter<'_>,
status_code: u16,
message: &str,
response_body: &str,
retry_after: Option<Duration>,
provider_code: Option<&str>,
) -> fmt::Result {
write!(f, "HTTP Status Error ({status_code}): {message}")?;
write_truncated_body(f, "Response", response_body)?;
if let Some(code) = provider_code {
write!(f, ". Provider code: {code}")?;
}
if let Some(retry_after) = retry_after {
write!(f, ". Retry-After: {}s", retry_after.as_secs())?;
}
Ok(())
}
fn display_invalid_request(
f: &mut fmt::Formatter<'_>,
message: &str,
status_code: Option<u16>,
response_body: Option<&str>,
) -> fmt::Result {
write_status_prefixed_error(f, "Invalid Request", message, status_code, response_body)
}
fn display_response_format_error(
f: &mut fmt::Formatter<'_>,
message: &str,
raw_response: &str,
) -> fmt::Result {
write!(f, "Response Format Error: {message}")?;
write_truncated_body(f, "Raw response", raw_response)
}
fn display_guardrail_blocked(
f: &mut fmt::Formatter<'_>,
phase: GuardrailPhase,
guard: &str,
rule_id: &str,
category: &str,
severity: &str,
message: &str,
) -> fmt::Result {
let phase = match phase {
GuardrailPhase::Input => "input",
GuardrailPhase::Output => "output",
};
write!(
f,
"guardrail blocked {phase}: guard={guard}, rule={rule_id}, category={category}, severity={severity}, message={message}"
)
}
impl fmt::Display for LLMError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LLMError::HttpError(e) => write!(f, "HTTP Error: {e}"),
LLMError::AuthError {
message,
status_code,
response_body,
} => display_auth_error(f, message, *status_code, response_body.as_deref()),
LLMError::RateLimitError {
status_code,
message,
response_body,
retry_after,
provider_code,
} => display_rate_limit_error(
f,
*status_code,
message,
response_body,
*retry_after,
provider_code.as_deref(),
),
LLMError::HttpStatusError {
status_code,
message,
response_body,
retry_after,
provider_code,
} => display_http_status_error(
f,
*status_code,
message,
response_body,
*retry_after,
provider_code.as_deref(),
),
LLMError::InvalidRequest {
message,
status_code,
response_body,
} => display_invalid_request(f, message, *status_code, response_body.as_deref()),
LLMError::ProviderError(e) => write!(f, "Provider Error: {e}"),
LLMError::Generic(e) => write!(f, "Generic Error : {e}"),
LLMError::ResponseFormatError {
message,
raw_response,
} => display_response_format_error(f, message, raw_response),
LLMError::JsonError(e) => write!(f, "JSON Parse Error: {e}"),
LLMError::ToolConfigError(e) => write!(f, "Tool Configuration Error: {e}"),
LLMError::NoToolSupport(e) => write!(f, "No Tool Support: {e}"),
LLMError::GuardrailBlocked {
phase,
guard,
rule_id,
category,
severity,
message,
} => display_guardrail_blocked(f, *phase, guard, rule_id, category, severity, message),
LLMError::GuardrailExecutionFailed { guard, message } => write!(
f,
"guardrail execution failed: guard={guard}, error={message}"
),
}
}
}
impl std::error::Error for LLMError {}
#[cfg(not(target_arch = "wasm32"))]
impl From<reqwest::Error> for LLMError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() {
LLMError::HttpError(format!("request timed out: {err}"))
} else if err.is_connect() {
LLMError::HttpError(format!("connection failed: {err}"))
} else {
LLMError::HttpError(err.to_string())
}
}
}
impl From<serde_json::Error> for LLMError {
fn from(err: serde_json::Error) -> Self {
LLMError::JsonError(format!(
"{} at line {} column {}",
err,
err.line(),
err.column()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Error as JsonError;
#[test]
fn test_truncate_for_display_short_body_unchanged() {
assert_eq!(truncate_for_display("short"), "short");
}
#[test]
fn test_truncate_for_display_long_body() {
let body = "x".repeat(MAX_ERROR_BODY_DISPLAY_BYTES + 10);
let truncated = truncate_for_display(&body);
assert!(truncated.contains("truncated"));
assert!(truncated.starts_with(&"x".repeat(MAX_ERROR_BODY_DISPLAY_BYTES)));
}
#[test]
fn test_is_transport_retryable_message_prefixed_forms() {
assert!(is_transport_retryable_message(
"request timed out: operation timed out"
));
assert!(is_transport_retryable_message(
"connection failed: tcp connect error"
));
assert!(!is_transport_retryable_message(
"HTTP Status Error (400): bad request"
));
}
#[test]
fn test_llm_error_display_auth_error_with_status_truncates_body() {
let body = "secret".repeat(200);
let error = LLMError::AuthError {
message: "Unauthorized".to_string(),
status_code: Some(401),
response_body: Some(body.clone().into_boxed_str()),
};
let display = error.to_string();
assert!(display.contains("401"));
assert!(display.contains("truncated"));
assert_eq!(error.response_body(), Some(body.as_str()));
}
#[test]
fn test_llm_error_display_rate_limit_error_includes_status() {
let error = LLMError::RateLimitError {
status_code: 529,
message: "Overloaded".to_string(),
response_body: "overload".into(),
retry_after: Some(Duration::from_secs(30)),
provider_code: Some("overloaded".into()),
};
let display = error.to_string();
assert!(display.contains("529"));
assert!(display.contains("overloaded"));
assert_eq!(error.http_status_code(), Some(529));
}
#[test]
fn test_invalid_request_preserves_response_body() {
let err = LLMError::InvalidRequest {
message: "bad request".into(),
status_code: Some(400),
response_body: Some(r#"{"error":"details"}"#.into()),
};
assert_eq!(err.http_status_code(), Some(400));
assert_eq!(err.response_body(), Some(r#"{"error":"details"}"#));
}
#[test]
fn test_is_retryable_matrix() {
assert!(
LLMError::RateLimitError {
status_code: 429,
message: "limit".into(),
response_body: "body".into(),
retry_after: None,
provider_code: None,
}
.is_retryable()
);
assert!(
LLMError::HttpStatusError {
status_code: 503,
message: "down".into(),
response_body: "body".into(),
retry_after: None,
provider_code: None,
}
.is_retryable()
);
assert!(
!LLMError::HttpStatusError {
status_code: 400,
message: "bad".into(),
response_body: "body".into(),
retry_after: None,
provider_code: None,
}
.is_retryable()
);
assert!(!LLMError::Generic("unsupported".into()).is_retryable());
assert!(LLMError::HttpError("request timed out: elapsed".into()).is_retryable());
}
#[test]
fn test_llm_error_debug_truncates_response_body() {
let body = "secret".repeat(MAX_ERROR_BODY_DISPLAY_BYTES + 10);
let error = LLMError::RateLimitError {
status_code: 429,
message: "limit".into(),
response_body: body.clone().into_boxed_str(),
retry_after: None,
provider_code: None,
};
let debug = format!("{error:?}");
assert!(debug.contains("truncated"));
assert!(!debug.contains(&body));
}
#[test]
fn test_from_serde_json_error() {
let json_str = r#"{"invalid": json}"#;
let json_error: JsonError =
serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
let llm_error: LLMError = json_error.into();
match llm_error {
LLMError::JsonError(msg) => {
assert!(msg.contains("line"));
assert!(msg.contains("column"));
}
_ => panic!("Expected JsonError"),
}
}
#[test]
fn test_http_status_code_and_response_body_accessors() {
let auth = LLMError::AuthError {
message: "denied".into(),
status_code: Some(403),
response_body: Some("body".into()),
};
assert_eq!(auth.http_status_code(), Some(403));
assert_eq!(auth.response_body(), Some("body"));
let rate_limit = LLMError::RateLimitError {
status_code: 429,
message: "limit".into(),
response_body: "payload".into(),
retry_after: None,
provider_code: None,
};
assert_eq!(rate_limit.response_body(), Some("payload"));
let http_status = LLMError::HttpStatusError {
status_code: 502,
message: "bad gateway".into(),
response_body: "html".into(),
retry_after: None,
provider_code: None,
};
assert_eq!(http_status.http_status_code(), Some(502));
assert_eq!(http_status.response_body(), Some("html"));
let format_error = LLMError::ResponseFormatError {
message: "invalid json".into(),
raw_response: "not-json".into(),
};
assert_eq!(format_error.response_body(), Some("not-json"));
assert_eq!(LLMError::Generic("x".into()).http_status_code(), None);
assert_eq!(LLMError::JsonError("parse".into()).response_body(), None);
}
#[test]
fn test_is_transport_retryable_method() {
assert!(LLMError::HttpError("request timed out: elapsed".into()).is_transport_retryable());
assert!(!LLMError::Generic("timeout".into()).is_transport_retryable());
}
#[test]
fn test_truncate_for_display_respects_utf8_boundary() {
let body = format!("{}€{}", "a".repeat(511), "z".repeat(20));
let truncated = truncate_for_display(&body);
assert!(truncated.contains("truncated"));
assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
}
#[test]
fn test_is_fallbackable_matrix() {
assert!(is_fallbackable(&LLMError::HttpError("down".into())));
assert!(is_fallbackable(&LLMError::RateLimitError {
status_code: 429,
message: "limit".into(),
response_body: "body".into(),
retry_after: None,
provider_code: None,
}));
assert!(!is_fallbackable(&LLMError::missing_api_key("missing")));
assert!(!is_fallbackable(&LLMError::GuardrailBlocked {
phase: GuardrailPhase::Input,
guard: "g".into(),
rule_id: "r".into(),
category: "c".into(),
severity: "high".into(),
message: "blocked".into(),
}));
}
#[test]
fn test_llm_error_debug_covers_struct_variants() {
let cases: Vec<LLMError> = vec![
LLMError::HttpError("transport".into()),
LLMError::AuthError {
message: "denied".into(),
status_code: Some(401),
response_body: Some("secret".into()),
},
LLMError::HttpStatusError {
status_code: 500,
message: "fail".into(),
response_body: "body".into(),
retry_after: None,
provider_code: Some("internal".into()),
},
LLMError::InvalidRequest {
message: "bad".into(),
status_code: Some(422),
response_body: Some("details".into()),
},
LLMError::ProviderError("provider".into()),
LLMError::ResponseFormatError {
message: "parse".into(),
raw_response: "raw".into(),
},
LLMError::Generic("generic".into()),
LLMError::JsonError("json".into()),
LLMError::ToolConfigError("tool".into()),
LLMError::NoToolSupport("tools".into()),
LLMError::GuardrailBlocked {
phase: GuardrailPhase::Output,
guard: "guard".into(),
rule_id: "rule".into(),
category: "cat".into(),
severity: "low".into(),
message: "msg".into(),
},
LLMError::GuardrailExecutionFailed {
guard: "guard".into(),
message: "runtime".into(),
},
];
for error in cases {
let debug = format!("{error:?}");
assert!(!debug.is_empty());
}
}
#[test]
fn test_llm_error_display_covers_remaining_variants() {
assert!(
LLMError::HttpError("transport".into())
.to_string()
.contains("HTTP Error")
);
let status = LLMError::HttpStatusError {
status_code: 500,
message: "fail".into(),
response_body: "body".into(),
retry_after: Some(Duration::from_secs(120)),
provider_code: Some("INTERNAL".into()),
};
let status_display = status.to_string();
assert!(status_display.contains("Provider code: INTERNAL"));
assert!(status_display.contains("Retry-After: 120s"));
let format_error = LLMError::ResponseFormatError {
message: "bad json".into(),
raw_response: "not-json".into(),
};
assert!(format_error.to_string().contains("Response Format Error"));
assert!(format_error.to_string().contains("Raw response"));
let input_block = LLMError::GuardrailBlocked {
phase: GuardrailPhase::Input,
guard: "g".into(),
rule_id: "r".into(),
category: "c".into(),
severity: "high".into(),
message: "blocked".into(),
};
assert!(input_block.to_string().contains("guardrail blocked input"));
let output_block = LLMError::GuardrailBlocked {
phase: GuardrailPhase::Output,
guard: "g".into(),
rule_id: "r".into(),
category: "c".into(),
severity: "high".into(),
message: "blocked".into(),
};
assert!(
output_block
.to_string()
.contains("guardrail blocked output")
);
let execution_failed = LLMError::GuardrailExecutionFailed {
guard: "g".into(),
message: "runtime".into(),
};
assert!(
execution_failed
.to_string()
.contains("guardrail execution failed")
);
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_from_reqwest_connection_error() {
let client = reqwest::Client::new();
let err = client
.get("http://127.0.0.1:1")
.send()
.await
.expect_err("connection should fail");
let llm_err = LLMError::from(err);
match llm_err {
LLMError::HttpError(message) => {
assert!(message.starts_with("connection failed:"));
}
other => panic!("unexpected error: {other:?}"),
}
}
}