Skip to main content

polyc_llm/
error.rs

1//! `LlmError` marker trait and reference [`DummyError`] implementation.
2//!
3//! Every `LlmProvider::Error` associated type must satisfy the [`LlmError`]
4//! bound, which is equivalent to
5//! `std::error::Error + Send + Sync + 'static` but named so it is
6//! grep-able and can grow cross-provider extension methods without
7//! breaking changes.
8
9/// Marker trait that every `LlmProvider::Error` must satisfy.
10///
11/// Equivalent to `std::error::Error + Send + Sync + 'static`, written as
12/// its own trait so it is:
13///   1. searchable in the codebase (grep for `LlmError`),
14///   2. one place to add cross-provider extension methods later, and
15///   3. a sticky name in error messages (clippy / rustdoc).
16pub trait LlmError: std::error::Error + Send + Sync + 'static {
17    /// Classify this error so a transport (e.g. the harness's Connect surface)
18    /// can map it onto an accurate status code — telling retryable (rate-limit /
19    /// timeout / unavailable) apart from terminal (auth / bad-request) failures
20    /// instead of collapsing everything to a catch-all.
21    ///
22    /// Defaults to [`LlmErrorKind::Other`]; provider error types override it,
23    /// and [`crate::BoxError`] carries the kind through type erasure.
24    fn kind(&self) -> LlmErrorKind {
25        LlmErrorKind::Other
26    }
27
28    /// A server-requested wait before retrying (e.g. a `Retry-After` header on a
29    /// 429), when the provider captured one. A retry loop prefers this over its
30    /// own computed backoff. Defaults to `None`; rate-limit-aware providers
31    /// override it, and [`crate::BoxError`] carries it through type erasure.
32    fn retry_after(&self) -> Option<std::time::Duration> {
33        None
34    }
35}
36
37/// A coarse, provider-agnostic classification of an [`LlmError`].
38///
39/// Deliberately small and stable: it names only the distinctions a caller acts
40/// on (retry vs. fail, and which status to surface), not a full provider
41/// taxonomy. [`kind_from_http_status`] maps an HTTP status onto these.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum LlmErrorKind {
44    /// Rate-limited / quota exhausted (HTTP 429). Retryable after backoff.
45    RateLimit,
46    /// Upstream timed out (HTTP 408/504, or a client read/connect timeout).
47    /// Retryable.
48    Timeout,
49    /// Transient upstream unavailability (HTTP 5xx, connection refused/reset,
50    /// DNS, stream break). Retryable.
51    Unavailable,
52    /// Authentication / authorization failure (HTTP 401/403). Terminal —
53    /// retrying with the same credentials won't help.
54    Auth,
55    /// The request itself was rejected (HTTP 400/404/422, unknown model).
56    /// Terminal.
57    BadRequest,
58    /// Anything else — an unclassified or internal failure.
59    #[default]
60    Other,
61}
62
63/// Parse a `Retry-After` header value into a wait duration.
64///
65/// Supports the common integer-seconds form (`"30"`); the HTTP-date form is not
66/// parsed (returns `None`), so the caller falls back to computed backoff.
67#[must_use]
68pub fn parse_retry_after(value: &str) -> Option<std::time::Duration> {
69    value
70        .trim()
71        .parse::<u64>()
72        .ok()
73        .map(std::time::Duration::from_secs)
74}
75
76/// Maps an HTTP status code onto an [`LlmErrorKind`]. Shared by every provider
77/// so the classification of `Provider { status, .. }` errors stays consistent.
78#[must_use]
79pub const fn kind_from_http_status(status: u16) -> LlmErrorKind {
80    match status {
81        429 => LlmErrorKind::RateLimit,
82        408 | 504 => LlmErrorKind::Timeout,
83        401 | 403 => LlmErrorKind::Auth,
84        400 | 404 | 422 => LlmErrorKind::BadRequest,
85        500..=599 => LlmErrorKind::Unavailable,
86        _ => LlmErrorKind::Other,
87    }
88}
89
90/// Reference implementation: the shape of error a real provider would
91/// ship. Concrete provider crates will define their own.
92#[derive(Debug, thiserror::Error)]
93pub enum DummyError {
94    /// Network or transport-layer failure.
95    #[error("transport: {0}")]
96    Transport(String),
97
98    /// Provider returned a non-2xx status with a body.
99    #[error("provider returned status {status}: {body}")]
100    Provider {
101        /// HTTP status code returned by the provider.
102        status: u16,
103        /// Response body, typically a JSON error payload.
104        body: String,
105    },
106
107    /// Streamed response broke mid-flight.
108    #[error("stream interrupted: {0}")]
109    StreamInterrupted(String),
110
111    /// Anything else, escape hatch.
112    #[error("other: {0}")]
113    Other(String),
114}
115
116impl LlmError for DummyError {
117    fn kind(&self) -> LlmErrorKind {
118        match self {
119            Self::Transport(_) | Self::StreamInterrupted(_) => LlmErrorKind::Unavailable,
120            Self::Provider { status, .. } => kind_from_http_status(*status),
121            Self::Other(_) => LlmErrorKind::Other,
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::{DummyError, LlmError};
129
130    /// Compile-time assertion: `E` implements [`LlmError`].
131    fn require_llm_error<E: LlmError>() {}
132
133    /// Compile-time assertion: `T` is `Send + Sync + 'static`.
134    fn assert_send_sync<T: Send + Sync + 'static>() {}
135
136    // --- Display -----------------------------------------------------------
137
138    #[test]
139    fn display_transport() {
140        let e = DummyError::Transport("DNS failure".to_owned());
141        assert_eq!(format!("{e}"), "transport: DNS failure");
142    }
143
144    #[test]
145    fn display_provider() {
146        let e = DummyError::Provider {
147            status: 404,
148            body: "not found".to_owned(),
149        };
150        assert_eq!(format!("{e}"), "provider returned status 404: not found");
151    }
152
153    #[test]
154    fn display_stream_interrupted() {
155        let e = DummyError::StreamInterrupted("EOF".to_owned());
156        assert_eq!(format!("{e}"), "stream interrupted: EOF");
157    }
158
159    #[test]
160    fn display_other() {
161        let e = DummyError::Other("unexpected".to_owned());
162        assert_eq!(format!("{e}"), "other: unexpected");
163    }
164
165    // --- Debug -------------------------------------------------------------
166
167    #[test]
168    fn debug_is_derived() {
169        let e = DummyError::Transport("t".to_owned());
170        assert!(format!("{e:?}").contains("Transport"));
171    }
172
173    // --- Trait-bound proofs (compile-time) ---------------------------------
174
175    #[test]
176    fn dummy_error_satisfies_llm_error() {
177        // DummyError: std::error::Error + Send + Sync + 'static
178        // → blanket impl grants DummyError: LlmError.
179        require_llm_error::<DummyError>();
180    }
181
182    #[test]
183    fn dummy_error_is_send_sync_static() {
184        assert_send_sync::<DummyError>();
185    }
186
187    #[test]
188    fn dummy_error_boxes_as_std_error() {
189        // Coercing to the trait object verifies std::error::Error + Send + Sync + 'static.
190        let _: Box<dyn std::error::Error + Send + Sync + 'static> =
191            Box::new(DummyError::Other("boxed".to_owned()));
192    }
193
194    #[test]
195    fn parse_retry_after_handles_seconds_and_rejects_dates() {
196        use super::parse_retry_after;
197        use std::time::Duration;
198        assert_eq!(parse_retry_after("30"), Some(Duration::from_secs(30)));
199        assert_eq!(parse_retry_after("  5 "), Some(Duration::from_secs(5)));
200        assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
201        // HTTP-date form is not parsed → None (caller falls back to backoff).
202        assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), None);
203        assert_eq!(parse_retry_after("soon"), None);
204    }
205
206    #[test]
207    fn http_status_maps_to_kind() {
208        use super::{LlmErrorKind, kind_from_http_status};
209        assert_eq!(kind_from_http_status(429), LlmErrorKind::RateLimit);
210        assert_eq!(kind_from_http_status(504), LlmErrorKind::Timeout);
211        assert_eq!(kind_from_http_status(408), LlmErrorKind::Timeout);
212        assert_eq!(kind_from_http_status(401), LlmErrorKind::Auth);
213        assert_eq!(kind_from_http_status(403), LlmErrorKind::Auth);
214        assert_eq!(kind_from_http_status(400), LlmErrorKind::BadRequest);
215        assert_eq!(kind_from_http_status(404), LlmErrorKind::BadRequest);
216        assert_eq!(kind_from_http_status(503), LlmErrorKind::Unavailable);
217        assert_eq!(kind_from_http_status(200), LlmErrorKind::Other);
218    }
219
220    #[test]
221    fn dummy_error_classifies() {
222        use super::{LlmError, LlmErrorKind};
223        assert_eq!(
224            DummyError::Provider {
225                status: 429,
226                body: String::new()
227            }
228            .kind(),
229            LlmErrorKind::RateLimit
230        );
231        assert_eq!(
232            DummyError::Transport("reset".to_owned()).kind(),
233            LlmErrorKind::Unavailable
234        );
235        assert_eq!(
236            DummyError::Other("x".to_owned()).kind(),
237            LlmErrorKind::Other
238        );
239    }
240}