use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::{ExtraMap, RequestField, Subject};
type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ErrorContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "option_duration_secs_f64"
)]
pub retry_after: Option<Duration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_retry_directive: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub param: Option<String>,
#[serde(default, skip_serializing_if = "MessageOrigin::is_unknown")]
pub message_origin: MessageOrigin,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream_message: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "serialize_capped_body"
)]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "ExtraMap::is_empty")]
pub metadata: ExtraMap,
#[serde(default, skip_serializing_if = "FallbackFrom::is_none")]
fallback_from: FallbackFrom,
#[serde(skip)]
source: ErrorSource,
}
#[derive(Clone, Default)]
struct ErrorSource(Option<Arc<dyn std::error::Error + Send + Sync + 'static>>);
impl PartialEq for ErrorSource {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Eq for ErrorSource {}
#[derive(Clone, Default)]
struct FallbackFrom(Option<Arc<Error>>);
impl FallbackFrom {
fn is_none(&self) -> bool {
self.0.is_none()
}
}
impl PartialEq for FallbackFrom {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Eq for FallbackFrom {}
impl Serialize for FallbackFrom {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
self.0.as_deref().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for FallbackFrom {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
Ok(Self(
Option::<Error>::deserialize(deserializer)?.map(Arc::new),
))
}
}
pub(crate) const EXTRACTION_METADATA_KEY: &str = "allama.extraction";
pub(crate) const EMBEDDING_METADATA_KEY: &str = "allama.embedding";
fn context_is_empty(context: &ErrorContext) -> bool {
context.is_empty()
}
impl ErrorContext {
pub const MAX_BODY_BYTES: usize = 8 * 1024;
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn capped_body(&self) -> Option<&str> {
self.body.as_deref().map(cap_body)
}
fn is_empty(&self) -> bool {
self == &Self::default() && self.source.0.is_none() && self.fallback_from.is_none()
}
#[must_use]
pub fn source(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
self.source.0.as_deref()
}
fn merge(&mut self, other: ErrorContext) {
let ErrorContext {
provider,
status,
request_id,
retry_after,
server_retry_directive,
provider_code,
provider_type,
param,
message_origin: _,
upstream_message,
body,
metadata,
fallback_from,
source,
} = other;
overwrite(&mut self.provider, provider);
overwrite(&mut self.status, status);
overwrite(&mut self.request_id, request_id);
overwrite(&mut self.retry_after, retry_after);
overwrite(&mut self.server_retry_directive, server_retry_directive);
overwrite(&mut self.provider_code, provider_code);
overwrite(&mut self.provider_type, provider_type);
overwrite(&mut self.param, param);
overwrite(&mut self.upstream_message, upstream_message);
overwrite(
&mut self.body,
body.map(|mut body| {
body.truncate(cap_body(&body).len());
body
}),
);
self.metadata.extend(metadata);
if !fallback_from.is_none() {
self.fallback_from = fallback_from;
}
if source.0.is_some() {
self.source = source;
}
}
fn debug_fields(&self, debug: &mut fmt::DebugStruct<'_, '_>) {
let Self {
provider,
status,
request_id,
retry_after,
server_retry_directive,
provider_code,
provider_type,
param,
message_origin,
upstream_message,
body: _,
metadata,
fallback_from,
source: _,
} = self;
if let Some(provider) = provider {
debug.field("provider", provider);
}
if let Some(status) = status {
debug.field("status", status);
}
for (name, value) in [
("provider_code", provider_code),
("provider_type", provider_type),
("param", param),
] {
if let Some(value) = value {
debug.field(name, &log_token(value));
}
}
if let Some(request_id) = request_id {
debug.field("request_id", request_id);
}
if let Some(retry_after) = retry_after {
debug.field("retry_after", retry_after);
}
if let Some(directive) = server_retry_directive {
debug.field("server_retry_directive", directive);
}
if !message_origin.is_unknown() {
debug.field("message_origin", message_origin);
}
if let Some(upstream) = upstream_message {
debug.field("upstream_message", &Withheld::shown_by_display(upstream));
}
if let Some(body) = self.capped_body() {
debug.field("body_len", &body.len());
}
if !metadata.is_empty() {
debug.field("metadata", &MetadataKeys(metadata));
}
if let Some(earlier) = &fallback_from.0 {
debug.field("fallback_from", earlier);
}
}
}
impl fmt::Debug for ErrorContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut debug = f.debug_struct("ErrorContext");
self.debug_fields(&mut debug);
debug.finish()
}
}
pub(crate) struct Withheld {
bytes: usize,
shown_by_display: bool,
}
impl Withheld {
pub(crate) fn shown_by_display(text: &str) -> Self {
Self {
bytes: text.len(),
shown_by_display: true,
}
}
#[cfg(feature = "extract")]
pub(crate) fn new(text: &str) -> Self {
Self {
bytes: text.len(),
shown_by_display: false,
}
}
}
impl fmt::Debug for Withheld {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "<withheld {} bytes", self.bytes)?;
if self.shown_by_display {
f.write_str("; print with {err}")?;
}
f.write_str(">")
}
}
struct MetadataKeys<'a>(&'a ExtraMap);
impl fmt::Debug for MetadataKeys<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list().entries(self.0.keys()).finish()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum UnsupportedStage {
BeforeSending,
Provider,
#[default]
#[serde(other)]
Unspecified,
}
impl UnsupportedStage {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::BeforeSending => "before_sending",
Self::Provider => "provider",
Self::Unspecified => "unspecified",
}
}
fn is_unspecified(&self) -> bool {
*self == Self::Unspecified
}
}
impl fmt::Display for UnsupportedStage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum MessageOrigin {
#[default]
Unknown,
Authored,
}
impl MessageOrigin {
fn is_unknown(&self) -> bool {
*self == Self::Unknown
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum StreamErrorKind {
Incomplete,
Transport,
Decode,
Protocol,
Other,
}
impl StreamErrorKind {
fn as_str(self) -> &'static str {
match self {
Self::Incomplete => "incomplete",
Self::Transport => "transport",
Self::Decode => "decode",
Self::Protocol => "protocol",
Self::Other => "other",
}
}
}
#[cfg_attr(
not(feature = "extract"),
doc = "[`Error::Extract`]: crate#cargo-features"
)]
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Error {
#[non_exhaustive]
Timeout {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
Transport {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
Auth {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
Configuration {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
Rejected {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
RateLimited {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
QuotaExhausted {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
Overloaded {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
Provider {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
Unsupported {
message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
subject: Option<Subject>,
#[serde(default, skip_serializing_if = "UnsupportedStage::is_unspecified")]
stage: UnsupportedStage,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
InvalidRequest {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
ModelNotFound {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
UnexpectedResponse {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
ContextLengthExceeded {
message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
context_window: Option<u64>,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
ContentFiltered {
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
Stream {
kind: StreamErrorKind,
message: String,
#[serde(default, skip_serializing_if = "context_is_empty")]
context: Box<ErrorContext>,
},
#[non_exhaustive]
Serialization {
error: SerializationError,
},
#[cfg(feature = "extract")]
#[non_exhaustive]
Extract {
failure: Box<crate::extract::ExtractFailure>,
},
}
macro_rules! constructors {
($($(#[$doc:meta])* $fn_name:ident => $variant:ident),* $(,)?) => {
$(
$(#[$doc])*
///
/// The message's origin is [`MessageOrigin::Unknown`], so
/// [`ErrorLog`] leaves it out. Mark text your code wrote, with no
/// upstream text in it, with [`authored`](Self::authored).
#[must_use]
pub fn $fn_name(message: impl Into<String>) -> Self {
Self::$variant {
message: message.into(),
context: Box::default(),
}
}
)*
};
}
impl Error {
constructors! {
timeout => Timeout,
transport => Transport,
auth => Auth,
configuration => Configuration,
rejected => Rejected,
rate_limited => RateLimited,
quota_exhausted => QuotaExhausted,
overloaded => Overloaded,
provider => Provider,
invalid_request => InvalidRequest,
model_not_found => ModelNotFound,
unexpected_response => UnexpectedResponse,
content_filtered => ContentFiltered,
}
#[must_use]
pub fn unsupported(message: impl Into<String>) -> Self {
Self::Unsupported {
message: message.into(),
subject: None,
stage: UnsupportedStage::Unspecified,
context: Box::default(),
}
}
#[must_use]
pub fn unsupported_by_provider(subject: Option<Subject>, message: impl Into<String>) -> Self {
Self::Unsupported {
message: message.into(),
subject,
stage: UnsupportedStage::Provider,
context: Box::default(),
}
}
pub(crate) fn unsupported_before_sending(
message: impl Into<String>,
subject: Option<Subject>,
) -> Self {
Self::Unsupported {
message: message.into(),
subject,
stage: UnsupportedStage::BeforeSending,
context: Box::default(),
}
}
#[must_use]
pub fn context_length_exceeded(
message: impl Into<String>,
context_window: Option<u64>,
) -> Self {
Self::ContextLengthExceeded {
message: message.into(),
context_window,
context: Box::default(),
}
}
#[must_use]
pub fn stream(kind: StreamErrorKind, message: impl Into<String>) -> Self {
Self::Stream {
kind,
message: message.into(),
context: Box::default(),
}
}
#[must_use]
pub fn stream_protocol(message: impl Into<String>) -> Self {
Self::stream(StreamErrorKind::Protocol, message)
}
#[must_use]
pub fn stream_decode(message: impl Into<String>) -> Self {
Self::stream(StreamErrorKind::Decode, message)
}
#[must_use]
pub fn serialization(err: impl Into<SerializationError>) -> Self {
Self::Serialization { error: err.into() }
}
#[cfg(feature = "extract")]
#[must_use]
pub fn extract(failure: crate::extract::ExtractFailure) -> Self {
Self::Extract {
failure: Box::new(failure),
}
}
#[must_use]
pub fn fallback_from(&self) -> Option<&Error> {
self.context()?.fallback_from.0.as_deref()
}
#[must_use]
pub fn with_fallback_from(mut self, earlier: Error) -> Self {
self.attach_fallback_from(earlier);
self
}
fn attach_fallback_from(&mut self, earlier: Error) {
let Some(context) = self.context_mut() else {
return;
};
match &mut context.fallback_from.0 {
Some(next) => Arc::make_mut(next).attach_fallback_from(earlier),
slot @ None => *slot = Some(Arc::new(earlier)),
}
}
#[must_use]
pub fn context(&self) -> Option<&ErrorContext> {
match self {
Error::Timeout { context, .. }
| Error::Transport { context, .. }
| Error::Auth { context, .. }
| Error::Configuration { context, .. }
| Error::Rejected { context, .. }
| Error::RateLimited { context, .. }
| Error::QuotaExhausted { context, .. }
| Error::Overloaded { context, .. }
| Error::Provider { context, .. }
| Error::Unsupported { context, .. }
| Error::InvalidRequest { context, .. }
| Error::ModelNotFound { context, .. }
| Error::UnexpectedResponse { context, .. }
| Error::ContextLengthExceeded { context, .. }
| Error::ContentFiltered { context, .. }
| Error::Stream { context, .. } => Some(context.as_ref()),
Error::Serialization { .. } => None,
#[cfg(feature = "extract")]
Error::Extract { .. } => None,
}
}
pub fn context_mut(&mut self) -> Option<&mut ErrorContext> {
match self {
Error::Timeout { context, .. }
| Error::Transport { context, .. }
| Error::Auth { context, .. }
| Error::Configuration { context, .. }
| Error::Rejected { context, .. }
| Error::RateLimited { context, .. }
| Error::QuotaExhausted { context, .. }
| Error::Overloaded { context, .. }
| Error::Provider { context, .. }
| Error::Unsupported { context, .. }
| Error::InvalidRequest { context, .. }
| Error::ModelNotFound { context, .. }
| Error::UnexpectedResponse { context, .. }
| Error::ContextLengthExceeded { context, .. }
| Error::ContentFiltered { context, .. }
| Error::Stream { context, .. } => Some(context.as_mut()),
Error::Serialization { .. } => None,
#[cfg(feature = "extract")]
Error::Extract { .. } => None,
}
}
fn map_context(mut self, f: impl FnOnce(&mut ErrorContext)) -> Self {
if let Some(context) = self.context_mut() {
f(context);
}
self
}
#[must_use]
pub fn with_context(self, context: ErrorContext) -> Self {
self.map_context(|c| c.merge(context))
}
#[must_use]
pub fn with_provider(self, provider: impl Into<String>) -> Self {
let provider = provider.into();
self.map_context(|c| c.provider = Some(provider))
}
#[must_use]
pub fn with_status(self, status: u16) -> Self {
self.map_context(|c| c.status = Some(status))
}
#[must_use]
pub fn with_request_id(self, request_id: impl Into<String>) -> Self {
let request_id = request_id.into();
self.map_context(|c| c.request_id = Some(request_id))
}
#[must_use]
pub fn with_request_id_opt(self, request_id: Option<&str>) -> Self {
match request_id {
Some(request_id) => self.with_request_id(request_id),
None => self,
}
}
#[must_use]
pub fn with_retry_after(self, retry_after: Duration) -> Self {
self.with_retry_after_opt(Some(retry_after))
}
#[must_use]
pub fn with_retry_after_opt(self, retry_after: Option<Duration>) -> Self {
self.map_context(|c| overwrite(&mut c.retry_after, retry_after))
}
#[must_use]
pub fn with_server_retry_directive(self, directive: bool) -> Self {
self.with_server_retry_directive_opt(Some(directive))
}
#[must_use]
pub fn with_server_retry_directive_opt(self, directive: Option<bool>) -> Self {
self.map_context(|c| overwrite(&mut c.server_retry_directive, directive))
}
#[must_use]
pub fn with_provider_code(self, code: impl Into<String>) -> Self {
let code = code.into();
self.map_context(|c| c.provider_code = Some(code))
}
#[must_use]
pub fn with_provider_code_opt(self, code: Option<&str>) -> Self {
match code {
Some(code) => self.with_provider_code(code),
None => self,
}
}
#[must_use]
pub fn with_provider_type(self, provider_type: impl Into<String>) -> Self {
let provider_type = provider_type.into();
self.map_context(|c| c.provider_type = Some(provider_type))
}
#[must_use]
pub fn with_provider_type_opt(self, provider_type: Option<&str>) -> Self {
match provider_type {
Some(provider_type) => self.with_provider_type(provider_type),
None => self,
}
}
#[must_use]
pub fn with_body(self, body: impl Into<String>) -> Self {
let mut body = body.into();
body.truncate(cap_body(&body).len());
self.map_context(|c| c.body = Some(body))
}
#[must_use]
pub fn with_body_opt(self, body: Option<&str>) -> Self {
match body {
Some(body) => self.with_body(cap_body(body)),
None => self,
}
}
#[must_use]
pub fn with_upstream_message(self, message: impl Into<String>) -> Self {
let message = message.into();
if message.trim().is_empty() {
return self;
}
self.map_context(|c| c.upstream_message = Some(message))
}
#[must_use]
pub fn with_upstream_message_opt(self, message: Option<&str>) -> Self {
match message {
Some(message) => self.with_upstream_message(message),
None => self,
}
}
#[must_use]
pub fn with_param(self, param: impl Into<String>) -> Self {
let param = param.into();
self.map_context(|c| c.param = Some(param))
}
#[must_use]
pub fn with_param_opt(self, param: Option<&str>) -> Self {
match param {
Some(param) => self.with_param(param),
None => self,
}
}
#[cfg_attr(
not(feature = "extract"),
doc = "[`Error::Extract`]: crate#cargo-features"
)]
#[must_use]
pub fn with_source(
self,
source: impl Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
) -> Self {
let source = ErrorSource(Some(Arc::from(source.into())));
self.map_context(|c| c.source = source)
}
#[cfg_attr(
not(feature = "extract"),
doc = "[`Error::Extract`]: crate#cargo-features"
)]
#[must_use]
pub fn authored(self) -> Self {
self.map_context(|c| c.message_origin = MessageOrigin::Authored)
}
#[cfg_attr(
not(feature = "extract"),
doc = "[`Error::Extract`]: crate#cargo-features"
)]
#[must_use]
pub fn with_message_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix_message(&prefix.into());
self
}
fn prefix_message(&mut self, prefix: &str) {
match self {
Error::Timeout { message, .. }
| Error::Transport { message, .. }
| Error::Auth { message, .. }
| Error::Configuration { message, .. }
| Error::Rejected { message, .. }
| Error::RateLimited { message, .. }
| Error::QuotaExhausted { message, .. }
| Error::Overloaded { message, .. }
| Error::Provider { message, .. }
| Error::Unsupported { message, .. }
| Error::InvalidRequest { message, .. }
| Error::ModelNotFound { message, .. }
| Error::UnexpectedResponse { message, .. }
| Error::ContextLengthExceeded { message, .. }
| Error::ContentFiltered { message, .. }
| Error::Stream { message, .. } => *message = format!("{prefix}: {message}"),
Error::Serialization { error } => error.prefix(prefix),
#[cfg(feature = "extract")]
Error::Extract { .. } => {}
}
}
#[must_use]
pub fn message(&self) -> String {
match self {
Error::Timeout { message, .. }
| Error::Transport { message, .. }
| Error::Auth { message, .. }
| Error::Configuration { message, .. }
| Error::Rejected { message, .. }
| Error::RateLimited { message, .. }
| Error::QuotaExhausted { message, .. }
| Error::Overloaded { message, .. }
| Error::Provider { message, .. }
| Error::Unsupported { message, .. }
| Error::InvalidRequest { message, .. }
| Error::ModelNotFound { message, .. }
| Error::UnexpectedResponse { message, .. }
| Error::ContextLengthExceeded { message, .. }
| Error::ContentFiltered { message, .. }
| Error::Stream { message, .. } => message.clone(),
Error::Serialization { error } => error.to_string(),
#[cfg(feature = "extract")]
Error::Extract { failure } => failure.to_string(),
}
}
#[must_use]
pub fn status(&self) -> Option<u16> {
self.context()?.status
}
#[must_use]
pub fn request_id(&self) -> Option<&str> {
self.context()?.request_id.as_deref()
}
#[must_use]
pub fn retry_after(&self) -> Option<Duration> {
self.context()?.retry_after
}
#[must_use]
pub fn server_retry_directive(&self) -> Option<bool> {
self.context()?.server_retry_directive
}
#[must_use]
pub fn upstream_message(&self) -> Option<&str> {
self.context()?.upstream_message.as_deref()
}
#[must_use]
pub fn param(&self) -> Option<&str> {
self.context()?.param.as_deref()
}
#[must_use]
pub fn provider_code(&self) -> Option<&str> {
self.context()?.provider_code.as_deref()
}
#[must_use]
pub fn unsupported_subject(&self) -> Option<&Subject> {
match self {
Error::Unsupported { subject, .. } => subject.as_ref(),
_ => None,
}
}
#[must_use]
pub fn unsupported_field(&self) -> Option<RequestField> {
match self.unsupported_subject()? {
Subject::Field { field } => Some(*field),
_ => None,
}
}
#[must_use]
pub fn unsupported_stage(&self) -> Option<UnsupportedStage> {
match self {
Error::Unsupported { stage, .. } => Some(*stage),
_ => None,
}
}
#[must_use]
pub fn message_origin(&self) -> MessageOrigin {
self.context()
.map_or(MessageOrigin::Unknown, |c| c.message_origin)
}
#[doc(alias = "kind")]
#[must_use]
pub fn kind_name(&self) -> &'static str {
match self {
Error::Timeout { .. } => "timeout",
Error::Transport { .. } => "transport",
Error::Auth { .. } => "auth",
Error::Configuration { .. } => "configuration",
Error::Rejected { .. } => "rejected",
Error::RateLimited { .. } => "rate_limited",
Error::QuotaExhausted { .. } => "quota_exhausted",
Error::Overloaded { .. } => "overloaded",
Error::Provider { .. } => "provider",
Error::Serialization { .. } => "serialization",
Error::Unsupported { .. } => "unsupported",
Error::InvalidRequest { .. } => "invalid_request",
Error::ModelNotFound { .. } => "model_not_found",
Error::UnexpectedResponse { .. } => "unexpected_response",
Error::ContextLengthExceeded { .. } => "context_length_exceeded",
Error::ContentFiltered { .. } => "content_filtered",
Error::Stream { .. } => "stream",
#[cfg(feature = "extract")]
Error::Extract { .. } => "extract",
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Error::RateLimited { .. }
| Error::Overloaded { .. }
| Error::Timeout { .. }
| Error::Transport { .. } => true,
Error::Provider { context, .. } => context
.status
.is_none_or(|s| s >= 500 && !matches!(s, 501 | 505)),
Error::Stream { kind, .. } => {
matches!(
kind,
StreamErrorKind::Incomplete | StreamErrorKind::Transport
)
}
_ => false,
}
}
#[must_use]
pub fn is_transient(&self) -> bool {
matches!(
self,
Error::Timeout { .. } | Error::RateLimited { .. } | Error::Overloaded { .. }
)
}
#[must_use]
pub fn is_auth(&self) -> bool {
matches!(self, Error::Auth { .. })
}
#[must_use]
pub fn as_log(&self) -> ErrorLog<'_> {
ErrorLog::new(self)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(provider) = self.context().and_then(|c| c.provider.as_deref()) {
write!(f, "{provider}: ")?;
}
match self {
Error::Timeout { message, .. } => write!(f, "timeout: {message}"),
Error::Transport { message, .. } => write!(f, "transport error: {message}"),
Error::Auth { message, .. } => write!(f, "authentication error: {message}"),
Error::Configuration { message, .. } => {
write!(f, "configuration error: {message}")
}
Error::Rejected { message, .. } => write!(f, "rejected: {message}"),
Error::RateLimited { message, .. } => write!(f, "rate limited: {message}"),
Error::QuotaExhausted { message, .. } => write!(f, "quota exhausted: {message}"),
Error::Overloaded { message, .. } => write!(f, "overloaded: {message}"),
Error::Provider { message, context } => match context.status {
Some(status) => write!(f, "provider error ({status}): {message}"),
None => write!(f, "provider error: {message}"),
},
Error::Serialization { error } => write!(f, "serialization error: {error}"),
Error::Unsupported { message, .. } => write!(f, "unsupported: {message}"),
Error::InvalidRequest { message, .. } => write!(f, "invalid request: {message}"),
Error::ModelNotFound { message, .. } => write!(f, "model not found: {message}"),
Error::UnexpectedResponse { message, .. } => {
write!(f, "unexpected response: {message}")
}
Error::ContextLengthExceeded { message, .. } => {
write!(f, "context length exceeded: {message}")
}
Error::ContentFiltered { message, .. } => write!(f, "content filtered: {message}"),
Error::Stream { kind, message, .. } => {
write!(f, "stream error ({}): {message}", kind.as_str())
}
#[cfg(feature = "extract")]
Error::Extract { failure } => write!(f, "extraction error: {failure}"),
}?;
if let Some(upstream) = self.upstream_message() {
write!(f, ": {upstream}")?;
}
Ok(())
}
}
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let (name, message, context) = match self {
Error::Timeout { message, context } => ("Timeout", message, context),
Error::Transport { message, context } => ("Transport", message, context),
Error::Auth { message, context } => ("Auth", message, context),
Error::Configuration { message, context } => ("Configuration", message, context),
Error::Rejected { message, context } => ("Rejected", message, context),
Error::RateLimited { message, context } => ("RateLimited", message, context),
Error::QuotaExhausted { message, context } => ("QuotaExhausted", message, context),
Error::Overloaded { message, context } => ("Overloaded", message, context),
Error::Provider { message, context } => ("Provider", message, context),
Error::Unsupported {
message, context, ..
} => ("Unsupported", message, context),
Error::InvalidRequest { message, context } => ("InvalidRequest", message, context),
Error::ModelNotFound { message, context } => ("ModelNotFound", message, context),
Error::UnexpectedResponse { message, context } => {
("UnexpectedResponse", message, context)
}
Error::ContextLengthExceeded {
message, context, ..
} => ("ContextLengthExceeded", message, context),
Error::ContentFiltered { message, context } => ("ContentFiltered", message, context),
Error::Stream {
message, context, ..
} => ("Stream", message, context),
Error::Serialization { error } => {
return f
.debug_struct("Serialization")
.field("error", &Withheld::shown_by_display(&error.to_string()))
.finish();
}
#[cfg(feature = "extract")]
Error::Extract { failure } => {
return f.debug_struct("Extract").field("failure", failure).finish();
}
};
let mut debug = f.debug_struct(name);
debug.field("message", message);
match self {
Error::Unsupported { subject, stage, .. } => {
if let Some(subject) = subject {
debug.field("subject", subject);
}
if !stage.is_unspecified() {
debug.field("stage", stage);
}
}
Error::ContextLengthExceeded {
context_window: Some(context_window),
..
} => {
debug.field("context_window", context_window);
}
Error::Stream { kind, .. } => {
debug.field("kind", kind);
}
_ => {}
}
context.debug_fields(&mut debug);
debug.finish()
}
}
#[cfg_attr(
not(feature = "extract"),
doc = "[`Error::Extract`]: crate#cargo-features"
)]
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Serialization { error } => error.source(),
#[cfg(feature = "extract")]
Self::Extract { failure } => failure.source(),
_ => {
let source = self.context()?.source()?;
Some(source)
}
}
}
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Self::serialization(err)
}
}
#[derive(Debug)]
pub struct SerializationError(BoxError);
impl Clone for SerializationError {
fn clone(&self) -> Self {
self.to_string().into()
}
}
impl Serialize for SerializationError {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for SerializationError {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
Ok(String::deserialize(deserializer)?.into())
}
}
impl fmt::Display for SerializationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for SerializationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.0.source()
}
}
impl From<serde_json::Error> for SerializationError {
fn from(err: serde_json::Error) -> Self {
Self(Box::new(err))
}
}
impl From<String> for SerializationError {
fn from(message: String) -> Self {
Self(Box::new(StringError(message)))
}
}
impl From<&str> for SerializationError {
fn from(message: &str) -> Self {
message.to_owned().into()
}
}
impl SerializationError {
#[must_use]
pub fn get_ref(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
match self.0.downcast_ref::<PrefixedError>() {
Some(prefixed) => prefixed.root(),
None => self.0.as_ref(),
}
}
pub(crate) fn prefix(&mut self, prefix: &str) {
let source = std::mem::replace(&mut self.0, Box::new(StringError(String::new())));
self.0 = Box::new(PrefixedError {
prefix: prefix.to_owned(),
source,
});
}
}
#[derive(Debug)]
struct StringError(String);
impl fmt::Display for StringError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for StringError {}
#[derive(Debug)]
struct PrefixedError {
prefix: String,
source: BoxError,
}
impl fmt::Display for PrefixedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.prefix, self.source)
}
}
impl std::error::Error for PrefixedError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source.source()
}
}
impl PrefixedError {
fn root(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
match self.source.downcast_ref::<PrefixedError>() {
Some(inner) => inner.root(),
None => self.source.as_ref(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ParseEnumError {
pub type_name: &'static str,
pub value: String,
pub expected: &'static [&'static str],
}
impl ParseEnumError {
pub(crate) fn new(
type_name: &'static str,
value: &str,
expected: &'static [&'static str],
) -> Self {
Self {
type_name,
value: value.to_owned(),
expected,
}
}
}
impl fmt::Display for ParseEnumError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"unknown {} {:?}; expected one of: {}",
self.type_name,
self.value,
self.expected.join(", ")
)
}
}
impl std::error::Error for ParseEnumError {}
#[derive(Debug, Clone, Copy)]
pub struct ErrorLog<'a> {
error: &'a Error,
message_chars: Option<usize>,
}
impl<'a> ErrorLog<'a> {
#[must_use]
pub const fn new(error: &'a Error) -> Self {
Self {
error,
message_chars: None,
}
}
#[must_use]
pub const fn with_message(mut self, max_chars: usize) -> Self {
self.message_chars = Some(max_chars);
self
}
#[must_use]
pub const fn error(self) -> &'a Error {
self.error
}
const fn of(self, error: &'a Error) -> Self {
Self { error, ..self }
}
fn message(self) -> Option<String> {
match self.error {
Error::Serialization { .. } => None,
#[cfg(feature = "extract")]
Error::Extract { failure } => Some(failure.error().log_message()),
error => (error.message_origin() == MessageOrigin::Authored).then(|| error.message()),
}
}
fn upstream_message(self) -> Option<&'a str> {
let max_chars = self.message_chars?;
let upstream = self.error.upstream_message()?;
Some(
upstream
.char_indices()
.nth(max_chars)
.map_or(upstream, |(end, _)| &upstream[..end]),
)
}
}
impl Serialize for ErrorLog<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
let error = self.error;
let mut map = serde_json::Map::new();
map.insert("type".into(), error.kind_name().into());
insert_opt(&mut map, "message", self.message());
insert_opt(&mut map, "upstream_message", self.upstream_message());
if let Error::ContextLengthExceeded {
context_window: Some(window),
..
} = error
{
map.insert("context_window".into(), (*window).into());
}
if let Error::Stream { kind, .. } = error {
map.insert("stream_kind".into(), kind.as_str().into());
}
if let Some(context) = error.context() {
insert_opt(&mut map, "provider", context.provider.clone());
insert_opt(&mut map, "status", context.status);
insert_opt(&mut map, "request_id", context.request_id.clone());
insert_opt(
&mut map,
"retry_after_secs",
context.retry_after.map(|d| d.as_secs_f64()),
);
insert_opt(
&mut map,
"server_retry_directive",
context.server_retry_directive,
);
for (key, value) in [
("provider_code", &context.provider_code),
("provider_type", &context.provider_type),
("param", &context.param),
] {
insert_opt(&mut map, key, value.as_deref().map(log_token));
}
if let Some(body) = context.capped_body() {
map.insert("body_present".into(), true.into());
map.insert("body_len".into(), body.len().into());
}
for key in [EXTRACTION_METADATA_KEY, EMBEDDING_METADATA_KEY] {
if let Some(record) = context.metadata.get(key) {
map.insert(key.into(), record.clone());
}
}
}
if let Some(earlier) = error.fallback_from() {
map.insert(
"fallback_from".into(),
serde_json::to_value(self.of(earlier)).unwrap_or_default(),
);
}
serde_json::Value::Object(map).serialize(serializer)
}
}
impl fmt::Display for ErrorLog<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let error = self.error;
write!(f, "kind={}", error.kind_name())?;
if let Error::Stream { kind, .. } = error {
write!(f, " stream_kind={}", kind.as_str())?;
}
if let Some(context) = error.context() {
write_log_field(f, "provider", context.provider.as_deref())?;
if let Some(status) = context.status {
write!(f, " status={status}")?;
}
for (key, value) in [
("provider_code", &context.provider_code),
("provider_type", &context.provider_type),
("param", &context.param),
] {
write_log_field(f, key, value.as_deref().map(log_token))?;
}
write_log_field(f, "request_id", context.request_id.as_deref())?;
if let Some(body) = context.capped_body() {
write!(f, " body_len={}", body.len())?;
}
if let Some(extraction) = context.metadata.get(EXTRACTION_METADATA_KEY) {
write_extraction_fields(f, extraction)?;
}
if let Some(embedding) = context.metadata.get(EMBEDDING_METADATA_KEY) {
write_embedding_fields(f, embedding)?;
}
}
if let Some(message) = self.message() {
write!(f, " message={message:?}")?;
}
if let Some(upstream) = self.upstream_message() {
write!(f, " upstream_message={upstream:?}")?;
}
if let Some(earlier) = error.fallback_from() {
write!(f, " fallback_from=[{}]", self.of(earlier))?;
}
Ok(())
}
}
const REDACTED: &str = "<redacted>";
const MAX_LOG_TOKEN_LEN: usize = 128;
fn log_token(value: &str) -> &str {
let token = (1..=MAX_LOG_TOKEN_LEN).contains(&value.len())
&& value.bytes().all(|b| {
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b':' | b'[' | b']' | b'-')
});
if token { value } else { REDACTED }
}
fn write_log_field(f: &mut fmt::Formatter<'_>, key: &str, value: Option<&str>) -> fmt::Result {
match value {
Some(value) => {
write!(f, " {key}=")?;
write_log_value(f, value)
}
None => Ok(()),
}
}
fn write_extraction_fields(f: &mut fmt::Formatter<'_>, record: &serde_json::Value) -> fmt::Result {
let key = EXTRACTION_METADATA_KEY;
write_log_field(f, &format!("{key}.mode"), record["mode"].as_str())?;
for (name, value) in [
("passes", &record["passes"]),
("input_tokens", &record["usage"]["input_tokens"]),
("output_tokens", &record["usage"]["output_tokens"]),
] {
if let Some(value) = value.as_u64() {
write!(f, " {key}.{name}={value}")?;
}
}
Ok(())
}
fn write_embedding_fields(f: &mut fmt::Formatter<'_>, record: &serde_json::Value) -> fmt::Result {
let key = EMBEDDING_METADATA_KEY;
for (name, value) in [
("completed_inputs", &record["completed_inputs"]),
("input_tokens", &record["usage"]["input_tokens"]),
] {
if let Some(value) = value.as_u64() {
write!(f, " {key}.{name}={value}")?;
}
}
Ok(())
}
fn write_log_value(f: &mut fmt::Formatter<'_>, value: &str) -> fmt::Result {
let bare = !value.is_empty()
&& value
.chars()
.all(|c| c.is_ascii_graphic() && c != '"' && c != '=');
if bare {
f.write_str(value)
} else {
write!(f, "{value:?}")
}
}
fn cap_body(body: &str) -> &str {
&body[..body.floor_char_boundary(ErrorContext::MAX_BODY_BYTES)]
}
fn serialize_capped_body<S: Serializer>(
body: &Option<String>,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
body.as_deref().map(cap_body).serialize(serializer)
}
fn overwrite<T>(slot: &mut Option<T>, value: Option<T>) {
if value.is_some() {
*slot = value;
}
}
fn insert_opt<T: Into<serde_json::Value>>(
map: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: Option<T>,
) {
if let Some(value) = value {
map.insert(key.into(), value.into());
}
}
mod option_duration_secs_f64 {
use std::time::Duration;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer>(
value: &Option<Duration>,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
value.map(|d| d.as_secs_f64()).serialize(serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<Duration>, D::Error> {
Ok(Option::<f64>::deserialize(deserializer)?
.and_then(|secs| Duration::try_from_secs_f64(secs).ok()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_names_the_provider_of_each_error() {
let auth = Error::auth("HTTP 401")
.with_provider("openai")
.with_upstream_message("Incorrect API key provided.");
assert_eq!(
auth.to_string(),
"openai: authentication error: HTTP 401: Incorrect API key provided."
);
assert_eq!(
Error::rate_limited("HTTP 429").to_string(),
"rate limited: HTTP 429"
);
let failover =
auth.with_fallback_from(Error::rate_limited("HTTP 429").with_provider("anthropic"));
assert_eq!(
failover.to_string(),
"openai: authentication error: HTTP 401: Incorrect API key provided."
);
}
#[test]
fn transport_errors_are_retryable_but_not_transient() {
let err = Error::transport("connection refused").with_provider("demo");
assert_eq!(err.kind_name(), "transport");
assert!(err.is_retryable());
assert!(!err.is_transient());
assert_eq!(err.to_string(), "demo: transport error: connection refused");
let round_trip: Error =
serde_json::from_value(serde_json::to_value(&err).unwrap()).unwrap();
assert!(matches!(round_trip, Error::Transport { .. }));
assert_eq!(
round_trip.context().unwrap().provider.as_deref(),
Some("demo")
);
}
#[test]
fn display_and_retryability() {
let cases = [
(Error::timeout("slow"), "timeout: slow", true),
(
Error::auth("bad key"),
"authentication error: bad key",
false,
),
(
Error::configuration("OPENAI_API_KEY not set"),
"configuration error: OPENAI_API_KEY not set",
false,
),
(Error::rate_limited("busy"), "rate limited: busy", true),
(
Error::quota_exhausted("no credit"),
"quota exhausted: no credit",
false,
),
(Error::overloaded("down"), "overloaded: down", true),
(Error::provider("reset"), "provider error: reset", true),
(
Error::provider("boom").with_status(500),
"provider error (500): boom",
true,
),
(
Error::provider("bad").with_status(400),
"provider error (400): bad",
false,
),
(Error::unsupported("x"), "unsupported: x", false),
(Error::invalid_request("x"), "invalid request: x", false),
(Error::model_not_found("x"), "model not found: x", false),
(
Error::unexpected_response("x"),
"unexpected response: x",
false,
),
(Error::content_filtered("x"), "content filtered: x", false),
(
Error::stream(StreamErrorKind::Incomplete, "cut"),
"stream error (incomplete): cut",
true,
),
(
Error::stream(StreamErrorKind::Transport, "reset"),
"stream error (transport): reset",
true,
),
(
Error::stream_decode("bad json"),
"stream error (decode): bad json",
false,
),
];
for (err, display, retryable) in cases {
assert_eq!(err.to_string(), display);
assert_eq!(err.is_retryable(), retryable, "{display}");
}
}
#[test]
fn server_errors_are_retryable_except_501_and_505() {
for (status, retryable) in [(500, true), (502, true), (501, false), (505, false)] {
let err = Error::provider(format!("HTTP {status}")).with_status(status);
assert_eq!(err.is_retryable(), retryable, "HTTP {status}");
}
}
#[test]
fn builders_fill_context() {
let err = Error::rate_limited("slow down")
.with_status(429)
.with_request_id("req_1")
.with_retry_after(Duration::from_secs(2))
.with_provider_code("rate_limit_exceeded")
.with_provider("openai");
assert_eq!(err.status(), Some(429));
assert_eq!(err.request_id(), Some("req_1"));
assert_eq!(err.retry_after(), Some(Duration::from_secs(2)));
assert_eq!(
err.context().unwrap().provider_code.as_deref(),
Some("rate_limit_exceeded")
);
}
#[test]
fn server_retry_directive_is_context() {
let err = Error::rate_limited("HTTP 429").with_server_retry_directive(false);
assert_eq!(err.server_retry_directive(), Some(false));
let err = err.with_server_retry_directive_opt(None);
assert_eq!(err.server_retry_directive(), Some(false));
assert!(err.is_retryable());
assert_eq!(Error::provider("x").server_retry_directive(), None);
let round_trip: Error =
serde_json::from_str(&serde_json::to_string(&err).unwrap()).unwrap();
assert_eq!(round_trip.server_retry_directive(), Some(false));
let log = serde_json::to_value(err.as_log()).unwrap();
assert_eq!(log["server_retry_directive"], false);
assert!(format!("{:?}", err.context().unwrap()).contains("server_retry_directive"));
let old: Error =
serde_json::from_str(r#"{"type":"rate_limited","data":{"message":"m"}}"#).unwrap();
assert_eq!(old.server_retry_directive(), None);
}
fn tried(error: &Error) -> Vec<&'static str> {
std::iter::successors(error.fallback_from(), |e| e.fallback_from())
.map(Error::kind_name)
.collect()
}
#[test]
fn an_error_that_ended_a_failover_is_itself_and_logs_the_chain() {
let err = Error::rate_limited("fallback")
.with_request_id("r")
.with_fallback_from(Error::timeout("primary").with_provider("a"));
assert!(matches!(err, Error::RateLimited { .. }));
assert_eq!(err.kind_name(), "rate_limited");
assert!(err.is_retryable());
assert_eq!(err.request_id(), Some("r"));
assert_eq!(tried(&err), ["timeout"]);
let log = serde_json::to_value(err.as_log()).unwrap();
assert_eq!(log["type"], "rate_limited");
assert_eq!(log["request_id"], "r");
assert_eq!(log["fallback_from"]["type"], "timeout");
assert_eq!(log["fallback_from"]["provider"], "a");
assert!(log["fallback_from"].get("fallback_from").is_none());
}
#[test]
fn with_fallback_from_attaches_at_the_tail() {
let last = || {
Error::invalid_request("HTTP 400")
.with_status(400)
.with_param("messages.[1].content")
.with_provider_code("invalid_value")
};
assert_eq!(last().fallback_from().map(Error::kind_name), None);
let chain = last()
.with_fallback_from(Error::rate_limited("c"))
.with_fallback_from(Error::overloaded("b").with_fallback_from(Error::timeout("a")));
assert_eq!(chain.kind_name(), "invalid_request");
assert_eq!(tried(&chain), ["rate_limited", "overloaded", "timeout"]);
assert_eq!(chain.param(), Some("messages.[1].content"));
assert_eq!(chain.provider_code(), Some("invalid_value"));
assert_eq!(chain.status(), Some(400));
assert!(!chain.is_retryable());
let shared = chain.clone();
let longer = shared.with_fallback_from(Error::auth("first"));
assert_eq!(tried(&chain), ["rate_limited", "overloaded", "timeout"]);
assert_eq!(
tried(&longer),
["rate_limited", "overloaded", "timeout", "auth"]
);
let plain = Error::serialization("bad json").with_fallback_from(Error::timeout("a"));
assert_eq!(plain.param(), None);
assert_eq!(plain.provider_code(), None);
assert!(plain.fallback_from().is_none());
let ends = Error::timeout("b")
.with_fallback_from(Error::serialization("bad json"))
.with_fallback_from(Error::overloaded("a"));
assert_eq!(tried(&ends), ["serialization"]);
}
#[test]
fn the_chain_is_not_compared_and_merges_only_when_set() {
let plain = Error::rate_limited("HTTP 429");
let chained = plain.clone().with_fallback_from(Error::timeout("a"));
assert_eq!(plain.context(), chained.context());
assert!(!chained.context().unwrap().is_empty());
let kept = chained.clone().with_context(ErrorContext::new());
assert_eq!(tried(&kept), ["timeout"]);
let other = Error::overloaded("x").with_fallback_from(Error::auth("b"));
let replaced = chained.with_context(other.context().unwrap().clone());
assert_eq!(tried(&replaced), ["auth"]);
}
#[test]
fn the_chain_survives_serde_under_fallback_from() {
let err = Error::rate_limited("HTTP 429")
.with_status(429)
.with_fallback_from(
Error::overloaded("HTTP 529")
.with_provider("anthropic")
.with_fallback_from(Error::timeout("slow")),
);
let json = serde_json::to_value(&err).unwrap();
assert_eq!(
json,
serde_json::json!({
"type": "rate_limited",
"data": {
"message": "HTTP 429",
"context": {
"status": 429,
"fallback_from": {
"type": "overloaded",
"data": {
"message": "HTTP 529",
"context": {
"provider": "anthropic",
"fallback_from": {
"type": "timeout",
"data": {"message": "slow"}
}
}
}
}
}
}
})
);
let back: Error = serde_json::from_value(json.clone()).unwrap();
assert!(matches!(back, Error::RateLimited { .. }));
assert_eq!(tried(&back), ["overloaded", "timeout"]);
assert_eq!(
back.fallback_from()
.unwrap()
.context()
.unwrap()
.provider
.as_deref(),
Some("anthropic")
);
assert_eq!(serde_json::to_value(&back).unwrap(), json);
let only_chain = Error::timeout("b").with_fallback_from(Error::timeout("a"));
let json = serde_json::to_value(&only_chain).unwrap();
assert_eq!(json["data"]["context"]["fallback_from"]["type"], "timeout");
}
#[test]
fn with_context_merges_and_never_touches_the_message_origin() {
let mut context = ErrorContext::new();
context.status = Some(429);
context.request_id = Some("req_new".into());
context
.metadata
.insert("b".into(), serde_json::json!("new"));
context.metadata.insert("c".into(), serde_json::json!(3));
context.message_origin = MessageOrigin::Authored;
let mut existing = ErrorContext::new();
existing.provider = Some("acme".into());
existing.request_id = Some("req_old".into());
existing.upstream_message = Some("slow down".into());
existing.metadata.insert("a".into(), serde_json::json!(1));
existing
.metadata
.insert("b".into(), serde_json::json!("old"));
let mut err = Error::rate_limited("HTTP 429");
*err.context_mut().unwrap() = existing;
let merged = err.with_context(context.clone());
let c = merged.context().unwrap();
assert_eq!(c.provider.as_deref(), Some("acme"));
assert_eq!(c.upstream_message.as_deref(), Some("slow down"));
assert_eq!(c.status, Some(429));
assert_eq!(c.request_id.as_deref(), Some("req_new"));
assert_eq!(c.metadata["a"], serde_json::json!(1));
assert_eq!(c.metadata["b"], serde_json::json!("new"));
assert_eq!(c.metadata["c"], serde_json::json!(3));
assert_eq!(merged.message_origin(), MessageOrigin::Unknown);
let kept = Error::rate_limited("HTTP 429")
.authored()
.with_context(ErrorContext::new());
assert_eq!(kept.message_origin(), MessageOrigin::Authored);
let before = Error::rate_limited("HTTP 429")
.with_provider("acme")
.authored()
.with_context(context.clone());
let after = Error::rate_limited("HTTP 429")
.with_context(context)
.with_provider("acme")
.authored();
assert_eq!(before.context(), after.context());
}
#[test]
fn context_mut_changes_the_error_itself_not_the_chain() {
let mut chain = Error::rate_limited("c")
.with_fallback_from(Error::overloaded("b"))
.with_fallback_from(Error::timeout("a"));
chain.context_mut().unwrap().provider_code = Some("rate_limit_exceeded".into());
assert_eq!(chain.provider_code(), Some("rate_limit_exceeded"));
assert_eq!(chain.fallback_from().unwrap().provider_code(), None);
*chain.context_mut().unwrap() = ErrorContext::new();
assert!(chain.fallback_from().is_none());
}
#[test]
fn log_redacts_body() {
let err = Error::provider("bad").with_status(400).with_body("secret");
let log = serde_json::to_value(err.as_log()).unwrap();
assert_eq!(log["body_present"], true);
assert_eq!(log["body_len"], 6);
assert!(log.get("body").is_none());
}
#[test]
fn log_display_is_one_redacted_line() {
let err = Error::provider("bad\nrequest")
.authored()
.with_provider("openai-compat:groq")
.with_status(400)
.with_provider_code("invalid_value")
.with_param("messages.[1].content")
.with_request_id("req 1")
.with_body("secret prompt");
let line = err.as_log().to_string();
assert_eq!(
line,
r#"kind=provider provider=openai-compat:groq status=400 provider_code=invalid_value param=messages.[1].content request_id="req 1" body_len=13 message="bad\nrequest""#
);
assert!(!line.contains("secret"));
assert!(!line.contains('\n'));
assert_eq!(
Error::configuration("OPENAI_API_KEY not set")
.authored()
.as_log()
.to_string(),
r#"kind=configuration message="OPENAI_API_KEY not set""#
);
}
#[test]
fn log_omits_messages_of_unknown_origin() {
let err = Error::invalid_request("echoed SENTINEL")
.with_status(400)
.with_body("SENTINEL");
assert_eq!(err.message_origin(), MessageOrigin::Unknown);
let line = err.as_log().with_message(100).to_string();
assert_eq!(line, "kind=invalid_request status=400 body_len=8");
let json = serde_json::to_value(err.as_log().with_message(100)).unwrap();
assert!(json.get("message").is_none(), "{json}");
assert!(!json.to_string().contains("SENTINEL"), "{json}");
}
#[test]
fn upstream_message_is_opt_in_and_truncated_at_a_char_boundary() {
let err = Error::invalid_request("HTTP 400")
.authored()
.with_status(400)
.with_upstream_message("héllo wörld");
assert_eq!(err.upstream_message(), Some("héllo wörld"));
let redacted = err.as_log().to_string();
assert!(!redacted.contains("héllo"), "{redacted}");
assert!(
serde_json::to_value(err.as_log())
.unwrap()
.get("upstream_message")
.is_none()
);
assert_eq!(
err.as_log().with_message(2).to_string(),
r#"kind=invalid_request status=400 message="HTTP 400" upstream_message="hé""#
);
let json = serde_json::to_value(err.as_log().with_message(100)).unwrap();
assert_eq!(json["upstream_message"], "héllo wörld");
assert_eq!(json["message"], "HTTP 400");
}
#[test]
fn display_is_not_redacted() {
let err = Error::invalid_request("HTTP 400")
.authored()
.with_upstream_message("bad value SENTINEL");
assert_eq!(
err.to_string(),
"invalid request: HTTP 400: bad value SENTINEL"
);
assert_eq!(err.message(), "HTTP 400");
let err = Error::timeout("slow").with_upstream_message(" ");
assert_eq!(err.upstream_message(), None);
assert_eq!(err.to_string(), "timeout: slow");
let failover = Error::rate_limited("f")
.with_upstream_message("slow down")
.with_fallback_from(Error::overloaded("p").with_upstream_message("busy"));
assert_eq!(failover.to_string(), "rate limited: f: slow down");
}
#[test]
fn provenance_round_trips_and_unknown_is_not_serialized() {
let unknown = serde_json::to_value(Error::timeout("x").with_status(408)).unwrap();
assert_eq!(
unknown,
serde_json::json!({"type": "timeout", "data": {"message": "x", "context": {"status": 408}}})
);
let err = Error::invalid_request("HTTP 400")
.authored()
.with_param("tools[0].function.parameters")
.with_upstream_message("bad schema");
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["data"]["context"]["message_origin"], "authored");
let back: Error = serde_json::from_value(json).unwrap();
assert_eq!(back.message_origin(), MessageOrigin::Authored);
assert_eq!(back.upstream_message(), Some("bad schema"));
assert_eq!(
back.context().unwrap().param.as_deref(),
Some("tools[0].function.parameters")
);
}
#[test]
fn body_is_capped_in_with_body_serialization_and_debug() {
let long = format!(
"{}é{}",
"a".repeat(ErrorContext::MAX_BODY_BYTES - 1),
"b".repeat(100)
);
let err = Error::provider("x").with_body(long.clone());
let body = err.context().unwrap().body.as_deref().unwrap();
assert_eq!(body.len(), ErrorContext::MAX_BODY_BYTES - 1);
assert!(body.chars().all(|c| c == 'a'));
assert_eq!(
serde_json::to_value(err.as_log()).unwrap()["body_len"],
ErrorContext::MAX_BODY_BYTES - 1
);
let mut context = ErrorContext::new();
context.body = Some(long.clone());
assert_eq!(
Error::provider("x")
.with_context(context.clone())
.context()
.unwrap()
.body
.as_deref()
.unwrap()
.len(),
ErrorContext::MAX_BODY_BYTES - 1
);
let mut direct = Error::provider("x");
direct.context_mut().unwrap().body = Some(long.clone());
let json = serde_json::to_value(&direct).unwrap();
assert_eq!(
json["data"]["context"]["body"].as_str().unwrap().len(),
ErrorContext::MAX_BODY_BYTES - 1
);
let debug = format!("{direct:?}");
assert!(!debug.contains("aaaa"), "{debug}");
assert!(
debug.contains(&format!("body_len: {}", ErrorContext::MAX_BODY_BYTES - 1)),
"{debug}"
);
}
#[test]
fn debug_shows_the_body_length_only() {
let err = Error::provider("x").with_body("my SSN is 123-45-6789");
let debug = format!("{err:?}");
assert!(!debug.contains("123-45-6789"), "{debug}");
assert!(debug.contains("body_len: 21"), "{debug}");
}
#[test]
fn debug_shows_the_upstream_message_length_only() {
let err = Error::invalid_request("HTTP 400")
.authored()
.with_upstream_message("my SSN is 123-45-6789");
let debug = format!("{err:?}");
assert!(!debug.contains("123-45-6789"), "{debug}");
assert!(
debug.contains("upstream_message: <withheld 21 bytes; print with {err}>"),
"{debug}"
);
let none = format!("{:?}", Error::invalid_request("HTTP 400"));
assert!(!none.contains("upstream_message"), "{none}");
assert!(err.to_string().contains("123-45-6789"));
}
#[test]
fn debug_is_compact_and_omits_unset_fields() {
let err = Error::invalid_request("HTTP 400")
.with_provider("openai")
.with_status(400)
.with_provider_code("invalid_value")
.with_param("messages.[1].content")
.with_request_id("req_1")
.with_upstream_message("my SSN is 123-45-6789");
assert_eq!(
format!("{err:?}"),
r#"InvalidRequest { message: "HTTP 400", provider: "openai", status: 400, provider_code: "invalid_value", param: "messages.[1].content", request_id: "req_1", upstream_message: <withheld 21 bytes; print with {err}> }"#
);
assert_eq!(
format!("{:?}", Error::timeout("deadline")),
r#"Timeout { message: "deadline" }"#
);
assert_eq!(format!("{:?}", ErrorContext::new()), "ErrorContext");
let retry = Error::rate_limited("HTTP 429")
.authored()
.with_retry_after(Duration::from_millis(1500))
.with_server_retry_directive(true);
assert_eq!(
format!("{retry:?}"),
r#"RateLimited { message: "HTTP 429", retry_after: 1.5s, server_retry_directive: true, message_origin: Authored }"#
);
assert_eq!(
format!("{:?}", retry.context().unwrap()),
"ErrorContext { retry_after: 1.5s, server_retry_directive: true, message_origin: Authored }"
);
}
#[test]
fn debug_shows_variant_fields_and_metadata_keys() {
let unsupported = Error::unsupported_by_provider(
Some(Subject::Field {
field: RequestField::Seed,
}),
"no seed",
);
assert_eq!(
format!("{unsupported:?}"),
r#"Unsupported { message: "no seed", subject: Field { field: Seed }, stage: Provider }"#
);
assert_eq!(
format!("{:?}", Error::unsupported("no seed")),
r#"Unsupported { message: "no seed" }"#
);
assert_eq!(
format!(
"{:?}",
Error::context_length_exceeded("too long", Some(8192))
),
r#"ContextLengthExceeded { message: "too long", context_window: 8192 }"#
);
assert_eq!(
format!("{:?}", Error::stream_protocol("bad order")),
r#"Stream { message: "bad order", kind: Protocol }"#
);
let mut err = Error::stream(StreamErrorKind::Incomplete, "cut off");
err.context_mut()
.unwrap()
.metadata
.insert("vendor.position".into(), serde_json::json!("SENTINEL"));
let debug = format!("{err:?}");
assert!(
debug.contains(r#"metadata: ["vendor.position"]"#),
"{debug}"
);
assert!(!debug.contains("SENTINEL"), "{debug}");
}
#[test]
fn pretty_debug_puts_each_field_on_its_own_line() {
let err = Error::provider("HTTP 500")
.with_status(500)
.with_upstream_message("SENTINEL");
assert_eq!(
format!("{err:#?}"),
"Provider {\n message: \"HTTP 500\",\n status: 500,\n \
upstream_message: <withheld 8 bytes; print with {err}>,\n}"
);
}
#[test]
fn debug_never_shows_upstream_text_through_a_fallback() {
let leaf = |message: &str| {
Error::overloaded("HTTP 529")
.with_upstream_message(format!("UPSTREAM {message}"))
.with_body(format!("BODY {message}"))
};
let err = leaf("three")
.with_fallback_from(leaf("two"))
.with_fallback_from(leaf("one"))
.with_fallback_from(Error::serialization(
"invalid type: string \"SERDE\", expected u64",
));
for debug in [
format!("{err:?}"),
format!("{err:#?}"),
format!("{:?}", err.fallback_from().unwrap()),
format!("{:?}", err.context().unwrap()),
] {
for secret in ["UPSTREAM", "BODY", "SERDE"] {
assert!(!debug.contains(secret), "{secret}: {debug}");
}
}
let debug = format!("{err:?}");
assert!(
debug.starts_with(
"Overloaded { message: \"HTTP 529\", upstream_message: <withheld 14 bytes; \
print with {err}>, body_len: 10, fallback_from: Overloaded {"
),
"{debug}"
);
assert!(
debug.contains("Serialization { error: <withheld 42 bytes; print with {err}> }"),
"{debug}"
);
assert!(err.to_string().contains("UPSTREAM three"));
assert!(!err.to_string().contains("UPSTREAM two"));
}
#[test]
fn log_shows_codes_types_and_params_only_as_bounded_tokens() {
let tokens = Error::invalid_request("HTTP 400")
.authored()
.with_provider_code("context_length_exceeded")
.with_provider_type("invalid_request_error")
.with_param("tools[0].function.parameters");
assert_eq!(
tokens.as_log().to_string(),
"kind=invalid_request provider_code=context_length_exceeded \
provider_type=invalid_request_error param=tools[0].function.parameters \
message=\"HTTP 400\""
);
for value in [
"my SSN is 123-45-6789",
"<b>bad</b>",
"a,b",
"",
&"a".repeat(129),
] {
let err = Error::invalid_request("HTTP 400")
.authored()
.with_provider_code(value.to_owned())
.with_provider_type(value.to_owned())
.with_param(value.to_owned());
assert_eq!(
err.as_log().to_string(),
"kind=invalid_request provider_code=<redacted> provider_type=<redacted> \
param=<redacted> message=\"HTTP 400\"",
"{value:?}"
);
let json = serde_json::to_value(err.as_log()).unwrap();
for key in ["provider_code", "provider_type", "param"] {
assert_eq!(json[key], REDACTED, "{value:?}");
}
let debug = format!("{err:?}");
assert!(debug.contains(r#"provider_type: "<redacted>""#), "{debug}");
if !value.is_empty() {
assert!(!debug.contains(value), "{debug}");
}
}
let long = "a".repeat(128);
let err = Error::provider("x").with_provider_code(long.clone());
assert!(err.as_log().to_string().contains(&long));
}
#[test]
fn log_cannot_tell_a_code_from_a_value_shaped_like_one() {
for echoed in ["123-45-6789", "alice", "user_42:secret"] {
let err = Error::invalid_request("HTTP 400")
.authored()
.with_param(echoed);
assert!(err.as_log().to_string().contains(echoed), "{echoed}");
}
}
#[test]
fn log_renders_the_fallback_chain_as_nested_redacted_logs() {
let err = Error::rate_limited("f")
.with_status(429)
.with_upstream_message("upstream f")
.with_fallback_from(
Error::overloaded("q")
.authored()
.with_body("BODY q")
.with_upstream_message("upstream q"),
)
.with_fallback_from(
Error::timeout("p")
.authored()
.with_provider("a")
.with_upstream_message("upstream p"),
);
assert_eq!(
err.as_log().to_string(),
r#"kind=rate_limited status=429 fallback_from=[kind=overloaded body_len=6 message="q" fallback_from=[kind=timeout provider=a message="p"]]"#
);
assert_eq!(
err.as_log().with_message(8).to_string(),
r#"kind=rate_limited status=429 upstream_message="upstream" fallback_from=[kind=overloaded body_len=6 message="q" upstream_message="upstream" fallback_from=[kind=timeout provider=a message="p" upstream_message="upstream"]]"#
);
let json = serde_json::to_value(err.as_log()).unwrap();
assert_eq!(json["type"], "rate_limited");
assert!(json.get("message").is_none(), "{json}");
assert!(json.get("upstream_message").is_none(), "{json}");
let q = &json["fallback_from"];
assert_eq!(q["type"], "overloaded");
assert_eq!(q["message"], "q");
assert_eq!(q["body_len"], 6);
assert!(q.get("body").is_none(), "{json}");
assert!(q.get("upstream_message").is_none(), "{json}");
assert_eq!(q["fallback_from"]["type"], "timeout");
assert_eq!(q["fallback_from"]["message"], "p");
assert_eq!(q["fallback_from"]["provider"], "a");
assert!(!json.to_string().contains("BODY"), "{json}");
}
#[test]
fn serialization_messages_are_not_logged() {
let err = Error::serialization("invalid type: string \"SENTINEL\", expected u64");
assert_eq!(err.as_log().to_string(), "kind=serialization");
}
#[cfg(feature = "extract")]
#[test]
fn extraction_logs_omit_parse_detail() {
use crate::extract::{ExtractError, ExtractFailure, ExtractionMetadata, ExtractionMode};
let parse = ExtractError::Parse {
mode: ExtractionMode::Native,
provider: "demo".into(),
raw: "{\"ssn\": \"SENTINEL\"}".into(),
message: "\"SENTINEL\" is not of type integer".into(),
path: Some("SENTINEL_KEY".into()),
};
let metadata = ExtractionMetadata::new(ExtractionMode::Native);
let err = Error::extract(ExtractFailure::new(parse, metadata));
assert_eq!(
err.as_log().to_string(),
r#"kind=extract message="demo: failed to parse the output using mode Native""#
);
assert!(err.to_string().contains("SENTINEL"));
assert!(err.to_string().contains("SENTINEL_KEY"));
}
#[cfg(feature = "extract")]
#[test]
fn an_extraction_error_chain_prints_the_failure_once() {
use crate::extract::{ExtractError, ExtractFailure, ExtractionMetadata, ExtractionMode};
let failure = ExtractError::MissingToolCall {
provider: "demo".into(),
};
let metadata = ExtractionMetadata::new(ExtractionMode::ForcedTool);
let err = Error::extract(ExtractFailure::new(failure, metadata));
assert_eq!(
chain(&err),
[
"extraction error: demo: the model did not call the extraction tool 'submit_structured_output'"
]
);
}
#[cfg(feature = "extract")]
#[test]
fn extraction_debug_never_shows_the_models_text() {
use crate::extract::{
ExtractError, ExtractFailure, ExtractionMetadata, ExtractionMode, RefusalSource,
};
use crate::{ChatResponse, ContentBlock, FinishReason};
let response = ChatResponse::new(vec![ContentBlock::text("RESPONSE {\"ssn\": 1}")]);
let parse = ExtractError::Parse {
mode: ExtractionMode::Native,
provider: "demo".into(),
raw: "{\"ssn\": \"SENTINEL\"}".into(),
message: "\"SENTINEL\" is not of type integer".into(),
path: Some("SENTINEL_KEY".into()),
};
let refused = ExtractError::Refused {
mode: ExtractionMode::Native,
provider: "demo".into(),
finish_reason: Some(FinishReason::Refusal),
text: Some("I won't share SENTINEL".into()),
source: RefusalSource::Model,
};
let metadata = ExtractionMetadata::new(ExtractionMode::Native);
let parse_err =
Error::extract(ExtractFailure::new(parse, metadata.clone()).with_response(response));
let refused_err = Error::extract(ExtractFailure::new(refused, metadata));
let nested = Error::timeout("t").with_fallback_from(parse_err.clone());
for err in [&parse_err, &refused_err, &nested] {
for debug in [format!("{err:?}"), format!("{err:#?}")] {
for secret in ["SENTINEL", "RESPONSE"] {
assert!(!debug.contains(secret), "{secret}: {debug}");
}
}
}
assert_eq!(
format!("{parse_err:?}"),
"Extract { failure: ExtractFailure { error: Parse { mode: Native, \
provider: \"demo\", raw: <withheld 19 bytes>, message: <withheld 33 \
bytes; print with {err}>, path: <withheld 12 bytes; print with {err}> }, \
response: <withheld>, metadata: ExtractionMetadata { mode: Native, \
passes: 0, enforcement: NotEnforced } } }"
);
let debug = format!("{refused_err:?}");
assert!(
debug.contains(
"Refused { mode: Native, provider: \"demo\", finish_reason: Refusal, \
text: <withheld 22 bytes; print with {err}>, source: Model }"
),
"{debug}"
);
assert!(!debug.contains("response"), "{debug}");
}
#[cfg(feature = "extract")]
#[test]
fn extraction_failures_serialize_under_failure() {
use crate::extract::{ExtractError, ExtractFailure, ExtractionMetadata, ExtractionMode};
let failure = ExtractFailure::new(
ExtractError::ToolConflict {
provider: "demo".into(),
},
ExtractionMetadata::new(ExtractionMode::ForcedTool),
);
let json = serde_json::to_value(Error::extract(failure)).unwrap();
assert_eq!(json["type"], "extract");
assert!(json["data"]["failure"].is_object(), "{json}");
assert!(json["data"].get("error").is_none(), "{json}");
let back: Error = serde_json::from_value(json).unwrap();
let Error::Extract { failure, .. } = back else {
panic!("an extraction failure expected: {back:?}");
};
assert!(matches!(failure.error(), ExtractError::ToolConflict { .. }));
}
#[test]
fn message_prefix_keeps_kind_context_and_origin() {
let context = |err: Error| {
err.with_provider("demo")
.with_status(503)
.with_request_id("req_1")
.with_retry_after(Duration::from_secs(3))
.with_upstream_message("upstream")
};
let errors = [
Error::timeout("m"),
Error::transport("m"),
Error::auth("m"),
Error::configuration("m"),
Error::rate_limited("m"),
Error::quota_exhausted("m"),
Error::overloaded("m"),
Error::provider("m"),
Error::unsupported("m"),
Error::invalid_request("m"),
Error::model_not_found("m"),
Error::unexpected_response("m"),
Error::context_length_exceeded("m", Some(8)),
Error::content_filtered("m"),
Error::stream(StreamErrorKind::Incomplete, "m"),
];
for err in errors {
for authored in [false, true] {
let err = context(if authored {
err.clone().authored()
} else {
err.clone()
});
let prefixed = err.clone().with_message_prefix("step");
assert_eq!(prefixed.kind_name(), err.kind_name());
assert_eq!(prefixed.message(), "step: m");
assert_eq!(prefixed.message_origin(), err.message_origin());
assert_eq!(prefixed.is_retryable(), err.is_retryable());
assert_eq!(prefixed.upstream_message(), Some("upstream"));
assert_eq!(prefixed.context(), err.context());
let log = prefixed.as_log().to_string();
assert_eq!(log.contains("step: m"), authored, "{log}");
}
}
}
#[test]
fn message_prefix_goes_on_the_error_not_its_fallback_chain() {
let err = Error::rate_limited("HTTP 429")
.authored()
.with_status(429)
.with_fallback_from(Error::overloaded("q"))
.with_fallback_from(Error::timeout("p").authored())
.with_message_prefix("step");
assert!(matches!(err, Error::RateLimited { .. }));
assert_eq!(err.message(), "step: HTTP 429");
assert_eq!(err.status(), Some(429));
let primary = err.fallback_from().and_then(Error::fallback_from).unwrap();
assert_eq!(primary.message(), "p");
assert_eq!(
err.as_log().to_string(),
r#"kind=rate_limited status=429 message="step: HTTP 429" fallback_from=[kind=overloaded fallback_from=[kind=timeout message="p"]]"#
);
}
#[test]
fn message_prefix_on_serialization_keeps_the_failure() {
let source = serde_json::from_str::<u64>("\"x\"").unwrap_err();
let source_text = source.to_string();
let err = Error::from(source).with_message_prefix("decode option");
assert!(matches!(err, Error::Serialization { .. }));
assert_eq!(err.message(), format!("decode option: {source_text}"));
assert_eq!(
err.to_string(),
format!("serialization error: decode option: {source_text}")
);
assert_eq!(err.as_log().to_string(), "kind=serialization");
assert!(std::error::Error::source(&err).is_none());
let Error::Serialization { error } = &err else {
unreachable!()
};
assert!(
error
.get_ref()
.downcast_ref::<serde_json::Error>()
.is_some()
);
let back: Error = serde_json::from_value(serde_json::to_value(&err).unwrap()).unwrap();
assert_eq!(back.message(), err.message());
}
#[cfg(feature = "extract")]
#[test]
fn message_prefix_leaves_extract_unchanged() {
use crate::extract::{ExtractError, ExtractFailure, ExtractionMetadata, ExtractionMode};
let failure = ExtractFailure::new(
ExtractError::ToolConflict {
provider: "demo".into(),
},
ExtractionMetadata::new(ExtractionMode::Native),
);
let err = Error::extract(failure);
let prefixed = err.clone().with_message_prefix("step");
assert!(matches!(prefixed, Error::Extract { .. }));
assert_eq!(prefixed.to_string(), err.to_string());
}
#[test]
fn configuration_errors_are_terminal_and_not_auth() {
let err = Error::configuration("ANTHROPIC_API_KEY not set").with_provider("anthropic");
assert_eq!(err.kind_name(), "configuration");
assert!(!err.is_retryable());
assert!(!err.is_transient());
assert!(!err.is_auth());
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["type"], "configuration");
let back: Error = serde_json::from_value(json).unwrap();
assert!(matches!(back, Error::Configuration { .. }));
assert_eq!(
back.context().unwrap().provider.as_deref(),
Some("anthropic")
);
let log = serde_json::to_value(err.as_log()).unwrap();
assert_eq!(log["type"], "configuration");
assert_eq!(log["provider"], "anthropic");
}
#[test]
fn serde_round_trip() {
let err = Error::context_length_exceeded("too long", Some(128_000)).with_request_id("r");
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["data"]["context_window"], 128_000, "{json}");
let log = serde_json::to_value(err.as_log()).unwrap();
assert_eq!(log["context_window"], 128_000, "{log}");
let back: Error = serde_json::from_value(json).unwrap();
assert!(matches!(
back,
Error::ContextLengthExceeded {
context_window: Some(128_000),
..
}
));
assert_eq!(back.request_id(), Some("r"));
}
#[test]
fn deserializing_a_huge_retry_after_never_panics() {
for value in ["1e20", "1.9e19", "-1", "1e308"] {
let json = format!(
r#"{{"type":"rate_limited","data":{{"message":"x","context":{{"retry_after":{value}}}}}}}"#
);
let err: Error = serde_json::from_str(&json).unwrap();
assert_eq!(err.retry_after(), None, "{value}");
}
let err: Error = serde_json::from_str(
r#"{"type":"rate_limited","data":{"message":"x","context":{"retry_after":1.5}}}"#,
)
.unwrap();
assert_eq!(err.retry_after(), Some(Duration::from_millis(1500)));
}
fn chain(error: &(dyn std::error::Error + 'static)) -> Vec<String> {
let mut lines = vec![error.to_string()];
let mut source = error.source();
while let Some(cause) = source {
lines.push(cause.to_string());
source = cause.source();
}
lines
}
#[test]
fn report_chains_print_each_error_once() {
let fallback = Error::rate_limited("HTTP 429")
.with_provider("openai")
.with_fallback_from(Error::overloaded("HTTP 529").with_provider("anthropic"));
assert_eq!(chain(&fallback), ["openai: rate limited: HTTP 429"]);
let json_failure = serde_json::from_str::<u8>("x").unwrap_err();
let serialization = Error::from(json_failure).with_message_prefix("tool arguments");
let lines = chain(&serialization);
assert_eq!(lines.len(), 1, "{lines:?}");
assert!(lines[0].starts_with("serialization error: tool arguments: expected value"));
let Error::Serialization { error } = &serialization else {
unreachable!()
};
assert!(
error
.get_ref()
.downcast_ref::<serde_json::Error>()
.is_some()
);
}
#[derive(Debug)]
struct Cause(&'static str);
impl fmt::Display for Cause {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for Cause {}
#[test]
fn an_attached_source_is_the_source_and_nothing_else() {
use std::error::Error as _;
let err = Error::transport("the request got no response")
.authored()
.with_source(Cause("dns: no such host"));
let cause = err.source().unwrap();
assert_eq!(
cause.downcast_ref::<Cause>().unwrap().0,
"dns: no such host"
);
assert_eq!(
chain(&err),
[
"transport error: the request got no response",
"dns: no such host"
]
);
assert!(!format!("{err:?}").contains("dns"));
assert!(!err.as_log().to_string().contains("dns"));
assert!(!serde_json::to_string(&err).unwrap().contains("dns"));
let clone = err.clone();
assert!(clone.source().is_some());
let merged = Error::transport("x").with_context(clone.context().unwrap().clone());
assert!(merged.source().is_some());
assert_eq!(
clone.context().unwrap(),
Error::transport("x").authored().context().unwrap()
);
let fallback = err.clone().with_fallback_from(Error::timeout("p"));
assert!(fallback.source().unwrap().downcast_ref::<Cause>().is_some());
let before = Error::timeout("p").with_fallback_from(err);
assert!(before.source().is_none());
}
#[test]
fn rejected_is_a_local_refusal() {
let err = Error::rejected("tenant budget exhausted")
.authored()
.with_provider_code("tenant_budget_exhausted");
assert_eq!(err.kind_name(), "rejected");
assert!(!err.is_retryable());
assert!(!err.is_transient());
assert_eq!(err.to_string(), "rejected: tenant budget exhausted");
assert_eq!(
err.as_log().to_string(),
r#"kind=rejected provider_code=tenant_budget_exhausted message="tenant budget exhausted""#
);
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["type"], "rejected");
let back: Error = serde_json::from_value(json).unwrap();
assert!(matches!(back, Error::Rejected { .. }));
assert_eq!(back.provider_code(), Some("tenant_budget_exhausted"));
}
#[test]
fn context_builders_take_values_and_opt_twins_take_options() {
let err = Error::rate_limited("HTTP 429")
.with_retry_after(Duration::from_secs(30))
.with_request_id("req_1")
.with_server_retry_directive(true)
.with_provider_code("rate_limit_exceeded")
.with_provider_type("requests")
.with_param("model")
.with_upstream_message("slow down")
.with_body("{}");
let kept = err
.clone()
.with_retry_after_opt(None)
.with_request_id_opt(None)
.with_server_retry_directive_opt(None)
.with_provider_code_opt(None)
.with_provider_type_opt(None)
.with_param_opt(None)
.with_upstream_message_opt(Some(" "))
.with_upstream_message("")
.with_body_opt(None);
assert_eq!(kept.context(), err.context());
let header: Option<&str> = Some("req_2");
assert_eq!(err.with_request_id_opt(header).request_id(), Some("req_2"));
}
}