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}