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(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
89pub 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 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 pub fn configuration(message: impl Into<String>) -> Self {
140 Self::new(
141 ErrorCategory::Configuration,
142 message,
143 RetryClassification::Never,
144 )
145 }
146
147 pub fn unsupported(message: impl Into<String>) -> Self {
149 Self::new(
150 ErrorCategory::UnsupportedOperation,
151 message,
152 RetryClassification::Never,
153 )
154 }
155
156 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 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 pub fn invalid_response(message: impl Into<String>) -> Self {
182 Self::new(
183 ErrorCategory::InvalidResponse,
184 message,
185 RetryClassification::Never,
186 )
187 }
188
189 pub fn integrity(message: impl Into<String>) -> Self {
191 Self::new(
192 ErrorCategory::Integrity,
193 message,
194 RetryClassification::Never,
195 )
196 }
197
198 pub fn cancellation(message: impl Into<String>) -> Self {
200 Self::new(
201 ErrorCategory::Cancellation,
202 message,
203 RetryClassification::Never,
204 )
205 }
206
207 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 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 pub fn with_cleanup_failure(mut self, cleanup_failure: Self) -> Self {
235 self.details.cleanup_failure = Some(cleanup_failure);
236 self
237 }
238
239 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 pub fn category(&self) -> ErrorCategory {
252 self.details.category
253 }
254
255 pub fn code(&self) -> Option<&str> {
257 self.details.code.as_deref()
258 }
259
260 pub fn status(&self) -> Option<StatusCode> {
262 self.details.status
263 }
264
265 pub fn message(&self) -> &str {
267 &self.details.message
268 }
269
270 pub fn request_id(&self) -> Option<&str> {
272 self.details.request_id.as_deref()
273 }
274
275 pub fn host_id(&self) -> Option<&str> {
277 self.details.host_id.as_deref()
278 }
279
280 pub fn retry_classification(&self) -> RetryClassification {
282 self.details.retry
283 }
284
285 pub fn timeout_phase(&self) -> Option<TimeoutPhase> {
287 self.details.timeout_phase
288 }
289
290 pub fn server_time(&self) -> Option<OffsetDateTime> {
292 self.details.server_time
293 }
294
295 pub fn clock_skew(&self) -> Option<time::Duration> {
297 self.details.clock_skew
298 }
299
300 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}