1use std::sync::Arc;
12use std::time::Duration;
13
14use jiff::Timestamp;
15
16use crate::id::IdError;
17
18pub type Result<T, E = Error> = std::result::Result<T, E>;
19
20pub type Source = Arc<dyn std::error::Error + Send + Sync>;
22
23#[derive(Debug, Clone, thiserror::Error)]
24#[non_exhaustive]
25pub enum Error {
26 #[error("rate limited{}", retry_hint(.retry_after))]
28 RateLimited { retry_after: Option<Duration> },
29
30 #[error("usage quota exhausted{}", reset_hint(.resets_at))]
33 QuotaExhausted { resets_at: Option<Timestamp> },
34
35 #[error("network error: {0}")]
36 Network(#[source] Source),
37
38 #[error("request timed out")]
39 Timeout,
40
41 #[error("server error (HTTP {status}){}", message_hint(.message))]
42 Server {
43 status: u16,
44 message: Option<String>,
45 },
46
47 #[error("offline, and not in the cache")]
49 Offline,
50
51 #[error("provider temporarily disabled after repeated failures")]
53 CircuitOpen { until: Option<Timestamp> },
54
55 #[error("authentication rejected{}", message_hint(.message))]
57 AuthRejected { message: Option<String> },
58
59 #[error("not found")]
62 NotFound,
63
64 #[error("bad request: {0}")]
66 BadRequest(String),
67
68 #[error("unsupported: {0}")]
70 Unsupported(&'static str),
71
72 #[error("could not decode response: {0}")]
74 Decode(#[source] Source),
75
76 #[error(transparent)]
77 InvalidId(#[from] IdError),
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83#[non_exhaustive]
84pub enum ErrorCategory {
85 Unavailable,
87 Throttled,
89 Auth,
91 NotFound,
92 Usage,
94 Unsupported,
96 Protocol,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104#[non_exhaustive]
105pub enum ErrorKind {
106 RateLimited,
107 BudgetSpent,
109 Network,
110 Timeout,
111 Server,
113 NotCached,
115 CircuitOpen,
117 KeyRejected,
119 NotFound,
120 BadRequest,
121 Unsupported,
122 Decode,
124 InvalidId,
125}
126
127impl ErrorKind {
128 pub fn as_str(self) -> &'static str {
129 match self {
130 ErrorKind::RateLimited => "rate_limited",
131 ErrorKind::BudgetSpent => "budget_spent",
132 ErrorKind::Network => "network",
133 ErrorKind::Timeout => "timeout",
134 ErrorKind::Server => "server",
135 ErrorKind::NotCached => "not_cached",
136 ErrorKind::CircuitOpen => "circuit_open",
137 ErrorKind::KeyRejected => "key_rejected",
138 ErrorKind::NotFound => "not_found",
139 ErrorKind::BadRequest => "bad_request",
140 ErrorKind::Unsupported => "unsupported",
141 ErrorKind::Decode => "decode",
142 ErrorKind::InvalidId => "invalid_id",
143 }
144 }
145}
146
147impl Error {
148 pub fn network(e: impl std::error::Error + Send + Sync + 'static) -> Self {
149 Error::Network(Arc::new(e))
150 }
151
152 pub fn decode(e: impl std::error::Error + Send + Sync + 'static) -> Self {
153 Error::Decode(Arc::new(e))
154 }
155
156 pub fn is_retryable(&self) -> bool {
159 matches!(
160 self,
161 Error::RateLimited { .. } | Error::Network(_) | Error::Timeout | Error::Server { .. }
162 )
163 }
164
165 pub fn trips_breaker(&self) -> bool {
168 matches!(
169 self,
170 Error::Network(_) | Error::Timeout | Error::Server { .. }
171 )
172 }
173
174 pub fn kind(&self) -> ErrorKind {
175 match self {
176 Error::RateLimited { .. } => ErrorKind::RateLimited,
177 Error::QuotaExhausted { .. } => ErrorKind::BudgetSpent,
178 Error::Network(_) => ErrorKind::Network,
179 Error::Timeout => ErrorKind::Timeout,
180 Error::Server { .. } => ErrorKind::Server,
181 Error::Offline => ErrorKind::NotCached,
182 Error::CircuitOpen { .. } => ErrorKind::CircuitOpen,
183 Error::AuthRejected { .. } => ErrorKind::KeyRejected,
184 Error::NotFound => ErrorKind::NotFound,
185 Error::BadRequest(_) => ErrorKind::BadRequest,
186 Error::Unsupported(_) => ErrorKind::Unsupported,
187 Error::Decode(_) => ErrorKind::Decode,
188 Error::InvalidId(_) => ErrorKind::InvalidId,
189 }
190 }
191
192 pub fn category(&self) -> ErrorCategory {
193 match self {
194 Error::Network(_)
195 | Error::Timeout
196 | Error::Server { .. }
197 | Error::CircuitOpen { .. }
198 | Error::Offline => ErrorCategory::Unavailable,
199 Error::RateLimited { .. } | Error::QuotaExhausted { .. } => ErrorCategory::Throttled,
200 Error::AuthRejected { .. } => ErrorCategory::Auth,
201 Error::NotFound => ErrorCategory::NotFound,
202 Error::BadRequest(_) | Error::InvalidId(_) => ErrorCategory::Usage,
203 Error::Unsupported(_) => ErrorCategory::Unsupported,
204 Error::Decode(_) => ErrorCategory::Protocol,
205 }
206 }
207}
208
209fn retry_hint(d: &Option<Duration>) -> String {
210 d.map(|d| format!(" (retry after {}s)", d.as_secs()))
211 .unwrap_or_default()
212}
213
214fn reset_hint(t: &Option<Timestamp>) -> String {
215 t.map(|t| format!(" (resets at {t})")).unwrap_or_default()
216}
217
218fn message_hint(m: &Option<String>) -> String {
219 m.as_ref().map(|m| format!(": {m}")).unwrap_or_default()
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn rate_limits_retry_but_do_not_trip_the_breaker() {
228 let e = Error::RateLimited { retry_after: None };
229 assert!(e.is_retryable());
230 assert!(!e.trips_breaker());
231 assert_eq!(e.category(), ErrorCategory::Throttled);
232 }
233
234 #[test]
235 fn quota_and_auth_are_not_retryable() {
236 assert!(!Error::QuotaExhausted { resets_at: None }.is_retryable());
237 assert!(!Error::AuthRejected { message: None }.is_retryable());
238 }
239
240 #[test]
241 fn server_errors_retry_and_trip() {
242 let e = Error::Server {
243 status: 503,
244 message: Some("Service Unavailable".into()),
245 };
246 assert!(e.is_retryable() && e.trips_breaker());
247 assert_eq!(
248 e.to_string(),
249 "server error (HTTP 503): Service Unavailable"
250 );
251 }
252
253 #[test]
254 fn errors_are_cloneable_with_sources() {
255 let e = Error::network(std::io::Error::other("reset"));
256 let shared = e.clone();
257 assert_eq!(shared.to_string(), "network error: reset");
258 assert!(std::error::Error::source(&shared).is_some());
259 }
260}