use std::fmt;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::capabilities::CapabilityMismatch;
use crate::ids::{ModelKey, ModelRef, ProviderKey};
pub const MAX_ERROR_CODE_LEN: usize = 64;
pub const MAX_ERROR_DETAIL_LEN: usize = 512;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProviderDetail(String);
impl ProviderDetail {
#[must_use]
pub fn new(raw: impl AsRef<str>) -> Self {
let mut out = String::with_capacity(MAX_ERROR_DETAIL_LEN);
let mut spaced = false;
for ch in raw.as_ref().chars() {
if out.len() + ch.len_utf8() > MAX_ERROR_DETAIL_LEN {
break;
}
if ch.is_whitespace() {
if !out.is_empty() && !spaced {
out.push(' ');
spaced = true;
}
continue;
}
if ch.is_control() {
continue;
}
out.push(ch);
spaced = false;
}
Self(out.trim_end().to_owned())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl fmt::Display for ProviderDetail {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ErrorCode(String);
impl ErrorCode {
#[must_use]
pub fn new(raw: impl AsRef<str>) -> Self {
let mut out = String::with_capacity(MAX_ERROR_CODE_LEN);
for ch in raw.as_ref().chars() {
if out.len() >= MAX_ERROR_CODE_LEN {
break;
}
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | ':' | '-') {
out.push(ch);
} else {
out.push('_');
}
}
Self(out)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetryClass {
Retry,
RetryAfter,
Fallback,
Fatal,
}
impl RetryClass {
pub const ALL: [Self; 4] = [Self::Retry, Self::RetryAfter, Self::Fallback, Self::Fatal];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Retry => "retry",
Self::RetryAfter => "retry_after",
Self::Fallback => "fallback",
Self::Fatal => "fatal",
}
}
#[must_use]
pub const fn allows_same_provider(self) -> bool {
matches!(self, Self::Retry | Self::RetryAfter)
}
#[must_use]
pub const fn allows_another_candidate(self) -> bool {
matches!(self, Self::Retry | Self::RetryAfter | Self::Fallback)
}
}
impl fmt::Display for RetryClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProviderErrorKind {
Timeout,
RateLimited {
#[serde(default, skip_serializing_if = "Option::is_none")]
retry_after: Option<Duration>,
},
Authentication,
Authorization,
CredentialExpired,
QuotaExhausted {
#[serde(default, skip_serializing_if = "Option::is_none")]
scope: Option<ErrorCode>,
},
InvalidRequest,
ContextOverflow {
#[serde(default, skip_serializing_if = "Option::is_none")]
needed_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
limit_tokens: Option<u64>,
},
ModelNotFound,
Refusal,
ContentFilter,
Malformed,
Transport,
Server {
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<u16>,
},
Cancelled,
CapabilityMismatch {
mismatch: CapabilityMismatch,
},
Unsupported {
feature: ErrorCode,
},
Other,
}
impl ProviderErrorKind {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Timeout => "timeout",
Self::RateLimited { .. } => "rate_limited",
Self::Authentication => "authentication",
Self::Authorization => "authorization",
Self::CredentialExpired => "credential_expired",
Self::QuotaExhausted { .. } => "quota_exhausted",
Self::InvalidRequest => "invalid_request",
Self::ContextOverflow { .. } => "context_overflow",
Self::ModelNotFound => "model_not_found",
Self::Refusal => "refusal",
Self::ContentFilter => "content_filter",
Self::Malformed => "malformed",
Self::Transport => "transport",
Self::Server { .. } => "server",
Self::Cancelled => "cancelled",
Self::CapabilityMismatch { .. } => "capability_mismatch",
Self::Unsupported { .. } => "unsupported",
Self::Other => "other",
}
}
#[must_use]
pub const fn retry_after(&self) -> Option<Duration> {
match self {
Self::RateLimited { retry_after } => *retry_after,
_ => None,
}
}
#[must_use]
pub const fn retry_class(&self) -> RetryClass {
match self {
Self::Timeout | Self::Malformed | Self::Transport | Self::Server { .. } => {
RetryClass::Retry
}
Self::RateLimited { .. } => RetryClass::RetryAfter,
Self::Authentication
| Self::Authorization
| Self::CredentialExpired
| Self::QuotaExhausted { .. }
| Self::ModelNotFound
| Self::CapabilityMismatch { .. }
| Self::Unsupported { .. } => RetryClass::Fallback,
Self::InvalidRequest
| Self::ContextOverflow { .. }
| Self::Refusal
| Self::ContentFilter
| Self::Cancelled
| Self::Other => RetryClass::Fatal,
}
}
#[must_use]
pub const fn core_code(&self) -> turnframe_core::error::ProviderFailureCode {
use turnframe_core::error::ProviderFailureCode as Code;
match self {
Self::Timeout => Code::Timeout,
Self::RateLimited { .. } => Code::RateLimited,
Self::Authentication | Self::Authorization => Code::Authentication,
Self::CredentialExpired => Code::CredentialExpired,
Self::QuotaExhausted { .. } => Code::QuotaExhausted,
Self::ContextOverflow { .. } => Code::ContextOverflow,
Self::Malformed => Code::Malformed,
Self::Refusal => Code::Refusal,
Self::CapabilityMismatch { .. } => Code::CapabilityMismatch,
Self::Cancelled => Code::Cancelled,
Self::Server { .. } | Self::Transport => Code::ServerError,
Self::InvalidRequest
| Self::ModelNotFound
| Self::ContentFilter
| Self::Unsupported { .. }
| Self::Other => Code::Other,
}
}
}
impl fmt::Display for ProviderErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())?;
match self {
Self::RateLimited {
retry_after: Some(delay),
} => write!(f, "(retry_after={}s)", delay.as_secs()),
Self::ContextOverflow {
needed_tokens: Some(needed),
limit_tokens: Some(limit),
} => write!(f, "(needed={needed}, limit={limit})"),
Self::Server {
status: Some(status),
} => write!(f, "(status={status})"),
Self::QuotaExhausted { scope: Some(scope) } => write!(f, "({scope})"),
Self::CapabilityMismatch { mismatch } => write!(f, "({mismatch})"),
Self::Unsupported { feature } => write!(f, "({feature})"),
_ => Ok(()),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ProviderError {
kind: ProviderErrorKind,
provider: Option<ProviderKey>,
model: Option<ModelKey>,
code: Option<ErrorCode>,
detail: Option<Box<ProviderDetail>>,
}
impl ProviderError {
#[must_use]
pub const fn new(kind: ProviderErrorKind) -> Self {
Self {
kind,
provider: None,
model: None,
code: None,
detail: None,
}
}
#[must_use]
pub const fn timeout() -> Self {
Self::new(ProviderErrorKind::Timeout)
}
#[must_use]
pub const fn rate_limited(retry_after: Option<Duration>) -> Self {
Self::new(ProviderErrorKind::RateLimited { retry_after })
}
#[must_use]
pub const fn authentication() -> Self {
Self::new(ProviderErrorKind::Authentication)
}
#[must_use]
pub const fn authorization() -> Self {
Self::new(ProviderErrorKind::Authorization)
}
#[must_use]
pub const fn credential_expired() -> Self {
Self::new(ProviderErrorKind::CredentialExpired)
}
#[must_use]
pub fn quota_exhausted(scope: Option<&str>) -> Self {
Self::new(ProviderErrorKind::QuotaExhausted {
scope: scope.map(ErrorCode::new),
})
}
#[must_use]
pub fn invalid_request(code: impl AsRef<str>) -> Self {
Self::new(ProviderErrorKind::InvalidRequest).with_code(code)
}
#[must_use]
pub const fn context_overflow(needed_tokens: Option<u64>, limit_tokens: Option<u64>) -> Self {
Self::new(ProviderErrorKind::ContextOverflow {
needed_tokens,
limit_tokens,
})
}
#[must_use]
pub const fn model_not_found() -> Self {
Self::new(ProviderErrorKind::ModelNotFound)
}
#[must_use]
pub const fn refusal() -> Self {
Self::new(ProviderErrorKind::Refusal)
}
#[must_use]
pub const fn content_filter() -> Self {
Self::new(ProviderErrorKind::ContentFilter)
}
#[must_use]
pub fn malformed(code: impl AsRef<str>) -> Self {
Self::new(ProviderErrorKind::Malformed).with_code(code)
}
#[must_use]
pub fn transport(code: impl AsRef<str>) -> Self {
Self::new(ProviderErrorKind::Transport).with_code(code)
}
#[must_use]
pub const fn server(status: Option<u16>) -> Self {
Self::new(ProviderErrorKind::Server { status })
}
#[must_use]
pub const fn cancelled() -> Self {
Self::new(ProviderErrorKind::Cancelled)
}
#[must_use]
pub fn capability_mismatch(mismatch: CapabilityMismatch) -> Self {
Self::new(ProviderErrorKind::CapabilityMismatch { mismatch })
}
#[must_use]
pub fn unsupported(feature: impl AsRef<str>) -> Self {
Self::new(ProviderErrorKind::Unsupported {
feature: ErrorCode::new(feature),
})
}
#[must_use]
pub fn other(code: impl AsRef<str>) -> Self {
Self::new(ProviderErrorKind::Other).with_code(code)
}
#[must_use]
pub fn with_detail(mut self, detail: impl AsRef<str>) -> Self {
let detail = ProviderDetail::new(detail);
if !detail.is_empty() {
self.detail = Some(Box::new(detail));
}
self
}
#[must_use]
pub fn detail(&self) -> Option<&ProviderDetail> {
self.detail.as_deref()
}
#[must_use]
pub fn with_code(mut self, code: impl AsRef<str>) -> Self {
self.code = Some(ErrorCode::new(code));
self
}
#[must_use]
pub fn with_provider(mut self, provider: impl Into<ProviderKey>) -> Self {
self.provider = Some(provider.into());
self
}
#[must_use]
pub fn with_model(mut self, model: &ModelRef) -> Self {
self.provider = Some(model.provider.clone());
self.model = Some(model.model.clone());
self
}
#[must_use]
pub fn kind(&self) -> &ProviderErrorKind {
&self.kind
}
#[must_use]
pub fn provider(&self) -> Option<&ProviderKey> {
self.provider.as_ref()
}
#[must_use]
pub fn model(&self) -> Option<&ModelKey> {
self.model.as_ref()
}
#[must_use]
pub fn code(&self) -> Option<&ErrorCode> {
self.code.as_ref()
}
#[must_use]
pub const fn retry_class(&self) -> RetryClass {
self.kind.retry_class()
}
#[must_use]
pub const fn retry_after(&self) -> Option<Duration> {
self.kind.retry_after()
}
#[must_use]
pub const fn is_retryable(&self) -> bool {
self.retry_class().allows_same_provider()
}
#[must_use]
pub fn to_core_failure(&self) -> turnframe_core::error::ProviderFailure {
turnframe_core::error::ProviderFailure {
provider_key: self
.provider
.clone()
.unwrap_or_else(|| ProviderKey::from("unknown")),
model_key: self.model.clone(),
code: self.kind.core_code(),
retryable: self.is_retryable(),
detail: self
.detail
.as_ref()
.map(|detail| detail.as_str().to_owned()),
}
}
}
impl fmt::Debug for ProviderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProviderError")
.field("kind", &self.kind)
.field("provider", &self.provider)
.field("model", &self.model)
.field("code", &self.code)
.field("detail", &self.detail.is_some())
.finish()
}
}
impl fmt::Display for ProviderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("provider call failed: ")?;
fmt::Display::fmt(&self.kind, f)?;
match (&self.provider, &self.model) {
(Some(provider), Some(model)) => write!(f, " [{provider}/{model}]")?,
(Some(provider), None) => write!(f, " [{provider}]")?,
_ => {}
}
if let Some(code) = &self.code {
write!(f, " code={code}")?;
}
Ok(())
}
}
impl std::error::Error for ProviderError {}
impl From<ProviderError> for turnframe_core::error::ProviderFailure {
fn from(value: ProviderError) -> Self {
value.to_core_failure()
}
}
impl From<CapabilityMismatch> for ProviderError {
fn from(value: CapabilityMismatch) -> Self {
Self::capability_mismatch(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capabilities::{MissingCapability, StructuredOutputCapability};
#[test]
fn every_kind_has_a_class_and_a_unique_label() {
let kinds = [
ProviderErrorKind::Timeout,
ProviderErrorKind::RateLimited { retry_after: None },
ProviderErrorKind::Authentication,
ProviderErrorKind::Authorization,
ProviderErrorKind::CredentialExpired,
ProviderErrorKind::QuotaExhausted { scope: None },
ProviderErrorKind::InvalidRequest,
ProviderErrorKind::ContextOverflow {
needed_tokens: None,
limit_tokens: None,
},
ProviderErrorKind::ModelNotFound,
ProviderErrorKind::Refusal,
ProviderErrorKind::ContentFilter,
ProviderErrorKind::Malformed,
ProviderErrorKind::Transport,
ProviderErrorKind::Server { status: None },
ProviderErrorKind::Cancelled,
ProviderErrorKind::CapabilityMismatch {
mismatch: CapabilityMismatch {
missing: vec![MissingCapability::Streaming],
},
},
ProviderErrorKind::Unsupported {
feature: ErrorCode::new("streaming"),
},
ProviderErrorKind::Other,
];
let mut labels: Vec<&str> = kinds.iter().map(ProviderErrorKind::as_str).collect();
assert_eq!(labels.len(), 18);
labels.sort_unstable();
labels.dedup();
assert_eq!(labels.len(), 18, "labels must be unique");
for kind in &kinds {
assert!(RetryClass::ALL.contains(&kind.retry_class()));
}
}
#[test]
fn classification_matches_the_documented_table() {
assert_eq!(ProviderError::timeout().retry_class(), RetryClass::Retry);
assert_eq!(
ProviderError::malformed("bad_json").retry_class(),
RetryClass::Retry
);
assert_eq!(
ProviderError::server(Some(503)).retry_class(),
RetryClass::Retry
);
assert_eq!(
ProviderError::rate_limited(Some(Duration::from_secs(3))).retry_class(),
RetryClass::RetryAfter
);
assert_eq!(
ProviderError::authentication().retry_class(),
RetryClass::Fallback
);
assert_eq!(
ProviderError::model_not_found().retry_class(),
RetryClass::Fallback
);
assert_eq!(
ProviderError::unsupported("streaming").retry_class(),
RetryClass::Fallback
);
assert_eq!(
ProviderError::context_overflow(Some(9), Some(8)).retry_class(),
RetryClass::Fatal
);
assert_eq!(ProviderError::refusal().retry_class(), RetryClass::Fatal);
assert_eq!(
ProviderError::content_filter().retry_class(),
RetryClass::Fatal
);
assert_eq!(ProviderError::cancelled().retry_class(), RetryClass::Fatal);
assert_eq!(
ProviderError::other("weird").retry_class(),
RetryClass::Fatal
);
}
#[test]
fn an_expired_credential_is_not_a_bad_key() {
let expired = ProviderError::credential_expired();
assert!(matches!(
expired.kind(),
ProviderErrorKind::CredentialExpired
));
assert_ne!(
expired.kind().as_str(),
ProviderError::authentication().kind().as_str(),
"the two must stay tellable apart in metrics and replay records"
);
assert_ne!(expired.retry_class(), RetryClass::Retry);
assert_eq!(expired.retry_class(), RetryClass::Fallback);
assert!(!expired.is_retryable());
assert!(expired.retry_class().allows_another_candidate());
assert_eq!(
expired.to_core_failure().code,
turnframe_core::error::ProviderFailureCode::CredentialExpired
);
}
#[test]
fn an_exhausted_quota_is_not_a_rate_limit() {
let quota = ProviderError::quota_exhausted(Some("tokens_per_day"));
assert_eq!(quota.kind().as_str(), "quota_exhausted");
assert_eq!(quota.retry_class(), RetryClass::Fallback);
assert_ne!(quota.retry_class(), RetryClass::RetryAfter);
assert_eq!(quota.retry_after(), None);
assert!(quota.to_string().contains("tokens_per_day"), "{quota}");
assert_eq!(
quota.to_core_failure().code,
turnframe_core::error::ProviderFailureCode::QuotaExhausted
);
let planted = ProviderError::quota_exhausted(Some("{\"error\": \"no credit\"}"));
assert!(!planted.to_string().contains('"'), "{planted}");
assert!(
ProviderError::quota_exhausted(None)
.to_string()
.ends_with("quota_exhausted")
);
}
#[test]
fn error_codes_are_sanitized_and_truncated() {
let planted = ErrorCode::new("{\"error\":{\"message\":\"invalid api key sk-live-1\"}}");
assert!(!planted.as_str().contains('"'));
assert!(!planted.as_str().contains(' '));
let long = ErrorCode::new("x".repeat(500));
assert_eq!(long.as_str().len(), MAX_ERROR_CODE_LEN);
}
#[test]
fn display_carries_codes_and_keys_only() {
let error = ProviderError::rate_limited(Some(Duration::from_secs(30)))
.with_model(&ModelRef::new("openai", "gpt-4o"))
.with_code("requests_per_minute");
let text = error.to_string();
assert_eq!(
text,
"provider call failed: rate_limited(retry_after=30s) [openai/gpt-4o] code=requests_per_minute"
);
assert_eq!(error.retry_after(), Some(Duration::from_secs(30)));
}
#[test]
fn capability_mismatch_names_the_missing_transport() {
let mismatch = CapabilityMismatch {
missing: vec![MissingCapability::StructuredOutput {
required: vec![StructuredOutputCapability::NativeJsonSchema],
declared: StructuredOutputCapability::PromptOnly,
}],
};
let error = ProviderError::from(mismatch);
assert_eq!(error.retry_class(), RetryClass::Fallback);
let text = error.to_string();
assert!(text.contains("native_json_schema"), "{text}");
assert!(text.contains("prompt_only"), "{text}");
}
#[test]
fn core_failure_bridge_keeps_keys_and_retryability() {
let failure = ProviderError::timeout()
.with_model(&ModelRef::new("anthropic", "claude"))
.to_core_failure();
assert_eq!(failure.provider_key.as_str(), "anthropic");
assert_eq!(
failure.model_key.as_ref().map(ModelKey::as_str),
Some("claude")
);
assert!(failure.retryable);
assert_eq!(
failure.code,
turnframe_core::error::ProviderFailureCode::Timeout
);
let fatal = ProviderError::refusal().to_core_failure();
assert!(!fatal.retryable);
assert_eq!(fatal.provider_key.as_str(), "unknown");
}
#[test]
fn kinds_round_trip_through_serde() {
let kind = ProviderErrorKind::RateLimited {
retry_after: Some(Duration::from_millis(1500)),
};
let json = serde_json::to_string(&kind).unwrap();
assert!(json.contains("\"rate_limited\""));
let back: ProviderErrorKind = serde_json::from_str(&json).unwrap();
assert_eq!(back, kind);
}
}