Skip to main content

aspero_core/
error.rs

1//! The error contract between providers, the engine and front ends.
2//!
3//! Variants are grouped by what the caller should *do*, not by where the
4//! failure happened. Providers map their HTTP/transport failures into these;
5//! the engine decides on retry, fallback or giving up from [`Error::is_retryable`]
6//! and [`Error::category`]; the CLI maps [`ErrorCategory`] to exit codes.
7//!
8//! `Error` is `Clone` (sources are behind `Arc`) so one in-flight request's
9//! result can be shared with every caller that was waiting on it.
10
11use 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
20/// A boxed, shareable underlying cause.
21pub type Source = Arc<dyn std::error::Error + Send + Sync>;
22
23#[derive(Debug, Clone, thiserror::Error)]
24#[non_exhaustive]
25pub enum Error {
26    /// Too many requests right now; back off and retry.
27    #[error("rate limited{}", retry_hint(.retry_after))]
28    RateLimited { retry_after: Option<Duration> },
29
30    /// The provider's usage budget (e.g. OpenAlex daily credits) is spent.
31    /// Not retryable until `resets_at`; fall back to other providers.
32    #[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    /// The caller asked for cached answers only, and there was none.
48    #[error("offline, and not in the cache")]
49    Offline,
50
51    /// The engine's circuit breaker for this provider is open.
52    #[error("provider temporarily disabled after repeated failures")]
53    CircuitOpen { until: Option<Timestamp> },
54
55    /// Credentials missing, invalid or not yet activated. Retrying won't help.
56    #[error("authentication rejected{}", message_hint(.message))]
57    AuthRejected { message: Option<String> },
58
59    /// The addressed entity doesn't exist at this provider. Batch lookups
60    /// report per-item absence in their result instead.
61    #[error("not found")]
62    NotFound,
63
64    /// The provider rejected the request as malformed.
65    #[error("bad request: {0}")]
66    BadRequest(String),
67
68    /// The provider doesn't offer this operation or identifier kind.
69    #[error("unsupported: {0}")]
70    Unsupported(&'static str),
71
72    /// The response arrived but couldn't be understood.
73    #[error("could not decode response: {0}")]
74    Decode(#[source] Source),
75
76    #[error(transparent)]
77    InvalidId(#[from] IdError),
78}
79
80/// What a caller should do about an error. Stable: the CLI derives exit
81/// codes from it.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83#[non_exhaustive]
84pub enum ErrorCategory {
85    /// Transient; retrying later may succeed.
86    Unavailable,
87    /// Out of budget or rate; wait or use another provider.
88    Throttled,
89    /// Credentials need fixing.
90    Auth,
91    NotFound,
92    /// The caller's input or request was wrong.
93    Usage,
94    /// The provider can't do this.
95    Unsupported,
96    /// The provider sent something unexpected; likely an aspero bug or an
97    /// upstream API change.
98    Protocol,
99}
100
101/// Exactly what went wrong, for front ends that word or localise it
102/// themselves. Finer than [`ErrorCategory`]; new kinds may be added.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104#[non_exhaustive]
105pub enum ErrorKind {
106    RateLimited,
107    /// The provider's usage budget is spent until it resets.
108    BudgetSpent,
109    Network,
110    Timeout,
111    /// The provider answered with a server error.
112    Server,
113    /// Offline, and not in the cache.
114    NotCached,
115    /// Skipped after repeated failures, for a cooldown.
116    CircuitOpen,
117    /// The API key (or other credential) was rejected.
118    KeyRejected,
119    NotFound,
120    BadRequest,
121    Unsupported,
122    /// The answer couldn't be understood.
123    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    /// Whether retrying the *same* request against the *same* provider, after
157    /// backoff, could plausibly succeed.
158    pub fn is_retryable(&self) -> bool {
159        matches!(
160            self,
161            Error::RateLimited { .. } | Error::Network(_) | Error::Timeout | Error::Server { .. }
162        )
163    }
164
165    /// Whether this failure counts towards opening the circuit breaker.
166    /// Rate limiting is handled by backoff and does not count.
167    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}