use std::fmt;
#[cfg(not(target_arch = "wasm32"))]
pub type BoxedSource = Box<dyn std::error::Error + Send + Sync + 'static>;
#[cfg(target_arch = "wasm32")]
pub type BoxedSource = Box<dyn std::error::Error + 'static>;
#[derive(Debug)]
pub struct Error {
kind: ErrorKind,
context: Option<String>,
oauth_error_code: Option<String>,
source: Option<BoxedSource>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
InvalidGrant,
ReauthRequired,
Transport {
retryable: bool,
},
Protocol,
Auth,
Dpop,
Config,
Crypto,
}
impl Error {
pub fn new(kind: ErrorKind, source: impl Into<BoxedSource>) -> Self {
Self {
kind,
context: None,
oauth_error_code: None,
source: Some(source.into()),
}
}
#[must_use]
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(match self.context {
Some(existing) => format!("{}: {existing}", context.into()),
None => context.into(),
});
self
}
#[must_use]
pub fn with_oauth_error_code(mut self, code: impl Into<String>) -> Self {
self.oauth_error_code = Some(code.into());
self
}
#[must_use]
pub fn kind(&self) -> ErrorKind {
self.kind
}
#[must_use]
pub fn oauth_error_code(&self) -> Option<&str> {
self.oauth_error_code.as_deref()
}
#[must_use]
pub fn is_retryable(&self) -> bool {
matches!(self.kind, ErrorKind::Transport { retryable: true })
}
#[must_use]
pub fn is_dpop_nonce_required(&self) -> bool {
self.oauth_error_code.as_deref() == Some("use_dpop_nonce")
}
}
impl From<ErrorKind> for Error {
fn from(kind: ErrorKind) -> Self {
Self {
kind,
context: None,
oauth_error_code: None,
source: None,
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(context) = &self.context {
write!(f, "{context}: ")?;
}
self.kind.fmt(f)?;
if let Some(code) = &self.oauth_error_code {
write!(f, " (oauth error code: {code})")?;
}
Ok(())
}
}
impl fmt::Display for ErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let description = match self {
Self::InvalidGrant => "the grant is no longer valid",
Self::ReauthRequired => "re-authorization is required",
Self::Transport { retryable: true } => "transient transport failure",
Self::Transport { retryable: false } => "transport failure",
Self::Protocol => "invalid or malformed server response",
Self::Auth => "client authentication construction failed",
Self::Dpop => "DPoP proof handling failed",
Self::Config => "invalid configuration",
Self::Crypto => "cryptographic operation failed",
};
f.write_str(description)
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_ref()
.map(|source| source.as_ref() as &(dyn std::error::Error + 'static))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug)]
struct Underlying;
impl fmt::Display for Underlying {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("underlying")
}
}
impl std::error::Error for Underlying {}
#[test]
fn kind_and_oauth_code_are_preserved() {
let err =
Error::new(ErrorKind::InvalidGrant, Underlying).with_oauth_error_code("invalid_grant");
assert_eq!(err.kind(), ErrorKind::InvalidGrant);
assert_eq!(err.oauth_error_code(), Some("invalid_grant"));
assert!(!err.is_retryable());
}
#[test]
fn retryable_follows_transport_classification() {
assert!(Error::from(ErrorKind::Transport { retryable: true }).is_retryable());
assert!(!Error::from(ErrorKind::Transport { retryable: false }).is_retryable());
assert!(!Error::from(ErrorKind::Crypto).is_retryable());
}
#[test]
fn source_chain_preserves_concrete_error() {
let err = Error::new(ErrorKind::Transport { retryable: false }, Underlying);
let source = std::error::Error::source(&err).expect("source set");
assert!(source.downcast_ref::<Underlying>().is_some());
let sourceless = Error::from(ErrorKind::Config);
assert!(std::error::Error::source(&sourceless).is_none());
}
#[test]
fn display_prefixes_context() {
let err = Error::from(ErrorKind::Transport { retryable: false })
.with_context("fetching https://as.example/jwks.json");
assert_eq!(
err.to_string(),
"fetching https://as.example/jwks.json: transport failure"
);
}
#[test]
fn context_layers_outermost_first() {
let err = Error::from(ErrorKind::Config)
.with_context("reading secret file /run/secret")
.with_context("fetching client secret");
assert_eq!(
err.to_string(),
"fetching client secret: reading secret file /run/secret: invalid configuration"
);
}
#[test]
fn display_includes_oauth_code() {
let err = Error::from(ErrorKind::InvalidGrant).with_oauth_error_code("invalid_grant");
assert_eq!(
err.to_string(),
"the grant is no longer valid (oauth error code: invalid_grant)"
);
}
}