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
29/// A coarse, provider-agnostic classification of an [`LlmError`].
30///
31/// Deliberately small and stable: it names only the distinctions a caller acts
32/// on (retry vs. fail, and which status to surface), not a full provider
33/// taxonomy. [`kind_from_http_status`] maps an HTTP status onto these.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum LlmErrorKind {
36    /// Rate-limited / quota exhausted (HTTP 429). Retryable after backoff.
37    RateLimit,
38    /// Upstream timed out (HTTP 408/504, or a client read/connect timeout).
39    /// Retryable.
40    Timeout,
41    /// Transient upstream unavailability (HTTP 5xx, connection refused/reset,
42    /// DNS, stream break). Retryable.
43    Unavailable,
44    /// Authentication / authorization failure (HTTP 401/403). Terminal —
45    /// retrying with the same credentials won't help.
46    Auth,
47    /// The request itself was rejected (HTTP 400/404/422, unknown model).
48    /// Terminal.
49    BadRequest,
50    /// Anything else — an unclassified or internal failure.
51    #[default]
52    Other,
53}
54
55/// Maps an HTTP status code onto an [`LlmErrorKind`]. Shared by every provider
56/// so the classification of `Provider { status, .. }` errors stays consistent.
57#[must_use]
58pub const fn kind_from_http_status(status: u16) -> LlmErrorKind {
59    match status {
60        429 => LlmErrorKind::RateLimit,
61        408 | 504 => LlmErrorKind::Timeout,
62        401 | 403 => LlmErrorKind::Auth,
63        400 | 404 | 422 => LlmErrorKind::BadRequest,
64        500..=599 => LlmErrorKind::Unavailable,
65        _ => LlmErrorKind::Other,
66    }
67}
68
69/// Reference implementation: the shape of error a real provider would
70/// ship. Concrete provider crates will define their own.
71#[derive(Debug, thiserror::Error)]
72pub enum DummyError {
73    /// Network or transport-layer failure.
74    #[error("transport: {0}")]
75    Transport(String),
76
77    /// Provider returned a non-2xx status with a body.
78    #[error("provider returned status {status}: {body}")]
79    Provider {
80        /// HTTP status code returned by the provider.
81        status: u16,
82        /// Response body, typically a JSON error payload.
83        body: String,
84    },
85
86    /// Streamed response broke mid-flight.
87    #[error("stream interrupted: {0}")]
88    StreamInterrupted(String),
89
90    /// Anything else, escape hatch.
91    #[error("other: {0}")]
92    Other(String),
93}
94
95impl LlmError for DummyError {
96    fn kind(&self) -> LlmErrorKind {
97        match self {
98            Self::Transport(_) | Self::StreamInterrupted(_) => LlmErrorKind::Unavailable,
99            Self::Provider { status, .. } => kind_from_http_status(*status),
100            Self::Other(_) => LlmErrorKind::Other,
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::{DummyError, LlmError};
108
109    /// Compile-time assertion: `E` implements [`LlmError`].
110    fn require_llm_error<E: LlmError>() {}
111
112    /// Compile-time assertion: `T` is `Send + Sync + 'static`.
113    fn assert_send_sync<T: Send + Sync + 'static>() {}
114
115    // --- Display -----------------------------------------------------------
116
117    #[test]
118    fn display_transport() {
119        let e = DummyError::Transport("DNS failure".to_owned());
120        assert_eq!(format!("{e}"), "transport: DNS failure");
121    }
122
123    #[test]
124    fn display_provider() {
125        let e = DummyError::Provider {
126            status: 404,
127            body: "not found".to_owned(),
128        };
129        assert_eq!(format!("{e}"), "provider returned status 404: not found");
130    }
131
132    #[test]
133    fn display_stream_interrupted() {
134        let e = DummyError::StreamInterrupted("EOF".to_owned());
135        assert_eq!(format!("{e}"), "stream interrupted: EOF");
136    }
137
138    #[test]
139    fn display_other() {
140        let e = DummyError::Other("unexpected".to_owned());
141        assert_eq!(format!("{e}"), "other: unexpected");
142    }
143
144    // --- Debug -------------------------------------------------------------
145
146    #[test]
147    fn debug_is_derived() {
148        let e = DummyError::Transport("t".to_owned());
149        assert!(format!("{e:?}").contains("Transport"));
150    }
151
152    // --- Trait-bound proofs (compile-time) ---------------------------------
153
154    #[test]
155    fn dummy_error_satisfies_llm_error() {
156        // DummyError: std::error::Error + Send + Sync + 'static
157        // → blanket impl grants DummyError: LlmError.
158        require_llm_error::<DummyError>();
159    }
160
161    #[test]
162    fn dummy_error_is_send_sync_static() {
163        assert_send_sync::<DummyError>();
164    }
165
166    #[test]
167    fn dummy_error_boxes_as_std_error() {
168        // Coercing to the trait object verifies std::error::Error + Send + Sync + 'static.
169        let _: Box<dyn std::error::Error + Send + Sync + 'static> =
170            Box::new(DummyError::Other("boxed".to_owned()));
171    }
172
173    #[test]
174    fn http_status_maps_to_kind() {
175        use super::{LlmErrorKind, kind_from_http_status};
176        assert_eq!(kind_from_http_status(429), LlmErrorKind::RateLimit);
177        assert_eq!(kind_from_http_status(504), LlmErrorKind::Timeout);
178        assert_eq!(kind_from_http_status(408), LlmErrorKind::Timeout);
179        assert_eq!(kind_from_http_status(401), LlmErrorKind::Auth);
180        assert_eq!(kind_from_http_status(403), LlmErrorKind::Auth);
181        assert_eq!(kind_from_http_status(400), LlmErrorKind::BadRequest);
182        assert_eq!(kind_from_http_status(404), LlmErrorKind::BadRequest);
183        assert_eq!(kind_from_http_status(503), LlmErrorKind::Unavailable);
184        assert_eq!(kind_from_http_status(200), LlmErrorKind::Other);
185    }
186
187    #[test]
188    fn dummy_error_classifies() {
189        use super::{LlmError, LlmErrorKind};
190        assert_eq!(
191            DummyError::Provider {
192                status: 429,
193                body: String::new()
194            }
195            .kind(),
196            LlmErrorKind::RateLimit
197        );
198        assert_eq!(
199            DummyError::Transport("reset".to_owned()).kind(),
200            LlmErrorKind::Unavailable
201        );
202        assert_eq!(
203            DummyError::Other("x".to_owned()).kind(),
204            LlmErrorKind::Other
205        );
206    }
207}