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#[derive(Debug)]
73struct RedactedSource {
74    error_type: &'static str,
75}
76
77impl fmt::Display for RedactedSource {
78    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79        write!(
80            formatter,
81            "underlying {} error (details redacted)",
82            self.error_type
83        )
84    }
85}
86
87impl Error for RedactedSource {}
88
89/// A structured S3 client error.
90///
91/// Formatting an error never prints its underlying source or cleanup error. Source
92/// errors are retained only as redacted type markers, preventing URLs, headers, or
93/// response data from escaping through an otherwise innocent log statement.
94pub struct S3Error {
95    details: Box<S3ErrorDetails>,
96}
97
98struct S3ErrorDetails {
99    category: ErrorCategory,
100    code: Option<String>,
101    status: Option<StatusCode>,
102    message: String,
103    request_id: Option<String>,
104    host_id: Option<String>,
105    retry: RetryClassification,
106    timeout_phase: Option<TimeoutPhase>,
107    server_time: Option<OffsetDateTime>,
108    clock_skew: Option<time::Duration>,
109    source: Option<RedactedSource>,
110    cleanup_failure: Option<S3Error>,
111}
112
113impl S3Error {
114    /// Creates an error with a category, safe message, and retry classification.
115    pub fn new(
116        category: ErrorCategory,
117        message: impl Into<String>,
118        retry: RetryClassification,
119    ) -> Self {
120        Self {
121            details: Box::new(S3ErrorDetails {
122                category,
123                code: None,
124                status: None,
125                message: message.into(),
126                request_id: None,
127                host_id: None,
128                retry,
129                timeout_phase: None,
130                server_time: None,
131                clock_skew: None,
132                source: None,
133                cleanup_failure: None,
134            }),
135        }
136    }
137
138    /// Creates a configuration error.
139    pub fn configuration(message: impl Into<String>) -> Self {
140        Self::new(
141            ErrorCategory::Configuration,
142            message,
143            RetryClassification::Never,
144        )
145    }
146
147    /// Creates an unsupported-operation error.
148    pub fn unsupported(message: impl Into<String>) -> Self {
149        Self::new(
150            ErrorCategory::UnsupportedOperation,
151            message,
152            RetryClassification::Never,
153        )
154    }
155
156    /// Creates a transport error while retaining only a redacted source marker.
157    pub fn transport<E>(source: E) -> Self
158    where
159        E: Error + Send + Sync + 'static,
160    {
161        Self::new(
162            ErrorCategory::Transport,
163            "HTTP transport failed",
164            RetryClassification::Retryable,
165        )
166        .with_source(source)
167    }
168
169    /// Creates a timeout error.
170    pub fn timeout(phase: TimeoutPhase, message: impl Into<String>) -> Self {
171        let mut error = Self::new(
172            ErrorCategory::Timeout,
173            message,
174            RetryClassification::Retryable,
175        );
176        error.details.timeout_phase = Some(phase);
177        error
178    }
179
180    /// Creates an invalid-response error.
181    pub fn invalid_response(message: impl Into<String>) -> Self {
182        Self::new(
183            ErrorCategory::InvalidResponse,
184            message,
185            RetryClassification::Never,
186        )
187    }
188
189    /// Creates an integrity error.
190    pub fn integrity(message: impl Into<String>) -> Self {
191        Self::new(
192            ErrorCategory::Integrity,
193            message,
194            RetryClassification::Never,
195        )
196    }
197
198    /// Creates a cancellation error.
199    pub fn cancellation(message: impl Into<String>) -> Self {
200        Self::new(
201            ErrorCategory::Cancellation,
202            message,
203            RetryClassification::Never,
204        )
205    }
206
207    /// Attaches parsed S3 response metadata.
208    pub fn with_service_details(
209        mut self,
210        code: Option<String>,
211        status: StatusCode,
212        request_id: Option<String>,
213        host_id: Option<String>,
214    ) -> Self {
215        self.details.code = code;
216        self.details.status = Some(status);
217        self.details.request_id = request_id;
218        self.details.host_id = host_id;
219        self
220    }
221
222    /// Retains the source error's type while discarding potentially sensitive text.
223    pub fn with_source<E>(mut self, _source: E) -> Self
224    where
225        E: Error + Send + Sync + 'static,
226    {
227        self.details.source = Some(RedactedSource {
228            error_type: std::any::type_name::<E>(),
229        });
230        self
231    }
232
233    /// Attaches a cleanup error without replacing the primary failure.
234    pub fn with_cleanup_failure(mut self, cleanup_failure: Self) -> Self {
235        self.details.cleanup_failure = Some(cleanup_failure);
236        self
237    }
238
239    /// Attaches the server time and measured local clock offset.
240    pub fn with_clock_skew(
241        mut self,
242        server_time: OffsetDateTime,
243        local_time: OffsetDateTime,
244    ) -> Self {
245        self.details.server_time = Some(server_time);
246        self.details.clock_skew = Some(server_time - local_time);
247        self
248    }
249
250    /// Returns the broad error category.
251    pub fn category(&self) -> ErrorCategory {
252        self.details.category
253    }
254
255    /// Returns the S3 error code, when supplied by the service.
256    pub fn code(&self) -> Option<&str> {
257        self.details.code.as_deref()
258    }
259
260    /// Returns the HTTP response status, when a response was received.
261    pub fn status(&self) -> Option<StatusCode> {
262        self.details.status
263    }
264
265    /// Returns the safe, caller-facing error message.
266    pub fn message(&self) -> &str {
267        &self.details.message
268    }
269
270    /// Returns the S3 request identifier, when supplied by the service.
271    pub fn request_id(&self) -> Option<&str> {
272        self.details.request_id.as_deref()
273    }
274
275    /// Returns the S3 host identifier, when supplied by the service.
276    pub fn host_id(&self) -> Option<&str> {
277        self.details.host_id.as_deref()
278    }
279
280    /// Returns the retry classification.
281    pub fn retry_classification(&self) -> RetryClassification {
282        self.details.retry
283    }
284
285    /// Returns the timeout phase, when this is a timeout error.
286    pub fn timeout_phase(&self) -> Option<TimeoutPhase> {
287        self.details.timeout_phase
288    }
289
290    /// Returns the server's reported time for a clock-skew failure.
291    pub fn server_time(&self) -> Option<OffsetDateTime> {
292        self.details.server_time
293    }
294
295    /// Returns `server_time - local_time` for a clock-skew failure.
296    pub fn clock_skew(&self) -> Option<time::Duration> {
297        self.details.clock_skew
298    }
299
300    /// Returns the cleanup error attached to a primary failure.
301    pub fn cleanup_failure(&self) -> Option<&Self> {
302        self.details.cleanup_failure.as_ref()
303    }
304}
305
306impl fmt::Display for S3Error {
307    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
308        write!(
309            formatter,
310            "{:?}: {}",
311            self.details.category, self.details.message
312        )?;
313        if self.details.cleanup_failure.is_some() {
314            formatter.write_str(" (cleanup also failed)")?;
315        }
316        Ok(())
317    }
318}
319
320impl fmt::Debug for S3Error {
321    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
322        formatter
323            .debug_struct("S3Error")
324            .field("category", &self.details.category)
325            .field("code", &self.details.code)
326            .field("status", &self.details.status)
327            .field("message", &self.details.message)
328            .field("request_id", &self.details.request_id)
329            .field("host_id", &self.details.host_id)
330            .field("retry", &self.details.retry)
331            .field("timeout_phase", &self.details.timeout_phase)
332            .field("server_time", &self.details.server_time)
333            .field("clock_skew", &self.details.clock_skew)
334            .field(
335                "source",
336                &self.details.source.as_ref().map(|_| "[REDACTED]"),
337            )
338            .field(
339                "cleanup_failure",
340                &self.details.cleanup_failure.as_ref().map(|_| "[REDACTED]"),
341            )
342            .finish()
343    }
344}
345
346impl Error for S3Error {
347    fn source(&self) -> Option<&(dyn Error + 'static)> {
348        self.details
349            .source
350            .as_ref()
351            .map(|source| source as &(dyn Error + 'static))
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[derive(Debug)]
360    struct SensitiveSource;
361
362    impl fmt::Display for SensitiveSource {
363        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
364            formatter.write_str("secret=do-not-print")
365        }
366    }
367
368    impl Error for SensitiveSource {}
369
370    #[test]
371    fn public_error_is_pointer_sized() {
372        assert_eq!(std::mem::size_of::<S3Error>(), std::mem::size_of::<usize>());
373    }
374
375    #[test]
376    fn source_and_cleanup_details_are_redacted() {
377        let error = S3Error::transport(SensitiveSource).with_cleanup_failure(
378            S3Error::invalid_response("cleanup detail must remain nested"),
379        );
380
381        let rendered = format!("{error:?} {error} {}", error.source().unwrap());
382        assert!(!rendered.contains("do-not-print"));
383        assert!(!rendered.contains("cleanup detail"));
384        assert!(rendered.contains("cleanup also failed"));
385    }
386}