Skip to main content

caliban_provider/
error.rs

1//! Cross-provider error enum.
2
3use std::time::Duration;
4
5/// All errors that can be produced by a caliban provider.
6#[derive(thiserror::Error, Debug)]
7pub enum Error {
8    /// Authentication credentials were missing or rejected.
9    #[error("authentication failed: {0}")]
10    Auth(String),
11
12    /// The provider rejected the request due to rate limiting.
13    #[error("rate limit exceeded (retry after {retry_after:?})")]
14    RateLimit {
15        /// How long to wait before retrying, if known.
16        retry_after: Option<Duration>,
17    },
18
19    /// The request was structurally invalid.
20    #[error("invalid request: {0}")]
21    InvalidRequest(String),
22
23    /// The request exceeds the model's context window.
24    #[error("context too long: requested {requested_tokens} but max is {max_tokens}")]
25    ContextTooLong {
26        /// The model's maximum context size.
27        max_tokens: u32,
28        /// The number of tokens in the request.
29        requested_tokens: u32,
30    },
31
32    /// The requested model is not available via this provider.
33    #[error("model unavailable: {0}")]
34    ModelUnavailable(String),
35
36    /// The provider returned an HTTP error response.
37    #[error("server error (HTTP {status}): {body}")]
38    ServerError {
39        /// HTTP status code.
40        status: u16,
41        /// Response body text.
42        body: String,
43    },
44
45    /// The upstream model server reported an internal fault (process
46    /// crash, OOM kill, segfault, etc.) — distinct from `ServerError`
47    /// because the fault may arrive in-band (HTTP 200 + SSE error
48    /// payload, as LM Studio does when the model crashes mid-stream).
49    /// The fault is server-side, not request-side, so callers should
50    /// surface it as such rather than as `InvalidRequest`.
51    #[error("upstream server fault: {0}")]
52    UpstreamServerFault(String),
53
54    /// The response was blocked by a content-safety filter.
55    #[error("content filter triggered: {0}")]
56    ContentFilter(String),
57
58    /// A transport-level network error occurred.
59    #[error("network error: {0}")]
60    Network(Box<dyn std::error::Error + Send + Sync>),
61
62    /// The HTTP response body was severed mid-stream. Distinct from
63    /// `Network` because the request itself succeeded (the upstream
64    /// accepted it and started replying) — what failed was reading the
65    /// streaming body to completion. Typical triggers: TCP RST or FIN
66    /// from upstream while the SSE stream was in flight, idle teardown
67    /// by NAT/proxy, or a transient connection reset. The wrapped
68    /// string is the underlying transport error chain, captured at the
69    /// point the chunk read failed.
70    #[error("stream interrupted mid-response: {0}")]
71    StreamInterrupted(String),
72
73    /// The operation was cancelled before completion.
74    #[error("operation cancelled")]
75    Cancelled,
76
77    /// An adapter-specific error that does not fit other categories.
78    #[error("adapter error: {0}")]
79    Adapter(#[source] Box<dyn std::error::Error + Send + Sync>),
80
81    /// The streaming response went silent past the idle timeout.
82    #[error("stream idle for {0:?}")]
83    StreamIdle(std::time::Duration),
84}
85
86/// Convenience alias for `Result<T, Error>`.
87pub type Result<T> = std::result::Result<T, Error>;
88
89impl Error {
90    /// Wrap a network-layer error.
91    pub fn network(e: impl std::error::Error + Send + Sync + 'static) -> Self {
92        Self::Network(Box::new(e))
93    }
94
95    /// Wrap an adapter-specific error.
96    pub fn adapter(e: impl std::error::Error + Send + Sync + 'static) -> Self {
97        Self::Adapter(Box::new(e))
98    }
99
100    /// Wrap an upstream-severed-stream error. `inner` is rendered into
101    /// the message so the user-visible line reads "stream interrupted
102    /// mid-response: <source chain>".
103    pub fn stream_interrupted(inner: impl std::fmt::Display) -> Self {
104        Self::StreamInterrupted(inner.to_string())
105    }
106}
107
108/// Classify an error as authentication-shaped. Used by `RefreshingProvider`
109/// to decide whether to invalidate the cached API key and retry once.
110///
111/// Treats both the explicit `Error::Auth` variant and HTTP 401 / 403
112/// `ServerError`s as auth-shaped; everything else (rate limit, network,
113/// invalid request, etc.) is passed through unchanged.
114#[must_use]
115pub fn is_auth_error(err: &Error) -> bool {
116    match err {
117        Error::Auth(_) => true,
118        Error::ServerError { status, .. } => *status == 401 || *status == 403,
119        _ => false,
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn stream_interrupted_display_uses_clear_prefix() {
129        // Operator-visible line. Must not say "decoding response body"
130        // (the prior misleading reqwest phrasing); must say something a
131        // user can recognize as a transport-level cutoff.
132        let e = Error::stream_interrupted("hyper: connection reset by peer");
133        assert_eq!(
134            e.to_string(),
135            "stream interrupted mid-response: hyper: connection reset by peer"
136        );
137    }
138
139    #[test]
140    fn stream_interrupted_constructor_accepts_display() {
141        // Helper should accept anything Display-able, mirroring how
142        // `network` / `adapter` accept anything Error-able.
143        let io = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "eof");
144        let e = Error::stream_interrupted(io);
145        assert!(matches!(e, Error::StreamInterrupted(_)));
146        assert!(e.to_string().contains("eof"));
147    }
148
149    #[test]
150    fn is_auth_error_matches_explicit_auth_variant() {
151        assert!(is_auth_error(&Error::Auth("bad key".into())));
152    }
153
154    #[test]
155    fn is_auth_error_matches_server_error_401() {
156        assert!(is_auth_error(&Error::ServerError {
157            status: 401,
158            body: "unauthorized".into(),
159        }));
160    }
161
162    #[test]
163    fn is_auth_error_matches_server_error_403() {
164        assert!(is_auth_error(&Error::ServerError {
165            status: 403,
166            body: "forbidden".into(),
167        }));
168    }
169
170    #[test]
171    fn is_auth_error_rejects_other_server_errors() {
172        assert!(!is_auth_error(&Error::ServerError {
173            status: 500,
174            body: "boom".into(),
175        }));
176        assert!(!is_auth_error(&Error::ServerError {
177            status: 429,
178            body: "slow down".into(),
179        }));
180    }
181
182    #[test]
183    fn is_auth_error_rejects_unrelated_variants() {
184        assert!(!is_auth_error(&Error::RateLimit { retry_after: None }));
185        assert!(!is_auth_error(&Error::InvalidRequest("nope".into())));
186        assert!(!is_auth_error(&Error::Cancelled));
187        assert!(!is_auth_error(&Error::Network(Box::new(
188            std::io::Error::new(std::io::ErrorKind::ConnectionReset, "x")
189        ))));
190    }
191}