Skip to main content

huskarl_core/
error.rs

1//! The concrete [`Error`] type used across the huskarl ecosystem.
2//!
3//! This follows the [`std::io::Error`] model: one non-generic struct carrying
4//! a matchable [`ErrorKind`], optional context, and a type-erased source.
5//! Programmatic handling — retry decisions, "re-run the interactive flow",
6//! surfacing the RFC 6749 error code — goes through [`ErrorKind`] and the
7//! accessors on [`Error`]; they are the stable contract.
8//!
9//! For the signal model applications follow (retry / back off / adjust /
10//! re-authenticate / fail) and notes on source chains and downcasting, see
11//! [the error model](crate::_docs::explanation::error_handling).
12
13use std::fmt;
14
15/// A type-erased error source.
16///
17/// `Send + Sync` on platforms that require it (everything except wasm32).
18#[cfg(not(target_arch = "wasm32"))]
19pub type BoxedSource = Box<dyn std::error::Error + Send + Sync + 'static>;
20
21/// A type-erased error source.
22///
23/// `Send + Sync` on platforms that require it (everything except wasm32).
24#[cfg(target_arch = "wasm32")]
25pub type BoxedSource = Box<dyn std::error::Error + 'static>;
26
27/// An error from a huskarl operation.
28///
29/// Carries a classification ([`kind`](Error::kind)), the raw OAuth error code
30/// when the server returned one
31/// ([`oauth_error_code`](Error::oauth_error_code)), and the underlying cause
32/// ([`source`](std::error::Error::source)).
33#[derive(Debug)]
34pub struct Error {
35    kind: ErrorKind,
36    context: Option<String>,
37    oauth_error_code: Option<String>,
38    source: Option<BoxedSource>,
39}
40
41/// Classification of an [`Error`].
42///
43/// Marked `#[non_exhaustive]`: match with a wildcard arm. Variants are kept
44/// coarse deliberately — additions are non-breaking, removals are not.
45///
46/// Most application code does not need to match individual variants: the
47/// three signals described in [the error
48/// model](crate::_docs::explanation::error_handling) (retry / re-authenticate /
49/// fail) are the intended consumption pattern.
50#[non_exhaustive]
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ErrorKind {
53    /// RFC 6749 §5.2 `invalid_grant` — the grant itself is dead.
54    ///
55    /// Seen when driving a grant directly. A token cache absorbs this kind:
56    /// it discards the rejected refresh token and reports
57    /// [`ReauthRequired`](Self::ReauthRequired) once no token source remains.
58    InvalidGrant,
59    /// No token can be obtained without re-running the interactive flow: the
60    /// refresh token is missing or was definitively rejected, and no usable
61    /// grant parameters remain.
62    ///
63    /// Transient failures are deliberately *not* classified as this kind —
64    /// they keep their retryable classification (see
65    /// [`Error::is_retryable`]), since a later call may succeed without user
66    /// involvement.
67    ReauthRequired,
68    /// Transport-level failure.
69    Transport {
70        /// If true, re-sending the request is known to be safe and may
71        /// succeed: either the request never reached the server, or it was
72        /// declared [`Idempotency::Idempotent`](crate::http::Idempotency)
73        /// and the failure was transient. Requests of
74        /// [unknown idempotency](crate::http::Idempotency::Unknown) are
75        /// only retryable when they provably never reached the server.
76        retryable: bool,
77    },
78    /// Malformed or invalid server response.
79    Protocol,
80    /// Client authentication could not be constructed.
81    Auth,
82    /// `DPoP` proof construction or handling failed.
83    DPoP,
84    /// Builder, URL, or other setup error.
85    Config,
86    /// Cryptographic operation failed.
87    Crypto,
88    /// The request was rejected for its parameters, but the credential is
89    /// intact (RFC 6749 §5.2 `invalid_scope`, RFC 8707 `invalid_target`).
90    ///
91    /// Recoverable by adjusting the request — narrowing the requested scope,
92    /// fixing the resource indicator — and retrying with the *same* credential.
93    /// Re-authentication does not help, and a token cache deliberately does
94    /// **not** discard the parameter source for this kind.
95    RequestRejected,
96    /// No token can be obtained right now, but the source expects the condition
97    /// to clear on its own: it is backing off after repeated non-recoverable
98    /// failures from scratch (for example an assertion signer that keeps
99    /// producing values the server rejects with `invalid_grant`).
100    ///
101    /// Distinct from its neighbours. Unlike [`ReauthRequired`](Self::ReauthRequired)
102    /// it does **not** call for re-running the interactive flow: a later
103    /// automatic call, after the source's cooldown, may succeed once the
104    /// underlying cause is fixed (a rotated signing key, a corrected clock).
105    /// Unlike a retryable [`Transport`](Self::Transport) failure, retrying
106    /// *immediately* will not help — wait for the cooldown before re-attempting.
107    /// In short: try again later, without user involvement.
108    Backoff,
109}
110
111impl Error {
112    /// Create an error of the given kind caused by `source`.
113    pub fn new(kind: ErrorKind, source: impl Into<BoxedSource>) -> Self {
114        Self {
115            kind,
116            context: None,
117            oauth_error_code: None,
118            source: Some(source.into()),
119        }
120    }
121
122    /// Attach human-readable context about the failed operation (for example
123    /// the endpoint being called). Shown as a prefix in the `Display` output.
124    ///
125    /// Layers: calling this on an error that already has context prefixes the
126    /// existing context, so outer operations read first
127    /// (`"fetching client secret: reading secret file /run/secret: ..."`).
128    #[must_use]
129    pub fn with_context(mut self, context: impl Into<String>) -> Self {
130        self.context = Some(match self.context {
131            Some(existing) => format!("{}: {existing}", context.into()),
132            None => context.into(),
133        });
134        self
135    }
136
137    /// Attach the raw OAuth error code returned by the server (see
138    /// [`oauth_error_code`](Self::oauth_error_code) for the codes this covers).
139    #[must_use]
140    pub fn with_oauth_error_code(mut self, code: impl Into<String>) -> Self {
141        self.oauth_error_code = Some(code.into());
142        self
143    }
144
145    /// The classification of this error.
146    #[must_use]
147    pub fn kind(&self) -> ErrorKind {
148        self.kind
149    }
150
151    /// The raw error code from the server's OAuth error response, if it returned
152    /// one.
153    ///
154    /// Most commonly an RFC 6749 §5.2 token-endpoint error code (e.g.
155    /// `invalid_grant`, `invalid_scope`), but the same field carries the
156    /// equivalent code from any OAuth error response, including extension
157    /// responses that reuse the `error` member — for example
158    /// [`use_dpop_nonce`](Self::is_dpop_nonce_required) (RFC 9449) or a dynamic
159    /// client registration error (RFC 7591 §3.2.2, such as
160    /// `invalid_redirect_uri`). It is the verbatim string from the server; match
161    /// on it only against codes defined by the protocol that produced the error.
162    #[must_use]
163    pub fn oauth_error_code(&self) -> Option<&str> {
164        self.oauth_error_code.as_deref()
165    }
166
167    /// If true, the failure is transient and the same call may succeed if
168    /// re-attempted (with backoff). No user involvement is needed; in
169    /// particular this is not a reason to re-run the interactive flow.
170    ///
171    /// See [the error model](crate::_docs::explanation::error_handling) for how
172    /// this composes with [`ErrorKind::ReauthRequired`].
173    #[must_use]
174    pub fn is_retryable(&self) -> bool {
175        matches!(self.kind, ErrorKind::Transport { retryable: true })
176    }
177
178    /// If true, the server requires a `DPoP` nonce (RFC 9449 §8) — the error
179    /// carries the `use_dpop_nonce` OAuth error code.
180    ///
181    /// The nonce value itself has already been recorded from the `DPoP-Nonce`
182    /// response header by the time this error propagates; retry the request
183    /// with freshly generated authentication parameters.
184    #[must_use]
185    pub fn is_dpop_nonce_required(&self) -> bool {
186        self.oauth_error_code.as_deref() == Some("use_dpop_nonce")
187    }
188}
189
190impl From<ErrorKind> for Error {
191    fn from(kind: ErrorKind) -> Self {
192        Self {
193            kind,
194            context: None,
195            oauth_error_code: None,
196            source: None,
197        }
198    }
199}
200
201impl fmt::Display for Error {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        if let Some(context) = &self.context {
204            write!(f, "{context}: ")?;
205        }
206        self.kind.fmt(f)?;
207        if let Some(code) = &self.oauth_error_code {
208            write!(f, " (oauth error code: {code})")?;
209        }
210        Ok(())
211    }
212}
213
214impl fmt::Display for ErrorKind {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        let description = match self {
217            Self::InvalidGrant => "the grant is no longer valid",
218            Self::RequestRejected => "the request parameters were rejected",
219            Self::ReauthRequired => "re-authorization is required",
220            Self::Backoff => "backing off after repeated failures",
221            Self::Transport { retryable: true } => "transient transport failure",
222            Self::Transport { retryable: false } => "transport failure",
223            Self::Protocol => "invalid or malformed server response",
224            Self::Auth => "client authentication construction failed",
225            Self::DPoP => "DPoP proof handling failed",
226            Self::Config => "invalid configuration",
227            Self::Crypto => "cryptographic operation failed",
228        };
229        f.write_str(description)
230    }
231}
232
233impl std::error::Error for Error {
234    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
235        self.source
236            .as_ref()
237            .map(|source| source.as_ref() as &(dyn std::error::Error + 'static))
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[derive(Debug)]
246    struct Underlying;
247
248    impl fmt::Display for Underlying {
249        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250            f.write_str("underlying")
251        }
252    }
253
254    impl std::error::Error for Underlying {}
255
256    #[test]
257    fn kind_and_oauth_code_are_preserved() {
258        let err =
259            Error::new(ErrorKind::InvalidGrant, Underlying).with_oauth_error_code("invalid_grant");
260        assert_eq!(err.kind(), ErrorKind::InvalidGrant);
261        assert_eq!(err.oauth_error_code(), Some("invalid_grant"));
262        assert!(!err.is_retryable());
263    }
264
265    #[test]
266    fn retryable_follows_transport_classification() {
267        assert!(Error::from(ErrorKind::Transport { retryable: true }).is_retryable());
268        assert!(!Error::from(ErrorKind::Transport { retryable: false }).is_retryable());
269        assert!(!Error::from(ErrorKind::Crypto).is_retryable());
270    }
271
272    #[test]
273    fn source_chain_preserves_concrete_error() {
274        let err = Error::new(ErrorKind::Transport { retryable: false }, Underlying);
275        let source = std::error::Error::source(&err).expect("source set");
276        assert!(source.downcast_ref::<Underlying>().is_some());
277
278        let sourceless = Error::from(ErrorKind::Config);
279        assert!(std::error::Error::source(&sourceless).is_none());
280    }
281
282    #[test]
283    fn display_prefixes_context() {
284        let err = Error::from(ErrorKind::Transport { retryable: false })
285            .with_context("fetching https://as.example/jwks.json");
286        assert_eq!(
287            err.to_string(),
288            "fetching https://as.example/jwks.json: transport failure"
289        );
290    }
291
292    #[test]
293    fn context_layers_outermost_first() {
294        let err = Error::from(ErrorKind::Config)
295            .with_context("reading secret file /run/secret")
296            .with_context("fetching client secret");
297        assert_eq!(
298            err.to_string(),
299            "fetching client secret: reading secret file /run/secret: invalid configuration"
300        );
301    }
302
303    #[test]
304    fn display_includes_oauth_code() {
305        let err = Error::from(ErrorKind::InvalidGrant).with_oauth_error_code("invalid_grant");
306        assert_eq!(
307            err.to_string(),
308            "the grant is no longer valid (oauth error code: invalid_grant)"
309        );
310    }
311}