Skip to main content

turnframe_provider/
error.rs

1//! The typed provider failure family and its retry classification (spec §24, §20.7).
2//!
3//! Every adapter maps its vendor errors into a [`ProviderError`]: a [`ProviderErrorKind`]
4//! with its classification payload, the provider and model keys, and an optional short
5//! code. Nothing from the wire appears in one: `Display` and `Debug` render the kind, the
6//! keys and a code [`ErrorCode::new`] sanitized, never a body, a header, a prompt, user text
7//! or a secret (spec §25.2).
8//!
9//! [`ProviderError::retry_class`] says what the caller may do next. Whether it may retry at
10//! all is [`FallbackStage`](crate::fallback::FallbackStage)'s to decide (I17).
11//!
12//! | [`RetryClass`] | Meaning |
13//! |----------------|---------|
14//! | [`Retry`](RetryClass::Retry) | The same provider may be tried again after a backoff. |
15//! | [`RetryAfter`](RetryClass::RetryAfter) | The same provider may be tried again, but only after the delay the provider asked for. |
16//! | [`Fallback`](RetryClass::Fallback) | This provider will keep failing; move to the next candidate that satisfies the **same** requirements. |
17//! | [`Fatal`](RetryClass::Fatal) | Neither retrying nor falling back can help; fail the stage. |
18//!
19//! Three kinds look alike from a status code and are not; the conformance suite has a row
20//! for each:
21//!
22//! | Looks like | Actually | Why it matters |
23//! |------------|----------|----------------|
24//! | [`Authentication`](ProviderErrorKind::Authentication) | [`CredentialExpired`](ProviderErrorKind::CredentialExpired) | A wrong key stays wrong; an expired token works again after a refresh. Providers that issue short-lived credentials (Vertex AI bearer tokens, Bedrock session credentials) fail this way routinely. |
25//! | [`RateLimited`](ProviderErrorKind::RateLimited) | [`QuotaExhausted`](ProviderErrorKind::QuotaExhausted) | A rate limit clears by waiting; a quota or an empty balance does not, and several providers report both with HTTP 429. |
26//! | [`InvalidRequest`](ProviderErrorKind::InvalidRequest) | [`ContextOverflow`](ProviderErrorKind::ContextOverflow) | Both arrive as HTTP 400; only one is fixed by shrinking the prompt. |
27
28use std::fmt;
29use std::time::Duration;
30
31use serde::{Deserialize, Serialize};
32
33use crate::capabilities::CapabilityMismatch;
34use crate::ids::{ModelKey, ModelRef, ProviderKey};
35
36/// Maximum length of a sanitized [`ErrorCode`], in bytes.
37pub const MAX_ERROR_CODE_LEN: usize = 64;
38
39/// Longest sanitized [`ProviderDetail`], in bytes.
40pub const MAX_ERROR_DETAIL_LEN: usize = 512;
41
42/// The endpoint's own sentence about why it refused, sanitized.
43///
44/// # Why this exists beside [`ErrorCode`]
45///
46/// A code says which family a failure belongs to. It cannot say *what was
47/// wrong with the request*, and for the one family where that is the adopter's
48/// own bug — a malformed request — the difference between "the provider is
49/// down" and "your function schema is missing `properties`" is the difference
50/// between waiting and fixing. Finding the second took putting a proxy between
51/// the process and the endpoint to read a body the runtime had already read
52/// and thrown away.
53///
54/// # What it is not
55///
56/// It is not shown to a user and it is not in [`ProviderError`]'s `Display`,
57/// which keeps its promise that what it renders is safe to log unconditionally.
58/// This is the vendor's words about a request this library sent: normally about
59/// the request's shape, and in principle able to quote a value from it. An
60/// adapter passes it through the same [`Redactor`](crate::secret::Redactor) the
61/// code goes through, and construction here drops control characters, collapses
62/// runs of whitespace and truncates on a character boundary.
63#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
64#[serde(transparent)]
65pub struct ProviderDetail(String);
66
67impl ProviderDetail {
68    /// Sanitizes and truncates `raw` into a detail.
69    ///
70    /// ```
71    /// use turnframe_provider::error::ProviderDetail;
72    ///
73    /// let detail = ProviderDetail::new("Invalid schema for function 'x':\n  missing properties");
74    /// assert_eq!(
75    ///     detail.as_str(),
76    ///     "Invalid schema for function 'x': missing properties"
77    /// );
78    /// ```
79    #[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    /// The sanitized text.
104    #[must_use]
105    pub fn as_str(&self) -> &str {
106        &self.0
107    }
108
109    /// Whether anything survived sanitizing.
110    #[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/// A short, sanitized machine code an adapter attaches to a failure.
123///
124/// Construction is the redaction point: only `[A-Za-z0-9_.:-]` survives, every
125/// other character becomes `_`, and the value is truncated to
126/// [`MAX_ERROR_CODE_LEN`]. A code is meant to be a stable label such as
127/// `"invalid_api_key"` or `"model_overloaded"`, never a message.
128#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
129#[serde(transparent)]
130pub struct ErrorCode(String);
131
132impl ErrorCode {
133    /// Sanitizes and truncates `raw` into a code.
134    ///
135    /// ```
136    /// use turnframe_provider::error::ErrorCode;
137    ///
138    /// assert_eq!(ErrorCode::new("invalid_api_key").as_str(), "invalid_api_key");
139    /// // A leaked body cannot survive readable.
140    /// assert_eq!(ErrorCode::new("{\"key\":\"sk-abc\"}").as_str(), "__key_:_sk-abc__");
141    /// ```
142    #[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    /// Borrows the sanitized code.
159    #[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/// What the caller may do after a failure (spec §20.7).
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum RetryClass {
175    /// Retry the same provider after the policy's backoff.
176    Retry,
177    /// Retry the same provider, but not before the delay the provider asked
178    /// for (see [`ProviderErrorKind::retry_after`]).
179    RetryAfter,
180    /// Do not retry this provider; move to the next candidate.
181    Fallback,
182    /// Fail the stage.
183    Fatal,
184}
185
186impl RetryClass {
187    /// Every class, in decreasing order of hope.
188    pub const ALL: [Self; 4] = [Self::Retry, Self::RetryAfter, Self::Fallback, Self::Fatal];
189
190    /// Stable snake-case label for metrics and replay records.
191    #[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    /// Returns `true` when the same provider may be called again.
202    #[must_use]
203    pub const fn allows_same_provider(self) -> bool {
204        matches!(self, Self::Retry | Self::RetryAfter)
205    }
206
207    /// Returns `true` when another candidate may be tried.
208    #[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/// The failure families an adapter maps its vendor errors into.
221///
222/// Growable: match with a `_` arm.
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(tag = "kind", rename_all = "snake_case")]
225#[non_exhaustive]
226pub enum ProviderErrorKind {
227    /// The call exceeded [`ModelRequest::timeout`](crate::request::ModelRequest::timeout)
228    /// or the transport deadline. The model call itself has no effect, so a
229    /// timeout here is safe to retry before commit.
230    Timeout,
231    /// The provider rate-limited the request.
232    RateLimited {
233        /// The delay the provider asked for, when it supplied one.
234        #[serde(default, skip_serializing_if = "Option::is_none")]
235        retry_after: Option<Duration>,
236    },
237    /// The credentials were rejected as invalid — a wrong or revoked key.
238    ///
239    /// A key that was valid and has *expired* is
240    /// [`CredentialExpired`](Self::CredentialExpired) instead, because the two
241    /// call for opposite reactions.
242    Authentication,
243    /// The credentials are valid but not entitled to this model or endpoint.
244    Authorization,
245    /// A credential that was valid has expired.
246    ///
247    /// Deliberately not [`Authentication`](Self::Authentication). A wrong key
248    /// stays wrong; an expired one becomes valid again the moment it is
249    /// refreshed, and several providers issue short-lived credentials as a
250    /// matter of design — Vertex AI bearer tokens and Bedrock session
251    /// credentials both expire on a schedule. Folding the two together forces
252    /// one of two wrong behaviours: a caller that could refresh and continue
253    /// gives up, or a caller that cannot refresh retries a credential that
254    /// will never work again.
255    ///
256    /// Its class is [`Fallback`](RetryClass::Fallback), which reads as "not
257    /// with this credential as it stands". A caller **holding a refresher** may
258    /// refresh and retry the same profile; a caller **without one** must treat
259    /// it as fatal for that profile and move on. It is never
260    /// [`Retry`](RetryClass::Retry): retrying the same expired token is a busy
261    /// loop.
262    CredentialExpired,
263    /// The account's quota or credit balance is exhausted.
264    ///
265    /// Deliberately not [`RateLimited`](Self::RateLimited), **even when the
266    /// provider reports it with HTTP 429**, which several do. A rate limit
267    /// means "wait and it will work"; an exhausted quota or an empty credit
268    /// balance means it will not work until a window resets or a human tops the
269    /// account up — hours or days, not seconds. Sleeping on it burns the turn's
270    /// deadline for nothing, which is why its class is
271    /// [`Fallback`](RetryClass::Fallback): the router moves to another
272    /// candidate instead of waiting.
273    QuotaExhausted {
274        /// The quota the provider named, when it named one, as a short
275        /// sanitized code: `"tokens_per_day"`, `"credit_balance"`.
276        #[serde(default, skip_serializing_if = "Option::is_none")]
277        scope: Option<ErrorCode>,
278    },
279    /// The provider rejected the request as malformed. Our request is wrong;
280    /// another provider will reject it too.
281    InvalidRequest,
282    /// The prompt exceeded the model's context window.
283    ContextOverflow {
284        /// Tokens the provider says the request needed, when reported.
285        #[serde(default, skip_serializing_if = "Option::is_none")]
286        needed_tokens: Option<u64>,
287        /// The model's limit, when reported.
288        #[serde(default, skip_serializing_if = "Option::is_none")]
289        limit_tokens: Option<u64>,
290    },
291    /// The configured model does not exist at this provider.
292    ModelNotFound,
293    /// The model declined to answer. A semantic outcome, not a transport fault.
294    Refusal,
295    /// The provider's safety filter blocked the prompt or the completion.
296    ContentFilter,
297    /// The response could not be normalized: unparseable body, a tool-call
298    /// fragment that is not JSON, a stream that ended without a finish event.
299    Malformed,
300    /// The request never got a complete answer from the network.
301    Transport,
302    /// The provider returned a server-side error.
303    Server {
304        /// HTTP status, when there was one.
305        #[serde(default, skip_serializing_if = "Option::is_none")]
306        status: Option<u16>,
307    },
308    /// The caller cancelled the call.
309    Cancelled,
310    /// The provider-model pair does not satisfy the requirements of the stage
311    /// (spec §0 rule 9). Never resolved by weakening the requirements.
312    CapabilityMismatch {
313        /// Exactly which requirements were unmet.
314        mismatch: CapabilityMismatch,
315    },
316    /// The adapter cannot serve a feature the request asked for (streaming, a
317    /// content part, a tool-choice mode).
318    Unsupported {
319        /// Name of the feature, e.g. `"streaming"`.
320        feature: ErrorCode,
321    },
322    /// Anything the adapter could not place. Fails closed.
323    Other,
324}
325
326impl ProviderErrorKind {
327    /// Stable snake-case label for metrics, replay records and reports.
328    #[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    /// The delay the provider asked for, when this kind carries one.
353    #[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    /// How the caller may proceed.
362    ///
363    /// The reasoning behind the less obvious rows:
364    ///
365    /// * `ContextOverflow` is [`Fatal`](RetryClass::Fatal): the same prompt is
366    ///   too long for the candidate that was already judged large enough, so the
367    ///   runtime must shrink the context rather than shop for a bigger window
368    ///   mid-flight.
369    /// * `Refusal` and `ContentFilter` are [`Fatal`](RetryClass::Fatal):
370    ///   retrying elsewhere until a model complies is a safety bypass, not a
371    ///   recovery.
372    /// * `Authentication`, `Authorization` and `ModelNotFound` are
373    ///   [`Fallback`](RetryClass::Fallback): they are configuration faults of
374    ///   one profile, and another candidate may be configured correctly.
375    /// * `CredentialExpired` is [`Fallback`](RetryClass::Fallback) rather than
376    ///   [`Retry`](RetryClass::Retry): the same credential will keep failing
377    ///   until something outside this call refreshes it. A caller that owns a
378    ///   refresher may refresh and call the same profile again; a caller that
379    ///   does not must treat it as fatal for that profile.
380    /// * `QuotaExhausted` is [`Fallback`](RetryClass::Fallback) and never
381    ///   [`RetryAfter`](RetryClass::RetryAfter), whatever status it arrived on:
382    ///   waiting does not refill a quota.
383    /// * `CapabilityMismatch` is [`Fallback`](RetryClass::Fallback) because the
384    ///   router only ever offers candidates that satisfy the same requirements;
385    ///   moving on is not a downgrade.
386    /// * `Malformed` is [`Retry`](RetryClass::Retry): a re-roll of the same
387    ///   request is the standard remedy for a decode that went off the rails.
388    #[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    /// Maps onto the normalized code the core error family stores.
412    #[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/// A normalized provider failure.
459///
460/// Built with the constructors below and enriched with
461/// [`with_model`](Self::with_model) once the caller knows which candidate
462/// produced it. `Display` **and** `Debug` are safe to log: the endpoint's own
463/// sentence is carried but rendered by neither, and is read deliberately
464/// through [`detail`](Self::detail). See [`ProviderDetail`].
465#[derive(Clone, PartialEq, Eq)]
466pub struct ProviderError {
467    kind: ProviderErrorKind,
468    provider: Option<ProviderKey>,
469    model: Option<ModelKey>,
470    code: Option<ErrorCode>,
471    /// Boxed because a `ProviderError` travels in the `Err` half of every
472    /// provider call, so its size is the size of every one of those returns —
473    /// and this is the largest thing on it and the least often read.
474    detail: Option<Box<ProviderDetail>>,
475}
476
477impl ProviderError {
478    /// Builds an error from a kind.
479    #[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    /// The call did not complete in time.
491    #[must_use]
492    pub const fn timeout() -> Self {
493        Self::new(ProviderErrorKind::Timeout)
494    }
495
496    /// The provider rate-limited the request.
497    #[must_use]
498    pub const fn rate_limited(retry_after: Option<Duration>) -> Self {
499        Self::new(ProviderErrorKind::RateLimited { retry_after })
500    }
501
502    /// The credentials were rejected.
503    #[must_use]
504    pub const fn authentication() -> Self {
505        Self::new(ProviderErrorKind::Authentication)
506    }
507
508    /// The credentials are not entitled to this model.
509    #[must_use]
510    pub const fn authorization() -> Self {
511        Self::new(ProviderErrorKind::Authorization)
512    }
513
514    /// A credential that was valid has expired and must be refreshed.
515    ///
516    /// Use this, not [`authentication`](Self::authentication), whenever the
517    /// provider distinguishes the two — a rejected key and an expired token
518    /// call for opposite reactions from whatever holds the credential.
519    #[must_use]
520    pub const fn credential_expired() -> Self {
521        Self::new(ProviderErrorKind::CredentialExpired)
522    }
523
524    /// The account's quota or credit balance is exhausted.
525    ///
526    /// `scope` names the quota when the provider names one. Use this, not
527    /// [`rate_limited`](Self::rate_limited), even when the provider reports it
528    /// with HTTP 429.
529    #[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    /// The provider rejected our request shape.
537    #[must_use]
538    pub fn invalid_request(code: impl AsRef<str>) -> Self {
539        Self::new(ProviderErrorKind::InvalidRequest).with_code(code)
540    }
541
542    /// The prompt did not fit the context window.
543    #[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    /// The configured model does not exist at this provider.
552    #[must_use]
553    pub const fn model_not_found() -> Self {
554        Self::new(ProviderErrorKind::ModelNotFound)
555    }
556
557    /// The model declined to answer.
558    #[must_use]
559    pub const fn refusal() -> Self {
560        Self::new(ProviderErrorKind::Refusal)
561    }
562
563    /// The provider's safety filter blocked the exchange.
564    #[must_use]
565    pub const fn content_filter() -> Self {
566        Self::new(ProviderErrorKind::ContentFilter)
567    }
568
569    /// The response could not be normalized. `code` says which step failed.
570    #[must_use]
571    pub fn malformed(code: impl AsRef<str>) -> Self {
572        Self::new(ProviderErrorKind::Malformed).with_code(code)
573    }
574
575    /// The network call did not complete.
576    #[must_use]
577    pub fn transport(code: impl AsRef<str>) -> Self {
578        Self::new(ProviderErrorKind::Transport).with_code(code)
579    }
580
581    /// The provider failed on its side.
582    #[must_use]
583    pub const fn server(status: Option<u16>) -> Self {
584        Self::new(ProviderErrorKind::Server { status })
585    }
586
587    /// The caller cancelled the call.
588    #[must_use]
589    pub const fn cancelled() -> Self {
590        Self::new(ProviderErrorKind::Cancelled)
591    }
592
593    /// The provider-model pair does not satisfy the stage's requirements.
594    #[must_use]
595    pub fn capability_mismatch(mismatch: CapabilityMismatch) -> Self {
596        Self::new(ProviderErrorKind::CapabilityMismatch { mismatch })
597    }
598
599    /// The adapter cannot serve a requested feature.
600    #[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    /// An unclassified failure. Treated as [`RetryClass::Fatal`].
608    #[must_use]
609    pub fn other(code: impl AsRef<str>) -> Self {
610        Self::new(ProviderErrorKind::Other).with_code(code)
611    }
612
613    /// Attaches the endpoint's own sentence, sanitized.
614    ///
615    /// Pass it through the adapter's [`Redactor`](crate::secret::Redactor)
616    /// first, the way a code is. Nothing is attached when nothing survives
617    /// sanitizing.
618    #[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    /// The endpoint's own sentence, when the adapter had one.
628    ///
629    /// Deliberately not in `Display`: see [`ProviderDetail`].
630    #[must_use]
631    pub fn detail(&self) -> Option<&ProviderDetail> {
632        self.detail.as_deref()
633    }
634
635    /// Attaches a sanitized adapter code.
636    #[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    /// Attaches the provider key.
643    #[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    /// Attaches provider and model keys at once.
650    #[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    /// The failure family.
658    #[must_use]
659    pub fn kind(&self) -> &ProviderErrorKind {
660        &self.kind
661    }
662
663    /// The provider key, when known.
664    #[must_use]
665    pub fn provider(&self) -> Option<&ProviderKey> {
666        self.provider.as_ref()
667    }
668
669    /// The model key, when known.
670    #[must_use]
671    pub fn model(&self) -> Option<&ModelKey> {
672        self.model.as_ref()
673    }
674
675    /// The adapter's sanitized code, when it supplied one.
676    #[must_use]
677    pub fn code(&self) -> Option<&ErrorCode> {
678        self.code.as_ref()
679    }
680
681    /// How the caller may proceed.
682    #[must_use]
683    pub const fn retry_class(&self) -> RetryClass {
684        self.kind.retry_class()
685    }
686
687    /// The delay the provider asked for, when it supplied one.
688    #[must_use]
689    pub const fn retry_after(&self) -> Option<Duration> {
690        self.kind.retry_after()
691    }
692
693    /// Returns `true` when the same provider may be called again.
694    #[must_use]
695    pub const fn is_retryable(&self) -> bool {
696        self.retry_class().allows_same_provider()
697    }
698
699    /// Converts into the normalized failure the core error family stores.
700    #[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    /// Renders everything `Display` does, and says only *whether* an endpoint
720    /// sentence is attached.
721    ///
722    /// Hand-written rather than derived because a derived one would print the
723    /// sentence, and `{:?}` on an error is the most common way a body reaches
724    /// a log by accident. The value is still there for a caller that asks for
725    /// it.
726    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    /// Renders kind, keys and code — never a body, a header or user text.
739    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        // Never a plain retry: the same token would fail again immediately.
870        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        // The whole point: waiting does not refill a quota, so the caller must
885        // move on rather than sleep on a Retry-After it was never given.
886        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        // The scope is sanitized like every other code, so a body pasted in by
896        // mistake cannot leak in readable form.
897        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}