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
101impl Error {
102    pub fn network(e: impl std::error::Error + Send + Sync + 'static) -> Self {
103        Error::Network(Arc::new(e))
104    }
105
106    pub fn decode(e: impl std::error::Error + Send + Sync + 'static) -> Self {
107        Error::Decode(Arc::new(e))
108    }
109
110    /// Whether retrying the *same* request against the *same* provider, after
111    /// backoff, could plausibly succeed.
112    pub fn is_retryable(&self) -> bool {
113        matches!(
114            self,
115            Error::RateLimited { .. } | Error::Network(_) | Error::Timeout | Error::Server { .. }
116        )
117    }
118
119    /// Whether this failure counts towards opening the circuit breaker.
120    /// Rate limiting is handled by backoff and does not count.
121    pub fn trips_breaker(&self) -> bool {
122        matches!(
123            self,
124            Error::Network(_) | Error::Timeout | Error::Server { .. }
125        )
126    }
127
128    pub fn category(&self) -> ErrorCategory {
129        match self {
130            Error::Network(_)
131            | Error::Timeout
132            | Error::Server { .. }
133            | Error::CircuitOpen { .. }
134            | Error::Offline => ErrorCategory::Unavailable,
135            Error::RateLimited { .. } | Error::QuotaExhausted { .. } => ErrorCategory::Throttled,
136            Error::AuthRejected { .. } => ErrorCategory::Auth,
137            Error::NotFound => ErrorCategory::NotFound,
138            Error::BadRequest(_) | Error::InvalidId(_) => ErrorCategory::Usage,
139            Error::Unsupported(_) => ErrorCategory::Unsupported,
140            Error::Decode(_) => ErrorCategory::Protocol,
141        }
142    }
143}
144
145fn retry_hint(d: &Option<Duration>) -> String {
146    d.map(|d| format!(" (retry after {}s)", d.as_secs()))
147        .unwrap_or_default()
148}
149
150fn reset_hint(t: &Option<Timestamp>) -> String {
151    t.map(|t| format!(" (resets at {t})")).unwrap_or_default()
152}
153
154fn message_hint(m: &Option<String>) -> String {
155    m.as_ref().map(|m| format!(": {m}")).unwrap_or_default()
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn rate_limits_retry_but_do_not_trip_the_breaker() {
164        let e = Error::RateLimited { retry_after: None };
165        assert!(e.is_retryable());
166        assert!(!e.trips_breaker());
167        assert_eq!(e.category(), ErrorCategory::Throttled);
168    }
169
170    #[test]
171    fn quota_and_auth_are_not_retryable() {
172        assert!(!Error::QuotaExhausted { resets_at: None }.is_retryable());
173        assert!(!Error::AuthRejected { message: None }.is_retryable());
174    }
175
176    #[test]
177    fn server_errors_retry_and_trip() {
178        let e = Error::Server {
179            status: 503,
180            message: Some("Service Unavailable".into()),
181        };
182        assert!(e.is_retryable() && e.trips_breaker());
183        assert_eq!(
184            e.to_string(),
185            "server error (HTTP 503): Service Unavailable"
186        );
187    }
188
189    #[test]
190    fn errors_are_cloneable_with_sources() {
191        let e = Error::network(std::io::Error::other("reset"));
192        let shared = e.clone();
193        assert_eq!(shared.to_string(), "network error: reset");
194        assert!(std::error::Error::source(&shared).is_some());
195    }
196}