use std::time::Duration;
use thiserror::Error;
use crate::money::Money;
use crate::payment::PaymentStatus;
#[derive(Error)]
#[non_exhaustive]
pub enum Error {
#[error("transport error")]
Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("request timed out")]
Timeout,
#[error("rate limited")]
#[non_exhaustive]
RateLimited {
retry_after: Option<Duration>,
},
#[error("provider error {status}")]
#[non_exhaustive]
Api {
status: u16,
code: Option<String>,
title: String,
detail: Option<String>,
raw_body: Option<String>,
},
#[error("payment not found")]
#[non_exhaustive]
NotFound {
raw_body: Option<String>,
},
#[error("unauthorized: invalid or missing API credentials")]
#[non_exhaustive]
Unauthorized {
raw_body: Option<String>,
},
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error("amount mismatch: expected {expected}, got {actual}")]
AmountMismatch {
expected: Money,
actual: Money,
},
#[error("failed to decode provider response: {message}")]
#[non_exhaustive]
Decode {
message: String,
raw_body: Option<String>,
},
#[error("operation not supported by this provider")]
Unsupported,
#[error("payment not paid: current status is {status}")]
#[non_exhaustive]
NotPaid {
status: PaymentStatus,
},
}
impl Error {
#[must_use]
pub fn is_retriable(&self) -> bool {
match self {
Self::Transport(_) | Self::Timeout | Self::RateLimited { .. } => true,
Self::Api { status, .. } => (500..600).contains(status),
Self::NotFound { .. }
| Self::Unauthorized { .. }
| Self::InvalidRequest(_)
| Self::AmountMismatch { .. }
| Self::Decode { .. }
| Self::Unsupported
| Self::NotPaid { .. } => false,
}
}
#[must_use]
pub fn raw_body(&self) -> Option<&str> {
match self {
Self::Api { raw_body, .. }
| Self::Decode { raw_body, .. }
| Self::NotFound { raw_body }
| Self::Unauthorized { raw_body } => raw_body.as_deref(),
Self::Transport(_)
| Self::Timeout
| Self::RateLimited { .. }
| Self::InvalidRequest(_)
| Self::AmountMismatch { .. }
| Self::Unsupported
| Self::NotPaid { .. } => None,
}
}
#[must_use]
pub fn title(&self) -> Option<&str> {
match self {
Self::Api { title, .. } => Some(title),
Self::Transport(_)
| Self::Timeout
| Self::RateLimited { .. }
| Self::NotFound { .. }
| Self::Unauthorized { .. }
| Self::InvalidRequest(_)
| Self::AmountMismatch { .. }
| Self::Decode { .. }
| Self::Unsupported
| Self::NotPaid { .. } => None,
}
}
#[must_use]
pub fn detail(&self) -> Option<&str> {
match self {
Self::Api { detail, .. } => detail.as_deref(),
Self::Transport(_)
| Self::Timeout
| Self::RateLimited { .. }
| Self::NotFound { .. }
| Self::Unauthorized { .. }
| Self::InvalidRequest(_)
| Self::AmountMismatch { .. }
| Self::Decode { .. }
| Self::Unsupported
| Self::NotPaid { .. } => None,
}
}
#[must_use]
pub fn not_found() -> Self {
Self::NotFound { raw_body: None }
}
#[must_use]
pub fn unauthorized() -> Self {
Self::Unauthorized { raw_body: None }
}
#[must_use]
pub fn rate_limited(retry_after: Option<Duration>) -> Self {
Self::RateLimited { retry_after }
}
#[must_use]
pub fn api(status: u16, title: impl Into<String>) -> Self {
Self::Api {
status,
code: None,
title: title.into(),
detail: None,
raw_body: None,
}
}
#[must_use]
pub fn with_code(mut self, code: impl Into<String>) -> Self {
if let Self::Api { code: c, .. } = &mut self {
*c = Some(code.into());
}
self
}
#[must_use]
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
if let Self::Api { detail: d, .. } = &mut self {
*d = Some(detail.into());
}
self
}
#[must_use]
pub fn with_raw_body(mut self, raw_body: impl Into<String>) -> Self {
match &mut self {
Self::Api { raw_body: b, .. }
| Self::Decode { raw_body: b, .. }
| Self::NotFound { raw_body: b }
| Self::Unauthorized { raw_body: b } => {
*b = Some(raw_body.into());
}
Self::Transport(_)
| Self::Timeout
| Self::RateLimited { .. }
| Self::InvalidRequest(_)
| Self::AmountMismatch { .. }
| Self::Unsupported
| Self::NotPaid { .. } => {}
}
self
}
#[must_use]
pub fn decode(message: impl Into<String>) -> Self {
Self::Decode {
message: message.into(),
raw_body: None,
}
}
#[must_use]
pub fn not_paid(status: PaymentStatus) -> Self {
Self::NotPaid { status }
}
}
impl std::fmt::Debug for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Transport(_) => f
.debug_tuple("Transport")
.field(&"<source elided>")
.finish(),
Self::Timeout => write!(f, "Timeout"),
Self::RateLimited { retry_after } => f
.debug_struct("RateLimited")
.field("retry_after", retry_after)
.finish(),
Self::Api { status, code, .. } => f
.debug_struct("Api")
.field("status", status)
.field("code", code)
.field("title", &"<redacted>")
.field("detail", &"<redacted>")
.field("raw_body", &"<redacted>")
.finish(),
Self::NotFound { .. } => f
.debug_struct("NotFound")
.field("raw_body", &"<redacted>")
.finish(),
Self::Unauthorized { .. } => f
.debug_struct("Unauthorized")
.field("raw_body", &"<redacted>")
.finish(),
Self::InvalidRequest(message) => {
f.debug_tuple("InvalidRequest").field(message).finish()
}
Self::AmountMismatch { expected, actual } => f
.debug_struct("AmountMismatch")
.field("expected", expected)
.field("actual", actual)
.finish(),
Self::Decode { message, .. } => f
.debug_struct("Decode")
.field("message", message)
.field("raw_body", &"<redacted>")
.finish(),
Self::Unsupported => write!(f, "Unsupported"),
Self::NotPaid { status } => f.debug_struct("NotPaid").field("status", status).finish(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::money::Currency;
fn amount() -> Money {
Money::from_minor(1000, Currency::EUR)
}
#[test]
fn transport_is_retriable() {
let err = Error::Transport(Box::new(std::io::Error::other("boom")));
assert!(err.is_retriable());
}
#[test]
fn timeout_is_retriable() {
assert!(Error::Timeout.is_retriable());
}
#[test]
fn rate_limited_is_retriable() {
let err = Error::RateLimited {
retry_after: Some(Duration::from_secs(1)),
};
assert!(err.is_retriable());
assert!(Error::RateLimited { retry_after: None }.is_retriable());
}
#[test]
fn api_5xx_is_retriable() {
let err = Error::Api {
status: 503,
code: None,
title: "Service Unavailable".into(),
detail: None,
raw_body: None,
};
assert!(err.is_retriable());
}
#[test]
fn api_4xx_is_not_retriable() {
let err = Error::Api {
status: 401,
code: None,
title: "Unauthorized".into(),
detail: None,
raw_body: None,
};
assert!(!err.is_retriable());
}
#[test]
fn not_found_is_not_retriable() {
assert!(!Error::not_found().is_retriable());
}
#[test]
fn unauthorized_is_not_retriable() {
assert!(!Error::unauthorized().is_retriable());
}
#[test]
fn invalid_request_is_not_retriable() {
assert!(!Error::InvalidRequest("bad field".into()).is_retriable());
}
#[test]
fn amount_mismatch_is_not_retriable() {
let err = Error::AmountMismatch {
expected: amount(),
actual: Money::from_minor(500, Currency::EUR),
};
assert!(!err.is_retriable());
}
#[test]
fn decode_is_not_retriable() {
let err = Error::Decode {
message: "unexpected shape".into(),
raw_body: None,
};
assert!(!err.is_retriable());
}
#[test]
fn unsupported_is_not_retriable() {
assert!(!Error::Unsupported.is_retriable());
}
#[test]
fn not_paid_is_not_retriable() {
assert!(!Error::not_paid(crate::payment::PaymentStatus::Open).is_retriable());
}
#[test]
fn api_raw_body_is_reachable() {
let err = Error::Api {
status: 422,
code: None,
title: "Invalid".into(),
detail: None,
raw_body: Some("{\"raw\":true}".into()),
};
assert_eq!(err.raw_body(), Some("{\"raw\":true}"));
}
#[test]
fn decode_raw_body_is_reachable() {
let err = Error::Decode {
message: "bad json".into(),
raw_body: Some("not json".into()),
};
assert_eq!(err.raw_body(), Some("not json"));
}
#[test]
fn api_title_is_reachable() {
let err = Error::api(422, "Invalid request");
assert_eq!(err.title(), Some("Invalid request"));
}
#[test]
fn api_detail_is_reachable() {
let err = Error::api(422, "Invalid request").with_detail("amount is required");
assert_eq!(err.detail(), Some("amount is required"));
}
#[test]
fn api_detail_is_none_when_unset() {
let err = Error::api(422, "Invalid request");
assert_eq!(err.detail(), None);
}
#[test]
fn variants_without_a_title_or_detail_return_none() {
assert_eq!(Error::Timeout.title(), None);
assert_eq!(Error::Timeout.detail(), None);
assert_eq!(Error::not_found().title(), None);
assert_eq!(Error::not_found().detail(), None);
}
#[test]
fn not_found_and_unauthorized_carry_a_body_without_leaking_it() {
for err in [
Error::not_found().with_raw_body(FAKE_API_KEY),
Error::unauthorized().with_raw_body(FAKE_API_KEY),
] {
assert_eq!(err.raw_body(), Some(FAKE_API_KEY));
assert!(!format!("{err}").contains(FAKE_API_KEY));
assert!(!format!("{err:?}").contains(FAKE_API_KEY));
}
}
#[test]
fn variants_without_a_body_return_none() {
assert_eq!(Error::Timeout.raw_body(), None);
assert_eq!(Error::Unsupported.raw_body(), None);
assert_eq!(Error::not_found().raw_body(), None);
assert_eq!(Error::unauthorized().raw_body(), None);
assert_eq!(Error::InvalidRequest("x".into()).raw_body(), None);
assert_eq!(Error::RateLimited { retry_after: None }.raw_body(), None);
assert_eq!(
Error::Transport(Box::new(std::io::Error::other("boom"))).raw_body(),
None
);
assert_eq!(
Error::AmountMismatch {
expected: amount(),
actual: amount(),
}
.raw_body(),
None
);
}
#[test]
fn transport_exposes_a_source() {
let err = Error::Transport(Box::new(std::io::Error::other("boom")));
assert!(std::error::Error::source(&err).is_some());
}
#[test]
fn variants_without_an_inner_error_have_no_source() {
assert!(std::error::Error::source(&Error::Timeout).is_none());
assert!(std::error::Error::source(&Error::not_found()).is_none());
assert!(std::error::Error::source(&Error::unauthorized()).is_none());
}
const SENSITIVE_BODY: &str = "{\"card_number\":\"4111111111111111\"}";
#[test]
fn api_display_never_contains_raw_body() {
let err = Error::Api {
status: 422,
code: Some("invalid".into()),
title: "Invalid request".into(),
detail: Some("safe summary".into()),
raw_body: Some(SENSITIVE_BODY.into()),
};
assert!(!format!("{err}").contains(SENSITIVE_BODY));
}
#[test]
fn api_display_contains_neither_title_nor_detail() {
let err = Error::api(422, "Invalid request").with_detail("safe-looking summary");
let display = format!("{err}");
assert!(!display.contains("Invalid request"));
assert!(!display.contains("safe-looking summary"));
assert_eq!(display, "provider error 422");
}
#[test]
fn api_debug_contains_neither_title_nor_detail() {
let err = Error::api(422, "Invalid request").with_detail("safe-looking summary");
let debug = format!("{err:?}");
assert!(!debug.contains("Invalid request"));
assert!(!debug.contains("safe-looking summary"));
}
#[test]
fn decode_display_never_contains_raw_body() {
let err = Error::Decode {
message: "unexpected shape".into(),
raw_body: Some(SENSITIVE_BODY.into()),
};
assert!(!format!("{err}").contains(SENSITIVE_BODY));
}
const FAKE_API_KEY: &str = "sk_live_FAKETESTKEY1234567890";
#[test]
fn api_key_in_raw_body_never_leaks() {
let err = Error::Api {
status: 400,
code: None,
title: "Bad request".into(),
detail: None,
raw_body: Some(format!("Authorization: Bearer {FAKE_API_KEY}")),
};
assert!(!format!("{err}").contains(FAKE_API_KEY));
assert!(!format!("{err:?}").contains(FAKE_API_KEY));
}
#[test]
fn api_key_in_title_never_leaks() {
let err = Error::api(422, format!("Bearer {FAKE_API_KEY}"));
assert!(!format!("{err}").contains(FAKE_API_KEY));
assert!(!format!("{err:?}").contains(FAKE_API_KEY));
}
#[test]
fn api_key_in_detail_never_leaks() {
let err = Error::api(422, "Invalid request").with_detail(format!("Bearer {FAKE_API_KEY}"));
assert!(!format!("{err}").contains(FAKE_API_KEY));
assert!(!format!("{err:?}").contains(FAKE_API_KEY));
}
#[test]
fn api_key_in_decode_raw_body_never_leaks() {
let err = Error::Decode {
message: "unexpected shape".into(),
raw_body: Some(format!("Authorization: Bearer {FAKE_API_KEY}")),
};
assert!(!format!("{err}").contains(FAKE_API_KEY));
assert!(!format!("{err:?}").contains(FAKE_API_KEY));
}
#[test]
fn api_key_in_transport_source_never_leaks() {
#[derive(Debug)]
struct LeakySource;
impl std::fmt::Display for LeakySource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"connect to https://user:{FAKE_API_KEY}@example.com failed"
)
}
}
impl std::error::Error for LeakySource {}
let err = Error::Transport(Box::new(LeakySource));
assert!(!format!("{err}").contains(FAKE_API_KEY));
assert!(!format!("{err:?}").contains(FAKE_API_KEY));
}
#[test]
fn variants_without_any_sensitive_field_cannot_leak_a_key() {
for err in [
Error::Timeout,
Error::not_found(),
Error::unauthorized(),
Error::Unsupported,
Error::RateLimited { retry_after: None },
] {
assert!(!format!("{err}").contains(FAKE_API_KEY));
assert!(!format!("{err:?}").contains(FAKE_API_KEY));
}
}
#[test]
fn rate_limited_constructor_round_trips_the_field() {
let err = Error::rate_limited(Some(Duration::from_secs(5)));
match err {
Error::RateLimited { retry_after } => {
assert_eq!(retry_after, Some(Duration::from_secs(5)));
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[test]
fn api_constructor_and_setters_populate_every_field() {
let err = Error::api(422, "Unprocessable Entity")
.with_code("amount")
.with_detail("amount is required")
.with_raw_body("{\"field\":\"amount\"}");
match err {
Error::Api {
status,
code,
title,
detail,
raw_body,
} => {
assert_eq!(status, 422);
assert_eq!(code.as_deref(), Some("amount"));
assert_eq!(title, "Unprocessable Entity");
assert_eq!(detail.as_deref(), Some("amount is required"));
assert_eq!(raw_body.as_deref(), Some("{\"field\":\"amount\"}"));
}
other => panic!("expected Api, got {other:?}"),
}
}
#[test]
fn api_constructor_alone_leaves_optional_fields_unset() {
let err = Error::api(500, "Internal Server Error");
match err {
Error::Api {
status,
code,
title,
detail,
raw_body,
} => {
assert_eq!(status, 500);
assert_eq!(code, None);
assert_eq!(title, "Internal Server Error");
assert_eq!(detail, None);
assert_eq!(raw_body, None);
}
other => panic!("expected Api, got {other:?}"),
}
}
#[test]
fn with_code_with_detail_and_with_raw_body_are_no_ops_on_variants_that_do_not_carry_them() {
let err = Error::Timeout
.with_code("x")
.with_detail("y")
.with_raw_body("z");
assert!(matches!(err, Error::Timeout));
}
#[test]
fn decode_constructor_and_setter_populate_every_field() {
let err = Error::decode("unexpected shape").with_raw_body("not json");
match err {
Error::Decode { message, raw_body } => {
assert_eq!(message, "unexpected shape");
assert_eq!(raw_body.as_deref(), Some("not json"));
}
other => panic!("expected Decode, got {other:?}"),
}
}
#[test]
fn not_paid_constructor_round_trips_the_status() {
let err = Error::not_paid(crate::payment::PaymentStatus::Pending);
match err {
Error::NotPaid { status } => {
assert_eq!(status, crate::payment::PaymentStatus::Pending);
}
other => panic!("expected NotPaid, got {other:?}"),
}
}
#[test]
fn not_paid_display_includes_the_actual_status() {
let err = Error::not_paid(crate::payment::PaymentStatus::Open);
assert!(format!("{err}").contains("open"));
}
}