use std::{fmt, time::Duration};
use aws_sdk_s3::error::SdkError;
use aws_smithy_types::error::metadata::ProvideErrorMetadata;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ConfigError {
MissingField(&'static str),
InvalidAccountId,
EmptyField(&'static str),
InvalidEnvironment(&'static str),
InvalidJurisdiction,
InvalidTimeout(&'static str),
InvalidAttempts(&'static str),
InconsistentTimeouts,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingField(field) => write!(f, "missing required R2 configuration: {field}"),
Self::InvalidAccountId => {
write!(
f,
"R2 account ID must be a 32-character hexadecimal identifier"
)
}
Self::EmptyField(field) => write!(f, "R2 configuration must not be empty: {field}"),
Self::InvalidEnvironment(field) => {
write!(f, "R2 environment variable is not valid Unicode: {field}")
}
Self::InvalidJurisdiction => write!(
f,
"R2 jurisdiction must be one of: default, eu, us, fedramp"
),
Self::InvalidTimeout(field) => {
write!(f, "R2 timeout must be greater than zero: {field}")
}
Self::InvalidAttempts(field) => {
write!(f, "R2 attempt limit must be at least one: {field}")
}
Self::InconsistentTimeouts => write!(
f,
"R2 operation_attempt_timeout must not exceed operation_timeout"
),
}
}
}
impl std::error::Error for ConfigError {}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ValidationError {
PartSizeOutOfRange {
provided: u64,
min: u64,
max: u64,
},
MultipartFileSizeZero,
MultipartObjectTooLarge {
provided: u64,
max: u64,
},
TooManyParts {
required: u64,
max: u16,
},
PartNumberOutOfRange {
provided: u16,
min: u16,
max: u16,
},
ConcurrencyOutOfRange {
provided: usize,
min: usize,
max: usize,
},
AttemptsOutOfRange {
provided: u8,
min: u8,
max: u8,
},
ManagedMemoryBudgetExceeded {
required: u64,
max: u64,
},
SingleUploadTooLarge {
provided: u64,
max: u64,
},
ListLimitOutOfRange {
provided: u16,
min: u16,
max: u16,
},
PresignExpiryOutOfRange {
provided: Duration,
min: Duration,
max: Duration,
},
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PartSizeOutOfRange { provided, min, max } => write!(
f,
"multipart part size {provided} bytes is outside the allowed range {min}..={max} bytes"
),
Self::MultipartFileSizeZero => {
write!(f, "multipart file size must be greater than zero")
}
Self::MultipartObjectTooLarge { provided, max } => write!(
f,
"multipart object size {provided} bytes exceeds the maximum {max} bytes"
),
Self::TooManyParts { required, max } => write!(
f,
"multipart plan requires {required} parts, exceeding the maximum {max}"
),
Self::PartNumberOutOfRange { provided, min, max } => write!(
f,
"multipart part number {provided} is outside the allowed range {min}..={max}"
),
Self::ConcurrencyOutOfRange { provided, min, max } => write!(
f,
"managed upload concurrency {provided} is outside the allowed range {min}..={max}"
),
Self::AttemptsOutOfRange { provided, min, max } => write!(
f,
"managed upload attempts {provided} is outside the allowed range {min}..={max}"
),
Self::ManagedMemoryBudgetExceeded { required, max } => write!(
f,
"managed upload may buffer {required} bytes, exceeding the configured memory budget of {max} bytes"
),
Self::SingleUploadTooLarge { provided, max } => write!(
f,
"single-request upload size {provided} bytes exceeds the maximum {max} bytes"
),
Self::ListLimitOutOfRange { provided, min, max } => write!(
f,
"list limit {provided} is outside the allowed range {min}..={max}"
),
Self::PresignExpiryOutOfRange { provided, min, max } => write!(
f,
"presigned request lifetime {provided:?} is outside the allowed range {min:?}..={max:?}"
),
}
}
}
impl std::error::Error for ValidationError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ServiceErrorKind {
Authentication,
PermissionDenied,
NotFound,
Conflict,
RateLimited,
Timeout,
Network,
Unavailable,
InvalidResponse,
Unknown,
}
impl ServiceErrorKind {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Authentication => "authentication failed",
Self::PermissionDenied => "permission denied",
Self::NotFound => "remote resource not found",
Self::Conflict => "remote state conflict",
Self::RateLimited => "rate limited",
Self::Timeout => "request timed out",
Self::Network => "network failure",
Self::Unavailable => "service unavailable",
Self::InvalidResponse => "invalid service response",
Self::Unknown => "unknown service failure",
}
}
const fn from_status(status: u16) -> Self {
match status {
401 => Self::Authentication,
403 => Self::PermissionDenied,
404 => Self::NotFound,
409 => Self::Conflict,
408 => Self::Timeout,
429 => Self::RateLimited,
500..=599 => Self::Unavailable,
_ => Self::Unknown,
}
}
fn from_code(code: &str) -> Option<Self> {
match code {
"Unauthorized" | "ExpiredRequest" | "SignatureDoesNotMatch" => {
Some(Self::Authentication)
}
"AccessDenied" | "NotEntitled" | "ObjectLockedByBucketPolicy" => {
Some(Self::PermissionDenied)
}
"NoSuchBucket" | "NoSuchKey" | "NoSuchUpload" => Some(Self::NotFound),
"BucketConflict" | "BucketNotEmpty" | "PreconditionFailed" => Some(Self::Conflict),
"TooManyRequests" | "SlowDown" => Some(Self::RateLimited),
"InternalError" | "ServiceUnavailable" => Some(Self::Unavailable),
_ => None,
}
}
}
impl fmt::Display for ServiceErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceError {
operation: &'static str,
kind: ServiceErrorKind,
status: Option<u16>,
}
impl ServiceError {
#[must_use]
pub const fn operation(&self) -> &'static str {
self.operation
}
#[must_use]
pub const fn kind(&self) -> ServiceErrorKind {
self.kind
}
#[must_use]
pub const fn status(&self) -> Option<u16> {
self.status
}
fn from_sdk<E: ProvideErrorMetadata>(operation: &'static str, error: &SdkError<E>) -> Self {
let status = error
.raw_response()
.map(|response| response.status().as_u16());
let service_code = match error {
SdkError::ServiceError(context) => context.err().code(),
_ => None,
};
let kind = service_code
.and_then(ServiceErrorKind::from_code)
.or_else(|| status.map(ServiceErrorKind::from_status))
.unwrap_or(match error {
SdkError::TimeoutError(_) => ServiceErrorKind::Timeout,
SdkError::DispatchFailure(_) => ServiceErrorKind::Network,
SdkError::ResponseError(_) => ServiceErrorKind::InvalidResponse,
_ => ServiceErrorKind::Unknown,
});
Self {
operation,
kind,
status,
}
}
}
impl fmt::Display for ServiceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "R2 {} failed: {}", self.operation, self.kind)?;
if let Some(status) = self.status {
write!(f, " (HTTP {status})")?;
}
Ok(())
}
}
impl std::error::Error for ServiceError {}
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
Config(ConfigError),
Validation(ValidationError),
InvalidInput {
field: &'static str,
reason: &'static str,
},
Service {
operation: &'static str,
},
Remote(ServiceError),
NotFound,
Io {
operation: &'static str,
},
Cancelled,
Presign,
InvalidSignedHeader,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Config(error) => error.fmt(f),
Self::Validation(error) => error.fmt(f),
Self::InvalidInput { field, reason } => write!(f, "invalid {field}: {reason}"),
Self::Service { operation } => write!(f, "R2 operation failed: {operation}"),
Self::Remote(error) => error.fmt(f),
Self::NotFound => write!(f, "R2 object was not found"),
Self::Io { operation } => write!(f, "local file operation failed: {operation}"),
Self::Cancelled => write!(f, "managed upload was cancelled"),
Self::Presign => write!(f, "failed to create a presigned R2 request"),
Self::InvalidSignedHeader => write!(f, "presigned request contains a non-text header"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Config(error) => Some(error),
Self::Validation(error) => Some(error),
Self::Remote(error) => Some(error),
_ => None,
}
}
}
impl Error {
pub(crate) fn remote<E: ProvideErrorMetadata>(
operation: &'static str,
error: &SdkError<E>,
) -> Self {
let error = ServiceError::from_sdk(operation, error);
crate::observability::remote_failure(&error);
Self::Remote(error)
}
}
impl From<ConfigError> for Error {
fn from(value: ConfigError) -> Self {
Self::Config(value)
}
}
impl From<ValidationError> for Error {
fn from(value: ValidationError) -> Self {
Self::Validation(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_http_status_without_exposing_response_details() {
let cases = [
(401, ServiceErrorKind::Authentication),
(403, ServiceErrorKind::PermissionDenied),
(404, ServiceErrorKind::NotFound),
(408, ServiceErrorKind::Timeout),
(409, ServiceErrorKind::Conflict),
(429, ServiceErrorKind::RateLimited),
(503, ServiceErrorKind::Unavailable),
(418, ServiceErrorKind::Unknown),
];
for (status, expected) in cases {
assert_eq!(ServiceErrorKind::from_status(status), expected);
}
}
#[test]
fn classifies_documented_r2_error_codes_before_falling_back_to_status() {
let cases = [
("SignatureDoesNotMatch", ServiceErrorKind::Authentication),
("AccessDenied", ServiceErrorKind::PermissionDenied),
("NoSuchUpload", ServiceErrorKind::NotFound),
("PreconditionFailed", ServiceErrorKind::Conflict),
("TooManyRequests", ServiceErrorKind::RateLimited),
("ServiceUnavailable", ServiceErrorKind::Unavailable),
];
for (code, expected) in cases {
assert_eq!(ServiceErrorKind::from_code(code), Some(expected));
}
assert_eq!(ServiceErrorKind::from_code("FutureR2Code"), None);
}
#[test]
fn classifies_sdk_timeout_without_formatting_its_source() {
let sdk_error: SdkError<aws_sdk_s3::operation::get_object::GetObjectError> =
SdkError::timeout_error(std::io::Error::other("sensitive-source"));
let error = ServiceError::from_sdk("GetObject", &sdk_error);
assert_eq!(error.kind(), ServiceErrorKind::Timeout);
assert_eq!(error.status(), None);
assert!(!format!("{error}").contains("sensitive-source"));
assert!(!format!("{error:?}").contains("sensitive-source"));
}
}