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}