use std::error::Error;
use std::fmt;
use http::StatusCode;
use time::OffsetDateTime;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ErrorCategory {
Configuration,
Authentication,
Authorization,
NotFound,
Conflict,
Precondition,
Throttling,
Server,
Transport,
Tls,
Timeout,
Cancellation,
InvalidResponse,
OversizedResponse,
UnsupportedOperation,
Integrity,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RetryClassification {
Never,
Retryable,
Throttled,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum TimeoutPhase {
Connect,
Request,
ResponseBody,
Operation,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum RetryStopReason {
NonRetryable,
NonReplayable,
AttemptsExhausted,
ElapsedLimit,
Deadline,
RetryQuota,
}
#[derive(Debug)]
struct RedactedSource {
error_type: &'static str,
}
impl fmt::Display for RedactedSource {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"underlying {} error (details redacted)",
self.error_type
)
}
}
impl Error for RedactedSource {}
pub struct S3Error {
details: Box<S3ErrorDetails>,
}
struct S3ErrorDetails {
category: ErrorCategory,
code: Option<String>,
status: Option<StatusCode>,
message: String,
request_id: Option<String>,
host_id: Option<String>,
retry: RetryClassification,
timeout_phase: Option<TimeoutPhase>,
server_time: Option<OffsetDateTime>,
clock_skew: Option<time::Duration>,
source: Option<RedactedSource>,
cleanup_failure: Option<S3Error>,
attempts: u32,
retry_stop_reason: Option<RetryStopReason>,
}
impl S3Error {
pub fn new(
category: ErrorCategory,
message: impl Into<String>,
retry: RetryClassification,
) -> Self {
Self {
details: Box::new(S3ErrorDetails {
category,
code: None,
status: None,
message: message.into(),
request_id: None,
host_id: None,
retry,
timeout_phase: None,
server_time: None,
clock_skew: None,
source: None,
cleanup_failure: None,
attempts: 0,
retry_stop_reason: None,
}),
}
}
pub fn configuration(message: impl Into<String>) -> Self {
Self::new(
ErrorCategory::Configuration,
message,
RetryClassification::Never,
)
}
pub fn unsupported(message: impl Into<String>) -> Self {
Self::new(
ErrorCategory::UnsupportedOperation,
message,
RetryClassification::Never,
)
}
pub fn transport<E>(source: E) -> Self
where
E: Error + Send + Sync + 'static,
{
Self::new(
ErrorCategory::Transport,
"HTTP transport failed",
RetryClassification::Retryable,
)
.with_source(source)
}
pub fn timeout(phase: TimeoutPhase, message: impl Into<String>) -> Self {
let mut error = Self::new(
ErrorCategory::Timeout,
message,
RetryClassification::Retryable,
);
error.details.timeout_phase = Some(phase);
error
}
pub fn invalid_response(message: impl Into<String>) -> Self {
Self::new(
ErrorCategory::InvalidResponse,
message,
RetryClassification::Never,
)
}
pub fn integrity(message: impl Into<String>) -> Self {
Self::new(
ErrorCategory::Integrity,
message,
RetryClassification::Never,
)
}
pub fn cancellation(message: impl Into<String>) -> Self {
Self::new(
ErrorCategory::Cancellation,
message,
RetryClassification::Never,
)
}
pub(crate) fn with_service_details(
mut self,
code: Option<String>,
status: StatusCode,
request_id: Option<String>,
host_id: Option<String>,
) -> Self {
self.details.code = code;
self.details.status = Some(status);
self.details.request_id = request_id;
self.details.host_id = host_id;
self
}
pub fn with_source<E>(mut self, _source: E) -> Self
where
E: Error + Send + Sync + 'static,
{
self.details.source = Some(RedactedSource {
error_type: std::any::type_name::<E>(),
});
self
}
pub(crate) fn with_cleanup_failure(mut self, cleanup_failure: Self) -> Self {
self.details.cleanup_failure = Some(cleanup_failure);
self
}
pub(crate) fn with_clock_skew(
mut self,
server_time: OffsetDateTime,
local_time: OffsetDateTime,
) -> Self {
self.details.server_time = Some(server_time);
self.details.clock_skew = Some(server_time - local_time);
self
}
pub fn category(&self) -> ErrorCategory {
self.details.category
}
pub fn code(&self) -> Option<&str> {
self.details.code.as_deref()
}
pub fn status(&self) -> Option<u16> {
self.details.status.map(|status| status.as_u16())
}
pub fn message(&self) -> &str {
&self.details.message
}
pub fn request_id(&self) -> Option<&str> {
self.details.request_id.as_deref()
}
pub fn host_id(&self) -> Option<&str> {
self.details.host_id.as_deref()
}
pub fn retry_classification(&self) -> RetryClassification {
self.details.retry
}
pub fn timeout_phase(&self) -> Option<TimeoutPhase> {
self.details.timeout_phase
}
pub fn server_time(&self) -> Option<OffsetDateTime> {
self.details.server_time
}
pub fn clock_skew(&self) -> Option<time::Duration> {
self.details.clock_skew
}
pub fn cleanup_failure(&self) -> Option<&Self> {
self.details.cleanup_failure.as_ref()
}
pub fn attempts(&self) -> u32 {
self.details.attempts
}
pub fn retry_stop_reason(&self) -> Option<RetryStopReason> {
self.details.retry_stop_reason
}
pub(crate) fn with_retry_context(mut self, attempts: u32, reason: RetryStopReason) -> Self {
self.details.attempts = attempts;
self.details.retry_stop_reason = Some(reason);
self
}
}
impl fmt::Display for S3Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{:?}: {}",
self.details.category, self.details.message
)?;
if self.details.cleanup_failure.is_some() {
formatter.write_str(" (cleanup also failed)")?;
}
Ok(())
}
}
impl fmt::Debug for S3Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("S3Error")
.field("category", &self.details.category)
.field("code", &self.details.code)
.field("status", &self.details.status)
.field("message", &self.details.message)
.field("request_id", &self.details.request_id)
.field("host_id", &self.details.host_id)
.field("retry", &self.details.retry)
.field("attempts", &self.details.attempts)
.field("retry_stop_reason", &self.details.retry_stop_reason)
.field("timeout_phase", &self.details.timeout_phase)
.field("server_time", &self.details.server_time)
.field("clock_skew", &self.details.clock_skew)
.field(
"source",
&self.details.source.as_ref().map(|_| "[REDACTED]"),
)
.field(
"cleanup_failure",
&self.details.cleanup_failure.as_ref().map(|_| "[REDACTED]"),
)
.finish()
}
}
impl Error for S3Error {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.details
.source
.as_ref()
.map(|source| source as &(dyn Error + 'static))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug)]
struct SensitiveSource;
impl fmt::Display for SensitiveSource {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("secret=do-not-print")
}
}
impl Error for SensitiveSource {}
#[test]
fn public_error_is_pointer_sized() {
assert_eq!(std::mem::size_of::<S3Error>(), std::mem::size_of::<usize>());
}
#[test]
fn service_status_is_exposed_without_an_http_type() {
let error = S3Error::invalid_response("service error").with_service_details(
Some("NoSuchKey".to_owned()),
StatusCode::NOT_FOUND,
None,
None,
);
assert_eq!(error.status(), Some(404));
}
#[test]
fn source_and_cleanup_details_are_redacted() {
let error = S3Error::transport(SensitiveSource).with_cleanup_failure(
S3Error::invalid_response("cleanup detail must remain nested"),
);
let rendered = format!("{error:?} {error} {}", error.source().unwrap());
assert!(!rendered.contains("do-not-print"));
assert!(!rendered.contains("cleanup detail"));
assert!(rendered.contains("cleanup also failed"));
}
}