Skip to main content

s3_wire/error/
mod.rs

1//! Structured client errors.
2
3use std::error::Error;
4use std::fmt;
5
6use http::StatusCode;
7use time::OffsetDateTime;
8
9/// Broad category suitable for programmatic error handling.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11#[non_exhaustive]
12pub enum ErrorCategory {
13    /// Client configuration is invalid.
14    Configuration,
15    /// Credentials or a signature were rejected.
16    Authentication,
17    /// The authenticated principal is not permitted to perform the operation.
18    Authorization,
19    /// The requested bucket, object, or upload does not exist.
20    NotFound,
21    /// The request conflicts with existing S3 state.
22    Conflict,
23    /// A conditional request precondition failed.
24    Precondition,
25    /// S3 asked the caller to reduce its request rate.
26    Throttling,
27    /// S3 returned a server-side failure.
28    Server,
29    /// The HTTP transport failed.
30    Transport,
31    /// TLS negotiation or validation failed.
32    Tls,
33    /// A configured timeout expired.
34    Timeout,
35    /// The operation was cancelled.
36    Cancellation,
37    /// The service response was malformed or internally inconsistent.
38    InvalidResponse,
39    /// A bounded response exceeded its configured limit.
40    OversizedResponse,
41    /// The requested behavior is intentionally unsupported.
42    UnsupportedOperation,
43    /// A checksum, length, or other integrity check failed.
44    Integrity,
45}
46
47/// Whether an operation may be retried after an error.
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum RetryClassification {
50    /// Retrying is not expected to succeed.
51    Never,
52    /// The failure is transient, subject to operation replayability.
53    Retryable,
54    /// The service throttled the request, subject to operation replayability.
55    Throttled,
56}
57
58/// The phase in which a timeout occurred.
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60#[non_exhaustive]
61pub enum TimeoutPhase {
62    /// Establishing the network connection.
63    Connect,
64    /// Sending the request or waiting for response headers.
65    Request,
66    /// Waiting for progress while streaming a response body.
67    ResponseBody,
68    /// The overall operation deadline.
69    Operation,
70}
71
72/// Why a retryable request stopped without another attempt.
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74#[non_exhaustive]
75pub enum RetryStopReason {
76    /// The final error is not retryable.
77    NonRetryable,
78    /// The request body or operation cannot be safely replayed.
79    NonReplayable,
80    /// The configured attempt limit was reached.
81    AttemptsExhausted,
82    /// The configured retry elapsed-time limit was reached.
83    ElapsedLimit,
84    /// The operation deadline cannot accommodate another retry.
85    Deadline,
86    /// The client-wide retry quota was depleted.
87    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
107/// A structured S3 client error.
108///
109/// Formatting an error never prints its underlying source or cleanup error. Source
110/// errors are retained only as redacted type markers, preventing URLs, headers, or
111/// response data from escaping through an otherwise innocent log statement.
112pub 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    /// Creates an error with a category, safe message, and retry classification.
135    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    /// Creates a configuration error.
161    pub fn configuration(message: impl Into<String>) -> Self {
162        Self::new(
163            ErrorCategory::Configuration,
164            message,
165            RetryClassification::Never,
166        )
167    }
168
169    /// Creates an unsupported-operation error.
170    pub fn unsupported(message: impl Into<String>) -> Self {
171        Self::new(
172            ErrorCategory::UnsupportedOperation,
173            message,
174            RetryClassification::Never,
175        )
176    }
177
178    /// Creates a transport error while retaining only a redacted source marker.
179    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    /// Creates a timeout error.
192    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    /// Creates an invalid-response error.
203    pub fn invalid_response(message: impl Into<String>) -> Self {
204        Self::new(
205            ErrorCategory::InvalidResponse,
206            message,
207            RetryClassification::Never,
208        )
209    }
210
211    /// Creates an integrity error.
212    pub fn integrity(message: impl Into<String>) -> Self {
213        Self::new(
214            ErrorCategory::Integrity,
215            message,
216            RetryClassification::Never,
217        )
218    }
219
220    /// Creates a cancellation error.
221    pub fn cancellation(message: impl Into<String>) -> Self {
222        Self::new(
223            ErrorCategory::Cancellation,
224            message,
225            RetryClassification::Never,
226        )
227    }
228
229    /// Attaches parsed S3 response metadata.
230    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    /// Retains the source error's type while discarding potentially sensitive text.
245    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    /// Attaches a cleanup error without replacing the primary failure.
256    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    /// Attaches the server time and measured local clock offset.
262    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    /// Returns the broad error category.
273    pub fn category(&self) -> ErrorCategory {
274        self.details.category
275    }
276
277    /// Returns the S3 error code, when supplied by the service.
278    pub fn code(&self) -> Option<&str> {
279        self.details.code.as_deref()
280    }
281
282    /// Returns the numeric HTTP response status, when a response was received.
283    pub fn status(&self) -> Option<u16> {
284        self.details.status.map(|status| status.as_u16())
285    }
286
287    /// Returns the safe, caller-facing error message.
288    pub fn message(&self) -> &str {
289        &self.details.message
290    }
291
292    /// Returns the S3 request identifier, when supplied by the service.
293    pub fn request_id(&self) -> Option<&str> {
294        self.details.request_id.as_deref()
295    }
296
297    /// Returns the S3 host identifier, when supplied by the service.
298    pub fn host_id(&self) -> Option<&str> {
299        self.details.host_id.as_deref()
300    }
301
302    /// Returns the retry classification.
303    pub fn retry_classification(&self) -> RetryClassification {
304        self.details.retry
305    }
306
307    /// Returns the timeout phase, when this is a timeout error.
308    pub fn timeout_phase(&self) -> Option<TimeoutPhase> {
309        self.details.timeout_phase
310    }
311
312    /// Returns the server's reported time for a clock-skew failure.
313    pub fn server_time(&self) -> Option<OffsetDateTime> {
314        self.details.server_time
315    }
316
317    /// Returns `server_time - local_time` for a clock-skew failure.
318    pub fn clock_skew(&self) -> Option<time::Duration> {
319        self.details.clock_skew
320    }
321
322    /// Returns the cleanup error attached to a primary failure.
323    pub fn cleanup_failure(&self) -> Option<&Self> {
324        self.details.cleanup_failure.as_ref()
325    }
326
327    /// Returns the number of completed request attempts represented by this error.
328    pub fn attempts(&self) -> u32 {
329        self.details.attempts
330    }
331
332    /// Returns why the client did not make another retry attempt.
333    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}