Skip to main content

llmr/
error.rs

1//! What can go wrong, in the shapes a caller can act on.
2
3use std::time::Duration;
4
5/// The result type every provider call returns.
6pub type Result<T> = std::result::Result<T, Error>;
7
8/// Why a call did not produce an answer.
9///
10/// The variants are chosen so a caller can decide what to do without reading the message.
11/// Retrying a bad credential earns a rate limit, and retrying a request the provider will
12/// never accept just spends the budget twice, so those cases are separated from the ones
13/// worth trying again.
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum Error {
17    /// The provider cannot do this at all. Ask [`crate::Provider::capabilities`] first.
18    #[error("unsupported: {0}")]
19    Unsupported(String),
20
21    /// The credential was rejected. Never retry this one.
22    #[error("authentication failed: {0}")]
23    Auth(String),
24
25    /// The model, endpoint or resource does not exist.
26    #[error("not found: {0}")]
27    NotFound(String),
28
29    /// The request was malformed, or the provider refused its shape.
30    #[error("invalid request: {0}")]
31    InvalidRequest(String),
32
33    /// Too many requests.
34    ///
35    /// Honour `retry_after` when it is present. A backoff you guessed locally only makes
36    /// the limit worse, because the provider is already telling you when it will clear.
37    #[error("rate limited{}", match .retry_after {
38        Some(d) => format!(", retry after {}ms", d.as_millis()),
39        None => String::new(),
40    })]
41    RateLimited {
42        /// How long the provider asked you to wait, when it said.
43        retry_after: Option<Duration>,
44    },
45
46    /// A deadline passed.
47    ///
48    /// Different from [`Error::Transient`] on purpose. The work may still be running on the
49    /// provider's side, so a retry can produce a second answer you are billed for.
50    #[error("timed out after {}ms", .elapsed.as_millis())]
51    Timeout {
52        /// How long the call ran before it was given up on.
53        elapsed: Duration,
54    },
55
56    /// The model declined to answer.
57    ///
58    /// Surfaced rather than retried. A refusal is an answer, and asking again in the hope
59    /// of a different one wastes a call and usually gets the same reply.
60    #[error("the model declined{}", match .category {
61        Some(c) => format!(": {c}"),
62        None => String::new(),
63    })]
64    Refused {
65        /// What the provider said it was declining, when it said.
66        category: Option<String>,
67    },
68
69    /// Something went wrong that is worth trying again.
70    #[error("transient: {0}")]
71    Transient(String),
72
73    /// The call was not made, because it would have taken the run over its cap.
74    ///
75    /// Never a provider's answer. Nothing was sent and nothing was billed: this is
76    /// [`crate::Budget`] refusing before the money goes, which is the only place a cap can
77    /// be enforced rather than reported.
78    ///
79    /// Not retryable. The same request against the same spent budget is refused again, and
80    /// what has to change is the cap or the run.
81    #[error("over budget: {0}")]
82    OverBudget(String),
83
84    /// The provider answered, and the answer could not be read.
85    ///
86    /// A successful status code with a body this crate cannot parse is a failure. Treating
87    /// it as an empty answer is how a run continues on nothing.
88    #[error("the provider replied and the reply could not be read: {0}")]
89    Unreadable(String),
90}
91
92impl Error {
93    /// Whether trying the same call again is reasonable.
94    ///
95    /// This is a hint, not a promise. It says the failure was not the caller's fault and
96    /// not permanent. It does not say the call is safe to repeat, which is a question about
97    /// your request rather than about the error. A [`Error::Timeout`] is retryable and may
98    /// still leave you paying for two answers.
99    pub fn is_retryable(&self) -> bool {
100        matches!(
101            self,
102            Error::RateLimited { .. } | Error::Timeout { .. } | Error::Transient(_)
103        )
104    }
105
106    /// How long the provider asked you to wait, when it said.
107    pub fn retry_after(&self) -> Option<Duration> {
108        match self {
109            Error::RateLimited { retry_after } => *retry_after,
110            _ => None,
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn a_bad_credential_is_never_retried() {
121        assert!(!Error::Auth("no key".into()).is_retryable());
122    }
123
124    #[test]
125    fn a_refusal_is_an_answer_rather_than_a_failure_to_retry() {
126        assert!(!Error::Refused { category: None }.is_retryable());
127    }
128
129    #[test]
130    fn an_unreadable_reply_is_not_retried_either() {
131        // The provider answered. Asking again gets the same body it could not parse.
132        assert!(!Error::Unreadable("missing content".into()).is_retryable());
133    }
134
135    #[test]
136    fn a_rate_limit_carries_the_wait_the_provider_asked_for() {
137        let limited = Error::RateLimited {
138            retry_after: Some(Duration::from_secs(30)),
139        };
140        assert!(limited.is_retryable());
141        assert_eq!(limited.retry_after(), Some(Duration::from_secs(30)));
142        assert!(limited.to_string().contains("30000ms"));
143    }
144}