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 engine's circuit breaker for this provider is open.
48    #[error("provider temporarily disabled after repeated failures")]
49    CircuitOpen { until: Option<Timestamp> },
50
51    /// Credentials missing, invalid or not yet activated. Retrying won't help.
52    #[error("authentication rejected{}", message_hint(.message))]
53    AuthRejected { message: Option<String> },
54
55    /// The addressed entity doesn't exist at this provider. Batch lookups
56    /// report per-item absence in their result instead.
57    #[error("not found")]
58    NotFound,
59
60    /// The provider rejected the request as malformed.
61    #[error("bad request: {0}")]
62    BadRequest(String),
63
64    /// The provider doesn't offer this operation or identifier kind.
65    #[error("unsupported: {0}")]
66    Unsupported(&'static str),
67
68    /// The response arrived but couldn't be understood.
69    #[error("could not decode response: {0}")]
70    Decode(#[source] Source),
71
72    #[error(transparent)]
73    InvalidId(#[from] IdError),
74}
75
76/// What a caller should do about an error. Stable: the CLI derives exit
77/// codes from it.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79#[non_exhaustive]
80pub enum ErrorCategory {
81    /// Transient; retrying later may succeed.
82    Unavailable,
83    /// Out of budget or rate; wait or use another provider.
84    Throttled,
85    /// Credentials need fixing.
86    Auth,
87    NotFound,
88    /// The caller's input or request was wrong.
89    Usage,
90    /// The provider can't do this.
91    Unsupported,
92    /// The provider sent something unexpected; likely an aspero bug or an
93    /// upstream API change.
94    Protocol,
95}
96
97impl Error {
98    pub fn network(e: impl std::error::Error + Send + Sync + 'static) -> Self {
99        Error::Network(Arc::new(e))
100    }
101
102    pub fn decode(e: impl std::error::Error + Send + Sync + 'static) -> Self {
103        Error::Decode(Arc::new(e))
104    }
105
106    /// Whether retrying the *same* request against the *same* provider, after
107    /// backoff, could plausibly succeed.
108    pub fn is_retryable(&self) -> bool {
109        matches!(
110            self,
111            Error::RateLimited { .. } | Error::Network(_) | Error::Timeout | Error::Server { .. }
112        )
113    }
114
115    /// Whether this failure counts towards opening the circuit breaker.
116    /// Rate limiting is handled by backoff and does not count.
117    pub fn trips_breaker(&self) -> bool {
118        matches!(
119            self,
120            Error::Network(_) | Error::Timeout | Error::Server { .. }
121        )
122    }
123
124    pub fn category(&self) -> ErrorCategory {
125        match self {
126            Error::Network(_)
127            | Error::Timeout
128            | Error::Server { .. }
129            | Error::CircuitOpen { .. } => ErrorCategory::Unavailable,
130            Error::RateLimited { .. } | Error::QuotaExhausted { .. } => ErrorCategory::Throttled,
131            Error::AuthRejected { .. } => ErrorCategory::Auth,
132            Error::NotFound => ErrorCategory::NotFound,
133            Error::BadRequest(_) | Error::InvalidId(_) => ErrorCategory::Usage,
134            Error::Unsupported(_) => ErrorCategory::Unsupported,
135            Error::Decode(_) => ErrorCategory::Protocol,
136        }
137    }
138}
139
140fn retry_hint(d: &Option<Duration>) -> String {
141    d.map(|d| format!(" (retry after {}s)", d.as_secs()))
142        .unwrap_or_default()
143}
144
145fn reset_hint(t: &Option<Timestamp>) -> String {
146    t.map(|t| format!(" (resets at {t})")).unwrap_or_default()
147}
148
149fn message_hint(m: &Option<String>) -> String {
150    m.as_ref().map(|m| format!(": {m}")).unwrap_or_default()
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn rate_limits_retry_but_do_not_trip_the_breaker() {
159        let e = Error::RateLimited { retry_after: None };
160        assert!(e.is_retryable());
161        assert!(!e.trips_breaker());
162        assert_eq!(e.category(), ErrorCategory::Throttled);
163    }
164
165    #[test]
166    fn quota_and_auth_are_not_retryable() {
167        assert!(!Error::QuotaExhausted { resets_at: None }.is_retryable());
168        assert!(!Error::AuthRejected { message: None }.is_retryable());
169    }
170
171    #[test]
172    fn server_errors_retry_and_trip() {
173        let e = Error::Server {
174            status: 503,
175            message: Some("Service Unavailable".into()),
176        };
177        assert!(e.is_retryable() && e.trips_breaker());
178        assert_eq!(
179            e.to_string(),
180            "server error (HTTP 503): Service Unavailable"
181        );
182    }
183
184    #[test]
185    fn errors_are_cloneable_with_sources() {
186        let e = Error::network(std::io::Error::other("reset"));
187        let shared = e.clone();
188        assert_eq!(shared.to_string(), "network error: reset");
189        assert!(std::error::Error::source(&shared).is_some());
190    }
191}