use std::fmt;
use crate::backend_error::BackendErrorKind;
#[derive(Debug, Clone)]
pub enum BackendError {
RateLimit {
provider: String,
model: String,
retry_after_seconds: Option<u64>,
body_preview: String,
},
Auth {
provider: String,
model: String,
api_key_env: Option<String>,
status: u16,
body_preview: String,
},
ContextLength {
provider: String,
model: String,
body_preview: String,
},
SafetyBreach {
provider: String,
model: String,
finish_reason: String,
body_preview: String,
},
ModelNotFound {
provider: String,
model: String,
status: u16,
body_preview: String,
},
Generic {
provider: String,
model: String,
status: Option<u16>,
message: String,
},
}
impl BackendError {
pub fn provider(&self) -> &str {
match self {
Self::RateLimit { provider, .. }
| Self::Auth { provider, .. }
| Self::ContextLength { provider, .. }
| Self::SafetyBreach { provider, .. }
| Self::ModelNotFound { provider, .. }
| Self::Generic { provider, .. } => provider,
}
}
pub fn model(&self) -> &str {
match self {
Self::RateLimit { model, .. }
| Self::Auth { model, .. }
| Self::ContextLength { model, .. }
| Self::SafetyBreach { model, .. }
| Self::ModelNotFound { model, .. }
| Self::Generic { model, .. } => model,
}
}
pub fn kind(&self) -> BackendErrorKind {
match self {
Self::RateLimit { retry_after_seconds, .. } => BackendErrorKind::RateLimit {
retry_after: retry_after_seconds.map(std::time::Duration::from_secs),
},
Self::Auth { .. } => BackendErrorKind::AuthError,
Self::ContextLength { .. } => BackendErrorKind::Unknown, Self::SafetyBreach { .. } => BackendErrorKind::Unknown, Self::ModelNotFound { .. } => BackendErrorKind::Unknown, Self::Generic { status, .. } => match status {
Some(s) if (500..600).contains(s) => BackendErrorKind::ServerError { status: *s },
Some(429) => BackendErrorKind::RateLimit { retry_after: None },
Some(401) | Some(403) => BackendErrorKind::AuthError,
Some(408) => BackendErrorKind::Timeout,
Some(_) => BackendErrorKind::Unknown,
None => BackendErrorKind::NetworkError,
},
}
}
pub fn is_retryable(&self) -> bool {
self.kind().is_retryable()
}
pub fn category(&self) -> &'static str {
match self {
Self::RateLimit { .. } => "rate_limit",
Self::Auth { .. } => "auth_error",
Self::ContextLength { .. } => "context_length_exceeded",
Self::SafetyBreach { .. } => "safety_breach",
Self::ModelNotFound { .. } => "model_not_found",
Self::Generic { .. } => "model_call_error",
}
}
}
impl fmt::Display for BackendError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::RateLimit {
provider,
model,
retry_after_seconds,
body_preview,
} => {
let retry_after_part = retry_after_seconds
.map(|s| format!(", retry_after={}s", s))
.unwrap_or_default();
write!(
f,
"Rate limit on provider {provider:?} (model={model:?}, status=429{retry_after_part}). \
Retries exhausted. Body: {body_preview}"
)
}
Self::Auth {
provider,
model,
api_key_env,
status,
body_preview,
} => {
let env_hint = api_key_env
.as_ref()
.map(|env| format!(" (env var: {env})"))
.unwrap_or_default();
write!(
f,
"Authentication failed on provider {provider:?}{env_hint}, \
status={status}. Verify the API key is set, valid, and has \
access to model {model:?}. Body: {body_preview}"
)
}
Self::ContextLength {
provider,
model,
body_preview,
} => write!(
f,
"Prompt exceeds context window of model {model:?} on provider \
{provider:?} (status=400). Body: {body_preview}"
),
Self::SafetyBreach {
provider,
model,
finish_reason,
body_preview,
} => write!(
f,
"Provider {provider:?} content filter blocked the request \
(model={model:?}, finish_reason={finish_reason:?}). Body: {body_preview}"
),
Self::ModelNotFound {
provider,
model,
status,
body_preview,
} => write!(
f,
"Model {model:?} not found at provider {provider:?} (status={status}). \
Either the slug is mistyped or the model was deprecated. Body: {body_preview}"
),
Self::Generic {
provider,
model,
status,
message,
} => {
let status_part = status
.map(|s| format!("HTTP {s}"))
.unwrap_or_else(|| "transport error".to_string());
write!(
f,
"Provider {provider:?} returned {status_part} for model \
{model:?}. {message}"
)
}
}
}
}
impl std::error::Error for BackendError {}
pub fn categorise_http(
provider: &str,
model: &str,
status: u16,
headers: &reqwest::header::HeaderMap,
body: &str,
api_key_env: Option<&str>,
) -> BackendError {
let body_preview: String = body.chars().take(200).collect();
let body_lower = body.to_lowercase();
if status == 429 {
let retry_after = headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse::<u64>().ok());
return BackendError::RateLimit {
provider: provider.to_string(),
model: model.to_string(),
retry_after_seconds: retry_after,
body_preview,
};
}
if status == 401 || status == 403 {
return BackendError::Auth {
provider: provider.to_string(),
model: model.to_string(),
api_key_env: api_key_env.map(str::to_string),
status,
body_preview,
};
}
if status == 404 {
return BackendError::ModelNotFound {
provider: provider.to_string(),
model: model.to_string(),
status,
body_preview,
};
}
if status == 400 {
if body_lower.contains("context_length")
|| body_lower.contains("context length")
|| body_lower.contains("maximum context")
|| body_lower.contains("too long")
{
return BackendError::ContextLength {
provider: provider.to_string(),
model: model.to_string(),
body_preview,
};
}
if body_lower.contains("model_not_found")
|| body_lower.contains("model not found")
|| body_lower.contains("no such model")
{
return BackendError::ModelNotFound {
provider: provider.to_string(),
model: model.to_string(),
status,
body_preview,
};
}
}
BackendError::Generic {
provider: provider.to_string(),
model: model.to_string(),
status: Some(status),
message: format!("Body: {body_preview}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::HeaderMap;
fn empty_headers() -> HeaderMap {
HeaderMap::new()
}
#[test]
fn ratelimit_carries_retry_after() {
let mut h = HeaderMap::new();
h.insert("retry-after", "60".parse().unwrap());
let err = categorise_http("anthropic", "claude-x", 429, &h, "rate-limited", None);
assert!(matches!(err, BackendError::RateLimit { retry_after_seconds: Some(60), .. }));
assert_eq!(err.category(), "rate_limit");
assert!(err.is_retryable());
}
#[test]
fn ratelimit_without_header_is_still_classified() {
let err = categorise_http("openai", "gpt-x", 429, &empty_headers(), "no body", None);
match err {
BackendError::RateLimit { retry_after_seconds, .. } => {
assert!(retry_after_seconds.is_none());
}
_ => panic!("expected RateLimit"),
}
}
#[test]
fn auth_401_with_env_hint() {
let err = categorise_http(
"kimi",
"kimi-k2.6",
401,
&empty_headers(),
"unauthorized",
Some("AXON_KIMI_API_KEY"),
);
match err {
BackendError::Auth { api_key_env, status, .. } => {
assert_eq!(api_key_env.as_deref(), Some("AXON_KIMI_API_KEY"));
assert_eq!(status, 401);
}
_ => panic!("expected Auth"),
}
}
#[test]
fn auth_403_also_classified_as_auth() {
let err = categorise_http("openai", "gpt-x", 403, &empty_headers(), "", None);
assert!(matches!(err, BackendError::Auth { status: 403, .. }));
}
#[test]
fn model_not_found_404() {
let err = categorise_http("openai", "gpt-3.999", 404, &empty_headers(), "", None);
assert!(matches!(err, BackendError::ModelNotFound { .. }));
assert!(!err.is_retryable()); }
#[test]
fn context_length_400_with_oai_marker() {
let body = r#"{"error":{"code":"context_length_exceeded","message":"prompt too long"}}"#;
let err = categorise_http("openai", "gpt-x", 400, &empty_headers(), body, None);
assert!(matches!(err, BackendError::ContextLength { .. }));
}
#[test]
fn context_length_400_with_anthropic_marker() {
let body = "the prompt is too long for this model's maximum context";
let err = categorise_http("anthropic", "claude-x", 400, &empty_headers(), body, None);
assert!(matches!(err, BackendError::ContextLength { .. }));
}
#[test]
fn model_not_found_400_with_marker() {
let body = r#"{"error":{"code":"model_not_found"}}"#;
let err = categorise_http("openai", "gpt-y", 400, &empty_headers(), body, None);
assert!(matches!(err, BackendError::ModelNotFound { status: 400, .. }));
}
#[test]
fn generic_500_is_retryable() {
let err = categorise_http("openai", "gpt-x", 500, &empty_headers(), "boom", None);
assert!(matches!(err, BackendError::Generic { status: Some(500), .. }));
assert!(err.is_retryable());
}
#[test]
fn generic_502_is_retryable() {
let err = categorise_http("openai", "gpt-x", 502, &empty_headers(), "", None);
assert!(err.is_retryable());
}
#[test]
fn generic_400_unmapped_is_not_retryable() {
let err = categorise_http("openai", "gpt-x", 400, &empty_headers(), "weird", None);
assert!(matches!(err, BackendError::Generic { .. }));
assert!(!err.is_retryable());
}
#[test]
fn provider_and_model_accessors() {
let err = categorise_http("kimi", "kimi-k2.6", 429, &empty_headers(), "", None);
assert_eq!(err.provider(), "kimi");
assert_eq!(err.model(), "kimi-k2.6");
}
#[test]
fn body_preview_truncated_to_200_chars() {
let body = "x".repeat(500);
let err = categorise_http("openai", "gpt-x", 500, &empty_headers(), &body, None);
match err {
BackendError::Generic { message, .. } => {
assert!(message.starts_with("Body: "));
let preview = &message["Body: ".len()..];
assert_eq!(preview.len(), 200);
}
_ => panic!("expected Generic"),
}
}
#[test]
fn display_includes_provider_and_status() {
let err = categorise_http("anthropic", "claude-x", 429, &empty_headers(), "tx", None);
let s = format!("{err}");
assert!(s.contains("anthropic"));
assert!(s.contains("claude-x"));
assert!(s.contains("429"));
}
#[test]
fn safety_breach_constructed_directly() {
let err = BackendError::SafetyBreach {
provider: "openai".to_string(),
model: "gpt-4o".to_string(),
finish_reason: "content_filter".to_string(),
body_preview: "{}".to_string(),
};
assert_eq!(err.category(), "safety_breach");
assert!(!err.is_retryable());
let msg = format!("{err}");
assert!(msg.contains("content_filter"));
}
}