1use std::fmt;
29use std::time::Duration;
30
31use serde::{Deserialize, Serialize};
32
33use crate::capabilities::CapabilityMismatch;
34use crate::ids::{ModelKey, ModelRef, ProviderKey};
35
36pub const MAX_ERROR_CODE_LEN: usize = 64;
38
39pub const MAX_ERROR_DETAIL_LEN: usize = 512;
41
42#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
64#[serde(transparent)]
65pub struct ProviderDetail(String);
66
67impl ProviderDetail {
68 #[must_use]
80 pub fn new(raw: impl AsRef<str>) -> Self {
81 let mut out = String::with_capacity(MAX_ERROR_DETAIL_LEN);
82 let mut spaced = false;
83 for ch in raw.as_ref().chars() {
84 if out.len() + ch.len_utf8() > MAX_ERROR_DETAIL_LEN {
85 break;
86 }
87 if ch.is_whitespace() {
88 if !out.is_empty() && !spaced {
89 out.push(' ');
90 spaced = true;
91 }
92 continue;
93 }
94 if ch.is_control() {
95 continue;
96 }
97 out.push(ch);
98 spaced = false;
99 }
100 Self(out.trim_end().to_owned())
101 }
102
103 #[must_use]
105 pub fn as_str(&self) -> &str {
106 &self.0
107 }
108
109 #[must_use]
111 pub fn is_empty(&self) -> bool {
112 self.0.is_empty()
113 }
114}
115
116impl fmt::Display for ProviderDetail {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 f.write_str(&self.0)
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
129#[serde(transparent)]
130pub struct ErrorCode(String);
131
132impl ErrorCode {
133 #[must_use]
143 pub fn new(raw: impl AsRef<str>) -> Self {
144 let mut out = String::with_capacity(MAX_ERROR_CODE_LEN);
145 for ch in raw.as_ref().chars() {
146 if out.len() >= MAX_ERROR_CODE_LEN {
147 break;
148 }
149 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | ':' | '-') {
150 out.push(ch);
151 } else {
152 out.push('_');
153 }
154 }
155 Self(out)
156 }
157
158 #[must_use]
160 pub fn as_str(&self) -> &str {
161 &self.0
162 }
163}
164
165impl fmt::Display for ErrorCode {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 f.write_str(&self.0)
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum RetryClass {
175 Retry,
177 RetryAfter,
180 Fallback,
182 Fatal,
184}
185
186impl RetryClass {
187 pub const ALL: [Self; 4] = [Self::Retry, Self::RetryAfter, Self::Fallback, Self::Fatal];
189
190 #[must_use]
192 pub const fn as_str(self) -> &'static str {
193 match self {
194 Self::Retry => "retry",
195 Self::RetryAfter => "retry_after",
196 Self::Fallback => "fallback",
197 Self::Fatal => "fatal",
198 }
199 }
200
201 #[must_use]
203 pub const fn allows_same_provider(self) -> bool {
204 matches!(self, Self::Retry | Self::RetryAfter)
205 }
206
207 #[must_use]
209 pub const fn allows_another_candidate(self) -> bool {
210 matches!(self, Self::Retry | Self::RetryAfter | Self::Fallback)
211 }
212}
213
214impl fmt::Display for RetryClass {
215 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216 f.write_str(self.as_str())
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(tag = "kind", rename_all = "snake_case")]
225#[non_exhaustive]
226pub enum ProviderErrorKind {
227 Timeout,
231 RateLimited {
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 retry_after: Option<Duration>,
236 },
237 Authentication,
243 Authorization,
245 CredentialExpired,
263 QuotaExhausted {
274 #[serde(default, skip_serializing_if = "Option::is_none")]
277 scope: Option<ErrorCode>,
278 },
279 InvalidRequest,
282 ContextOverflow {
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 needed_tokens: Option<u64>,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 limit_tokens: Option<u64>,
290 },
291 ModelNotFound,
293 Refusal,
295 ContentFilter,
297 Malformed,
300 Transport,
302 Server {
304 #[serde(default, skip_serializing_if = "Option::is_none")]
306 status: Option<u16>,
307 },
308 Cancelled,
310 CapabilityMismatch {
313 mismatch: CapabilityMismatch,
315 },
316 Unsupported {
319 feature: ErrorCode,
321 },
322 Other,
324}
325
326impl ProviderErrorKind {
327 #[must_use]
329 pub const fn as_str(&self) -> &'static str {
330 match self {
331 Self::Timeout => "timeout",
332 Self::RateLimited { .. } => "rate_limited",
333 Self::Authentication => "authentication",
334 Self::Authorization => "authorization",
335 Self::CredentialExpired => "credential_expired",
336 Self::QuotaExhausted { .. } => "quota_exhausted",
337 Self::InvalidRequest => "invalid_request",
338 Self::ContextOverflow { .. } => "context_overflow",
339 Self::ModelNotFound => "model_not_found",
340 Self::Refusal => "refusal",
341 Self::ContentFilter => "content_filter",
342 Self::Malformed => "malformed",
343 Self::Transport => "transport",
344 Self::Server { .. } => "server",
345 Self::Cancelled => "cancelled",
346 Self::CapabilityMismatch { .. } => "capability_mismatch",
347 Self::Unsupported { .. } => "unsupported",
348 Self::Other => "other",
349 }
350 }
351
352 #[must_use]
354 pub const fn retry_after(&self) -> Option<Duration> {
355 match self {
356 Self::RateLimited { retry_after } => *retry_after,
357 _ => None,
358 }
359 }
360
361 #[must_use]
389 pub const fn retry_class(&self) -> RetryClass {
390 match self {
391 Self::Timeout | Self::Malformed | Self::Transport | Self::Server { .. } => {
392 RetryClass::Retry
393 }
394 Self::RateLimited { .. } => RetryClass::RetryAfter,
395 Self::Authentication
396 | Self::Authorization
397 | Self::CredentialExpired
398 | Self::QuotaExhausted { .. }
399 | Self::ModelNotFound
400 | Self::CapabilityMismatch { .. }
401 | Self::Unsupported { .. } => RetryClass::Fallback,
402 Self::InvalidRequest
403 | Self::ContextOverflow { .. }
404 | Self::Refusal
405 | Self::ContentFilter
406 | Self::Cancelled
407 | Self::Other => RetryClass::Fatal,
408 }
409 }
410
411 #[must_use]
413 pub const fn core_code(&self) -> turnframe_core::error::ProviderFailureCode {
414 use turnframe_core::error::ProviderFailureCode as Code;
415 match self {
416 Self::Timeout => Code::Timeout,
417 Self::RateLimited { .. } => Code::RateLimited,
418 Self::Authentication | Self::Authorization => Code::Authentication,
419 Self::CredentialExpired => Code::CredentialExpired,
420 Self::QuotaExhausted { .. } => Code::QuotaExhausted,
421 Self::ContextOverflow { .. } => Code::ContextOverflow,
422 Self::Malformed => Code::Malformed,
423 Self::Refusal => Code::Refusal,
424 Self::CapabilityMismatch { .. } => Code::CapabilityMismatch,
425 Self::Cancelled => Code::Cancelled,
426 Self::Server { .. } | Self::Transport => Code::ServerError,
427 Self::InvalidRequest
428 | Self::ModelNotFound
429 | Self::ContentFilter
430 | Self::Unsupported { .. }
431 | Self::Other => Code::Other,
432 }
433 }
434}
435
436impl fmt::Display for ProviderErrorKind {
437 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438 f.write_str(self.as_str())?;
439 match self {
440 Self::RateLimited {
441 retry_after: Some(delay),
442 } => write!(f, "(retry_after={}s)", delay.as_secs()),
443 Self::ContextOverflow {
444 needed_tokens: Some(needed),
445 limit_tokens: Some(limit),
446 } => write!(f, "(needed={needed}, limit={limit})"),
447 Self::Server {
448 status: Some(status),
449 } => write!(f, "(status={status})"),
450 Self::QuotaExhausted { scope: Some(scope) } => write!(f, "({scope})"),
451 Self::CapabilityMismatch { mismatch } => write!(f, "({mismatch})"),
452 Self::Unsupported { feature } => write!(f, "({feature})"),
453 _ => Ok(()),
454 }
455 }
456}
457
458#[derive(Clone, PartialEq, Eq)]
466pub struct ProviderError {
467 kind: ProviderErrorKind,
468 provider: Option<ProviderKey>,
469 model: Option<ModelKey>,
470 code: Option<ErrorCode>,
471 detail: Option<Box<ProviderDetail>>,
475}
476
477impl ProviderError {
478 #[must_use]
480 pub const fn new(kind: ProviderErrorKind) -> Self {
481 Self {
482 kind,
483 provider: None,
484 model: None,
485 code: None,
486 detail: None,
487 }
488 }
489
490 #[must_use]
492 pub const fn timeout() -> Self {
493 Self::new(ProviderErrorKind::Timeout)
494 }
495
496 #[must_use]
498 pub const fn rate_limited(retry_after: Option<Duration>) -> Self {
499 Self::new(ProviderErrorKind::RateLimited { retry_after })
500 }
501
502 #[must_use]
504 pub const fn authentication() -> Self {
505 Self::new(ProviderErrorKind::Authentication)
506 }
507
508 #[must_use]
510 pub const fn authorization() -> Self {
511 Self::new(ProviderErrorKind::Authorization)
512 }
513
514 #[must_use]
520 pub const fn credential_expired() -> Self {
521 Self::new(ProviderErrorKind::CredentialExpired)
522 }
523
524 #[must_use]
530 pub fn quota_exhausted(scope: Option<&str>) -> Self {
531 Self::new(ProviderErrorKind::QuotaExhausted {
532 scope: scope.map(ErrorCode::new),
533 })
534 }
535
536 #[must_use]
538 pub fn invalid_request(code: impl AsRef<str>) -> Self {
539 Self::new(ProviderErrorKind::InvalidRequest).with_code(code)
540 }
541
542 #[must_use]
544 pub const fn context_overflow(needed_tokens: Option<u64>, limit_tokens: Option<u64>) -> Self {
545 Self::new(ProviderErrorKind::ContextOverflow {
546 needed_tokens,
547 limit_tokens,
548 })
549 }
550
551 #[must_use]
553 pub const fn model_not_found() -> Self {
554 Self::new(ProviderErrorKind::ModelNotFound)
555 }
556
557 #[must_use]
559 pub const fn refusal() -> Self {
560 Self::new(ProviderErrorKind::Refusal)
561 }
562
563 #[must_use]
565 pub const fn content_filter() -> Self {
566 Self::new(ProviderErrorKind::ContentFilter)
567 }
568
569 #[must_use]
571 pub fn malformed(code: impl AsRef<str>) -> Self {
572 Self::new(ProviderErrorKind::Malformed).with_code(code)
573 }
574
575 #[must_use]
577 pub fn transport(code: impl AsRef<str>) -> Self {
578 Self::new(ProviderErrorKind::Transport).with_code(code)
579 }
580
581 #[must_use]
583 pub const fn server(status: Option<u16>) -> Self {
584 Self::new(ProviderErrorKind::Server { status })
585 }
586
587 #[must_use]
589 pub const fn cancelled() -> Self {
590 Self::new(ProviderErrorKind::Cancelled)
591 }
592
593 #[must_use]
595 pub fn capability_mismatch(mismatch: CapabilityMismatch) -> Self {
596 Self::new(ProviderErrorKind::CapabilityMismatch { mismatch })
597 }
598
599 #[must_use]
601 pub fn unsupported(feature: impl AsRef<str>) -> Self {
602 Self::new(ProviderErrorKind::Unsupported {
603 feature: ErrorCode::new(feature),
604 })
605 }
606
607 #[must_use]
609 pub fn other(code: impl AsRef<str>) -> Self {
610 Self::new(ProviderErrorKind::Other).with_code(code)
611 }
612
613 #[must_use]
619 pub fn with_detail(mut self, detail: impl AsRef<str>) -> Self {
620 let detail = ProviderDetail::new(detail);
621 if !detail.is_empty() {
622 self.detail = Some(Box::new(detail));
623 }
624 self
625 }
626
627 #[must_use]
631 pub fn detail(&self) -> Option<&ProviderDetail> {
632 self.detail.as_deref()
633 }
634
635 #[must_use]
637 pub fn with_code(mut self, code: impl AsRef<str>) -> Self {
638 self.code = Some(ErrorCode::new(code));
639 self
640 }
641
642 #[must_use]
644 pub fn with_provider(mut self, provider: impl Into<ProviderKey>) -> Self {
645 self.provider = Some(provider.into());
646 self
647 }
648
649 #[must_use]
651 pub fn with_model(mut self, model: &ModelRef) -> Self {
652 self.provider = Some(model.provider.clone());
653 self.model = Some(model.model.clone());
654 self
655 }
656
657 #[must_use]
659 pub fn kind(&self) -> &ProviderErrorKind {
660 &self.kind
661 }
662
663 #[must_use]
665 pub fn provider(&self) -> Option<&ProviderKey> {
666 self.provider.as_ref()
667 }
668
669 #[must_use]
671 pub fn model(&self) -> Option<&ModelKey> {
672 self.model.as_ref()
673 }
674
675 #[must_use]
677 pub fn code(&self) -> Option<&ErrorCode> {
678 self.code.as_ref()
679 }
680
681 #[must_use]
683 pub const fn retry_class(&self) -> RetryClass {
684 self.kind.retry_class()
685 }
686
687 #[must_use]
689 pub const fn retry_after(&self) -> Option<Duration> {
690 self.kind.retry_after()
691 }
692
693 #[must_use]
695 pub const fn is_retryable(&self) -> bool {
696 self.retry_class().allows_same_provider()
697 }
698
699 #[must_use]
701 pub fn to_core_failure(&self) -> turnframe_core::error::ProviderFailure {
702 turnframe_core::error::ProviderFailure {
703 provider_key: self
704 .provider
705 .clone()
706 .unwrap_or_else(|| ProviderKey::from("unknown")),
707 model_key: self.model.clone(),
708 code: self.kind.core_code(),
709 retryable: self.is_retryable(),
710 detail: self
711 .detail
712 .as_ref()
713 .map(|detail| detail.as_str().to_owned()),
714 }
715 }
716}
717
718impl fmt::Debug for ProviderError {
719 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
727 f.debug_struct("ProviderError")
728 .field("kind", &self.kind)
729 .field("provider", &self.provider)
730 .field("model", &self.model)
731 .field("code", &self.code)
732 .field("detail", &self.detail.is_some())
733 .finish()
734 }
735}
736
737impl fmt::Display for ProviderError {
738 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
740 f.write_str("provider call failed: ")?;
741 fmt::Display::fmt(&self.kind, f)?;
742 match (&self.provider, &self.model) {
743 (Some(provider), Some(model)) => write!(f, " [{provider}/{model}]")?,
744 (Some(provider), None) => write!(f, " [{provider}]")?,
745 _ => {}
746 }
747 if let Some(code) = &self.code {
748 write!(f, " code={code}")?;
749 }
750 Ok(())
751 }
752}
753
754impl std::error::Error for ProviderError {}
755
756impl From<ProviderError> for turnframe_core::error::ProviderFailure {
757 fn from(value: ProviderError) -> Self {
758 value.to_core_failure()
759 }
760}
761
762impl From<CapabilityMismatch> for ProviderError {
763 fn from(value: CapabilityMismatch) -> Self {
764 Self::capability_mismatch(value)
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use crate::capabilities::{MissingCapability, StructuredOutputCapability};
772
773 #[test]
774 fn every_kind_has_a_class_and_a_unique_label() {
775 let kinds = [
776 ProviderErrorKind::Timeout,
777 ProviderErrorKind::RateLimited { retry_after: None },
778 ProviderErrorKind::Authentication,
779 ProviderErrorKind::Authorization,
780 ProviderErrorKind::CredentialExpired,
781 ProviderErrorKind::QuotaExhausted { scope: None },
782 ProviderErrorKind::InvalidRequest,
783 ProviderErrorKind::ContextOverflow {
784 needed_tokens: None,
785 limit_tokens: None,
786 },
787 ProviderErrorKind::ModelNotFound,
788 ProviderErrorKind::Refusal,
789 ProviderErrorKind::ContentFilter,
790 ProviderErrorKind::Malformed,
791 ProviderErrorKind::Transport,
792 ProviderErrorKind::Server { status: None },
793 ProviderErrorKind::Cancelled,
794 ProviderErrorKind::CapabilityMismatch {
795 mismatch: CapabilityMismatch {
796 missing: vec![MissingCapability::Streaming],
797 },
798 },
799 ProviderErrorKind::Unsupported {
800 feature: ErrorCode::new("streaming"),
801 },
802 ProviderErrorKind::Other,
803 ];
804 let mut labels: Vec<&str> = kinds.iter().map(ProviderErrorKind::as_str).collect();
805 assert_eq!(labels.len(), 18);
806 labels.sort_unstable();
807 labels.dedup();
808 assert_eq!(labels.len(), 18, "labels must be unique");
809 for kind in &kinds {
810 assert!(RetryClass::ALL.contains(&kind.retry_class()));
811 }
812 }
813
814 #[test]
815 fn classification_matches_the_documented_table() {
816 assert_eq!(ProviderError::timeout().retry_class(), RetryClass::Retry);
817 assert_eq!(
818 ProviderError::malformed("bad_json").retry_class(),
819 RetryClass::Retry
820 );
821 assert_eq!(
822 ProviderError::server(Some(503)).retry_class(),
823 RetryClass::Retry
824 );
825 assert_eq!(
826 ProviderError::rate_limited(Some(Duration::from_secs(3))).retry_class(),
827 RetryClass::RetryAfter
828 );
829 assert_eq!(
830 ProviderError::authentication().retry_class(),
831 RetryClass::Fallback
832 );
833 assert_eq!(
834 ProviderError::model_not_found().retry_class(),
835 RetryClass::Fallback
836 );
837 assert_eq!(
838 ProviderError::unsupported("streaming").retry_class(),
839 RetryClass::Fallback
840 );
841 assert_eq!(
842 ProviderError::context_overflow(Some(9), Some(8)).retry_class(),
843 RetryClass::Fatal
844 );
845 assert_eq!(ProviderError::refusal().retry_class(), RetryClass::Fatal);
846 assert_eq!(
847 ProviderError::content_filter().retry_class(),
848 RetryClass::Fatal
849 );
850 assert_eq!(ProviderError::cancelled().retry_class(), RetryClass::Fatal);
851 assert_eq!(
852 ProviderError::other("weird").retry_class(),
853 RetryClass::Fatal
854 );
855 }
856
857 #[test]
858 fn an_expired_credential_is_not_a_bad_key() {
859 let expired = ProviderError::credential_expired();
860 assert!(matches!(
861 expired.kind(),
862 ProviderErrorKind::CredentialExpired
863 ));
864 assert_ne!(
865 expired.kind().as_str(),
866 ProviderError::authentication().kind().as_str(),
867 "the two must stay tellable apart in metrics and replay records"
868 );
869 assert_ne!(expired.retry_class(), RetryClass::Retry);
871 assert_eq!(expired.retry_class(), RetryClass::Fallback);
872 assert!(!expired.is_retryable());
873 assert!(expired.retry_class().allows_another_candidate());
874 assert_eq!(
875 expired.to_core_failure().code,
876 turnframe_core::error::ProviderFailureCode::CredentialExpired
877 );
878 }
879
880 #[test]
881 fn an_exhausted_quota_is_not_a_rate_limit() {
882 let quota = ProviderError::quota_exhausted(Some("tokens_per_day"));
883 assert_eq!(quota.kind().as_str(), "quota_exhausted");
884 assert_eq!(quota.retry_class(), RetryClass::Fallback);
887 assert_ne!(quota.retry_class(), RetryClass::RetryAfter);
888 assert_eq!(quota.retry_after(), None);
889 assert!(quota.to_string().contains("tokens_per_day"), "{quota}");
890 assert_eq!(
891 quota.to_core_failure().code,
892 turnframe_core::error::ProviderFailureCode::QuotaExhausted
893 );
894
895 let planted = ProviderError::quota_exhausted(Some("{\"error\": \"no credit\"}"));
898 assert!(!planted.to_string().contains('"'), "{planted}");
899 assert!(
900 ProviderError::quota_exhausted(None)
901 .to_string()
902 .ends_with("quota_exhausted")
903 );
904 }
905
906 #[test]
907 fn error_codes_are_sanitized_and_truncated() {
908 let planted = ErrorCode::new("{\"error\":{\"message\":\"invalid api key sk-live-1\"}}");
909 assert!(!planted.as_str().contains('"'));
910 assert!(!planted.as_str().contains(' '));
911 let long = ErrorCode::new("x".repeat(500));
912 assert_eq!(long.as_str().len(), MAX_ERROR_CODE_LEN);
913 }
914
915 #[test]
916 fn display_carries_codes_and_keys_only() {
917 let error = ProviderError::rate_limited(Some(Duration::from_secs(30)))
918 .with_model(&ModelRef::new("openai", "gpt-4o"))
919 .with_code("requests_per_minute");
920 let text = error.to_string();
921 assert_eq!(
922 text,
923 "provider call failed: rate_limited(retry_after=30s) [openai/gpt-4o] code=requests_per_minute"
924 );
925 assert_eq!(error.retry_after(), Some(Duration::from_secs(30)));
926 }
927
928 #[test]
929 fn capability_mismatch_names_the_missing_transport() {
930 let mismatch = CapabilityMismatch {
931 missing: vec![MissingCapability::StructuredOutput {
932 required: vec![StructuredOutputCapability::NativeJsonSchema],
933 declared: StructuredOutputCapability::PromptOnly,
934 }],
935 };
936 let error = ProviderError::from(mismatch);
937 assert_eq!(error.retry_class(), RetryClass::Fallback);
938 let text = error.to_string();
939 assert!(text.contains("native_json_schema"), "{text}");
940 assert!(text.contains("prompt_only"), "{text}");
941 }
942
943 #[test]
944 fn core_failure_bridge_keeps_keys_and_retryability() {
945 let failure = ProviderError::timeout()
946 .with_model(&ModelRef::new("anthropic", "claude"))
947 .to_core_failure();
948 assert_eq!(failure.provider_key.as_str(), "anthropic");
949 assert_eq!(
950 failure.model_key.as_ref().map(ModelKey::as_str),
951 Some("claude")
952 );
953 assert!(failure.retryable);
954 assert_eq!(
955 failure.code,
956 turnframe_core::error::ProviderFailureCode::Timeout
957 );
958 let fatal = ProviderError::refusal().to_core_failure();
959 assert!(!fatal.retryable);
960 assert_eq!(fatal.provider_key.as_str(), "unknown");
961 }
962
963 #[test]
964 fn kinds_round_trip_through_serde() {
965 let kind = ProviderErrorKind::RateLimited {
966 retry_after: Some(Duration::from_millis(1500)),
967 };
968 let json = serde_json::to_string(&kind).unwrap();
969 assert!(json.contains("\"rate_limited\""));
970 let back: ProviderErrorKind = serde_json::from_str(&json).unwrap();
971 assert_eq!(back, kind);
972 }
973}