1use std::error::Error;
4use std::fmt;
5
6use http::StatusCode;
7use time::OffsetDateTime;
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11#[non_exhaustive]
12pub enum ErrorCategory {
13 Configuration,
15 Authentication,
17 Authorization,
19 NotFound,
21 Conflict,
23 Precondition,
25 Throttling,
27 Server,
29 Transport,
31 Tls,
33 Timeout,
35 Cancellation,
37 InvalidResponse,
39 OversizedResponse,
41 UnsupportedOperation,
43 Integrity,
45}
46
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum RetryClassification {
50 Never,
52 Retryable,
54 Throttled,
56}
57
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60#[non_exhaustive]
61pub enum TimeoutPhase {
62 Connect,
64 Request,
66 ResponseBody,
68 Operation,
70}
71
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74#[non_exhaustive]
75pub enum RetryStopReason {
76 NonRetryable,
78 NonReplayable,
80 AttemptsExhausted,
82 ElapsedLimit,
84 Deadline,
86 RetryQuota,
88}
89
90#[derive(Debug)]
91struct RedactedSource {
92 error_type: &'static str,
93}
94
95impl fmt::Display for RedactedSource {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 write!(
98 formatter,
99 "underlying {} error (details redacted)",
100 self.error_type
101 )
102 }
103}
104
105impl Error for RedactedSource {}
106
107pub struct S3Error {
113 details: Box<S3ErrorDetails>,
114}
115
116struct S3ErrorDetails {
117 category: ErrorCategory,
118 code: Option<String>,
119 status: Option<StatusCode>,
120 message: String,
121 request_id: Option<String>,
122 host_id: Option<String>,
123 retry: RetryClassification,
124 timeout_phase: Option<TimeoutPhase>,
125 server_time: Option<OffsetDateTime>,
126 clock_skew: Option<time::Duration>,
127 source: Option<RedactedSource>,
128 cleanup_failure: Option<S3Error>,
129 attempts: u32,
130 retry_stop_reason: Option<RetryStopReason>,
131}
132
133impl S3Error {
134 pub fn new(
136 category: ErrorCategory,
137 message: impl Into<String>,
138 retry: RetryClassification,
139 ) -> Self {
140 Self {
141 details: Box::new(S3ErrorDetails {
142 category,
143 code: None,
144 status: None,
145 message: message.into(),
146 request_id: None,
147 host_id: None,
148 retry,
149 timeout_phase: None,
150 server_time: None,
151 clock_skew: None,
152 source: None,
153 cleanup_failure: None,
154 attempts: 0,
155 retry_stop_reason: None,
156 }),
157 }
158 }
159
160 pub fn configuration(message: impl Into<String>) -> Self {
162 Self::new(
163 ErrorCategory::Configuration,
164 message,
165 RetryClassification::Never,
166 )
167 }
168
169 pub fn unsupported(message: impl Into<String>) -> Self {
171 Self::new(
172 ErrorCategory::UnsupportedOperation,
173 message,
174 RetryClassification::Never,
175 )
176 }
177
178 pub fn transport<E>(source: E) -> Self
180 where
181 E: Error + Send + Sync + 'static,
182 {
183 Self::new(
184 ErrorCategory::Transport,
185 "HTTP transport failed",
186 RetryClassification::Retryable,
187 )
188 .with_source(source)
189 }
190
191 pub fn timeout(phase: TimeoutPhase, message: impl Into<String>) -> Self {
193 let mut error = Self::new(
194 ErrorCategory::Timeout,
195 message,
196 RetryClassification::Retryable,
197 );
198 error.details.timeout_phase = Some(phase);
199 error
200 }
201
202 pub fn invalid_response(message: impl Into<String>) -> Self {
204 Self::new(
205 ErrorCategory::InvalidResponse,
206 message,
207 RetryClassification::Never,
208 )
209 }
210
211 pub fn integrity(message: impl Into<String>) -> Self {
213 Self::new(
214 ErrorCategory::Integrity,
215 message,
216 RetryClassification::Never,
217 )
218 }
219
220 pub fn cancellation(message: impl Into<String>) -> Self {
222 Self::new(
223 ErrorCategory::Cancellation,
224 message,
225 RetryClassification::Never,
226 )
227 }
228
229 pub(crate) fn with_service_details(
231 mut self,
232 code: Option<String>,
233 status: StatusCode,
234 request_id: Option<String>,
235 host_id: Option<String>,
236 ) -> Self {
237 self.details.code = code;
238 self.details.status = Some(status);
239 self.details.request_id = request_id;
240 self.details.host_id = host_id;
241 self
242 }
243
244 pub fn with_source<E>(mut self, _source: E) -> Self
246 where
247 E: Error + Send + Sync + 'static,
248 {
249 self.details.source = Some(RedactedSource {
250 error_type: std::any::type_name::<E>(),
251 });
252 self
253 }
254
255 pub(crate) fn with_cleanup_failure(mut self, cleanup_failure: Self) -> Self {
257 self.details.cleanup_failure = Some(cleanup_failure);
258 self
259 }
260
261 pub(crate) fn with_clock_skew(
263 mut self,
264 server_time: OffsetDateTime,
265 local_time: OffsetDateTime,
266 ) -> Self {
267 self.details.server_time = Some(server_time);
268 self.details.clock_skew = Some(server_time - local_time);
269 self
270 }
271
272 pub fn category(&self) -> ErrorCategory {
274 self.details.category
275 }
276
277 pub fn code(&self) -> Option<&str> {
279 self.details.code.as_deref()
280 }
281
282 pub fn status(&self) -> Option<u16> {
284 self.details.status.map(|status| status.as_u16())
285 }
286
287 pub fn message(&self) -> &str {
289 &self.details.message
290 }
291
292 pub fn request_id(&self) -> Option<&str> {
294 self.details.request_id.as_deref()
295 }
296
297 pub fn host_id(&self) -> Option<&str> {
299 self.details.host_id.as_deref()
300 }
301
302 pub fn retry_classification(&self) -> RetryClassification {
304 self.details.retry
305 }
306
307 pub fn timeout_phase(&self) -> Option<TimeoutPhase> {
309 self.details.timeout_phase
310 }
311
312 pub fn server_time(&self) -> Option<OffsetDateTime> {
314 self.details.server_time
315 }
316
317 pub fn clock_skew(&self) -> Option<time::Duration> {
319 self.details.clock_skew
320 }
321
322 pub fn cleanup_failure(&self) -> Option<&Self> {
324 self.details.cleanup_failure.as_ref()
325 }
326
327 pub fn attempts(&self) -> u32 {
329 self.details.attempts
330 }
331
332 pub fn retry_stop_reason(&self) -> Option<RetryStopReason> {
334 self.details.retry_stop_reason
335 }
336
337 pub(crate) fn with_retry_context(mut self, attempts: u32, reason: RetryStopReason) -> Self {
338 self.details.attempts = attempts;
339 self.details.retry_stop_reason = Some(reason);
340 self
341 }
342}
343
344impl fmt::Display for S3Error {
345 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
346 write!(
347 formatter,
348 "{:?}: {}",
349 self.details.category, self.details.message
350 )?;
351 if self.details.cleanup_failure.is_some() {
352 formatter.write_str(" (cleanup also failed)")?;
353 }
354 Ok(())
355 }
356}
357
358impl fmt::Debug for S3Error {
359 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
360 formatter
361 .debug_struct("S3Error")
362 .field("category", &self.details.category)
363 .field("code", &self.details.code)
364 .field("status", &self.details.status)
365 .field("message", &self.details.message)
366 .field("request_id", &self.details.request_id)
367 .field("host_id", &self.details.host_id)
368 .field("retry", &self.details.retry)
369 .field("attempts", &self.details.attempts)
370 .field("retry_stop_reason", &self.details.retry_stop_reason)
371 .field("timeout_phase", &self.details.timeout_phase)
372 .field("server_time", &self.details.server_time)
373 .field("clock_skew", &self.details.clock_skew)
374 .field(
375 "source",
376 &self.details.source.as_ref().map(|_| "[REDACTED]"),
377 )
378 .field(
379 "cleanup_failure",
380 &self.details.cleanup_failure.as_ref().map(|_| "[REDACTED]"),
381 )
382 .finish()
383 }
384}
385
386impl Error for S3Error {
387 fn source(&self) -> Option<&(dyn Error + 'static)> {
388 self.details
389 .source
390 .as_ref()
391 .map(|source| source as &(dyn Error + 'static))
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[derive(Debug)]
400 struct SensitiveSource;
401
402 impl fmt::Display for SensitiveSource {
403 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
404 formatter.write_str("secret=do-not-print")
405 }
406 }
407
408 impl Error for SensitiveSource {}
409
410 #[test]
411 fn public_error_is_pointer_sized() {
412 assert_eq!(std::mem::size_of::<S3Error>(), std::mem::size_of::<usize>());
413 }
414
415 #[test]
416 fn service_status_is_exposed_without_an_http_type() {
417 let error = S3Error::invalid_response("service error").with_service_details(
418 Some("NoSuchKey".to_owned()),
419 StatusCode::NOT_FOUND,
420 None,
421 None,
422 );
423 assert_eq!(error.status(), Some(404));
424 }
425
426 #[test]
427 fn source_and_cleanup_details_are_redacted() {
428 let error = S3Error::transport(SensitiveSource).with_cleanup_failure(
429 S3Error::invalid_response("cleanup detail must remain nested"),
430 );
431
432 let rendered = format!("{error:?} {error} {}", error.source().unwrap());
433 assert!(!rendered.contains("do-not-print"));
434 assert!(!rendered.contains("cleanup detail"));
435 assert!(rendered.contains("cleanup also failed"));
436 }
437}