object_store/retry.rs
1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements. See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership. The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License. You may obtain a copy of the License at
8//
9// http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied. See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Retry policies for HTTP-backed multipart uploads
19//!
20//! Each multipart part first uses the backend's existing bounded request retry
21//! loop. If those retries are exhausted, a [`RetryPolicy`] can restart the
22//! entire part operation. Restarting at this boundary rebuilds the request,
23//! including fetching credentials and generating a new signature.
24//!
25//! Configure a policy with [`PutMultipartOptions::with_retry_policy`]. The
26//! policy applies independently to each part and does not change the retry
27//! behavior of multipart initiation, completion, or abort requests.
28
29use crate::client::retry::{RequestError, RetryError};
30use crate::client::{HttpError, HttpErrorKind};
31use crate::{Error, PutMultipartOptions};
32use async_trait::async_trait;
33use http::StatusCode;
34use std::error::Error as StdError;
35use std::fmt::Debug;
36use std::sync::Arc;
37use std::time::Duration;
38#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
39use std::time::Instant;
40#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
41use web_time::Instant;
42
43/// A normalized HTTP failure passed to a [`RetryPolicy`]
44///
45/// Backends wrap request failures in provider-specific [`Error`] variants.
46/// Before invoking a policy, `object_store` reduces a supported failure to an
47/// HTTP response status or transport error kind. Failures that cannot be
48/// represented by this enum are returned without invoking the policy.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50#[non_exhaustive]
51pub enum RetryFailure {
52 /// The HTTP status of a response that the backend classified as failed
53 ///
54 /// This is usually a non-success status. Some services, including S3, can
55 /// report an error in the body of a successful response. In that case this
56 /// contains the actual successful status, such as `200 OK`.
57 Status(StatusCode),
58
59 /// The request failed before producing a usable HTTP response
60 ///
61 /// The kind distinguishes failures such as connection errors, timeouts,
62 /// interrupted requests, and response decoding errors.
63 Transport(HttpErrorKind),
64}
65
66/// The failure and retry state for one operation attempt
67///
68/// For multipart uploads, an attempt is one complete call to upload a part,
69/// including its bounded request retries. Each part has an independent attempt
70/// count and elapsed time.
71//
72// Note: deliberately does not implement `Copy` so that non-`Copy` details,
73// such as the underlying error, can be added in the future without a
74// breaking change.
75#[derive(Debug, Clone, PartialEq, Eq)]
76#[non_exhaustive]
77pub struct RetryContext {
78 /// The normalized HTTP failure
79 pub failure: RetryFailure,
80
81 /// The failed operation attempt number, starting at one
82 pub attempt: usize,
83
84 /// Time since the first operation attempt started
85 ///
86 /// This includes time spent in requests and in earlier policy calls.
87 pub elapsed: Duration,
88}
89
90/// Controls whether and when a failed operation should be attempted again
91///
92/// The policy is shared by concurrently uploaded parts. Implementations must
93/// manage their own retry limit and backoff. `object_store` does not add a delay
94/// or impose an outer retry limit after this method returns `true`.
95#[async_trait]
96pub trait RetryPolicy: Debug + Send + Sync + 'static {
97 /// Decide whether to attempt the failed operation again
98 ///
99 /// Because this method is asynchronous, implementations can wait using
100 /// their own backoff strategy, clock, or sleeper before returning `true`.
101 /// Returning `false` returns the current operation error to the caller.
102 async fn retry(&self, context: RetryContext) -> bool;
103}
104
105#[derive(Clone)]
106struct MultipartRetryPolicy(Arc<dyn RetryPolicy>);
107
108impl PutMultipartOptions {
109 /// Retry failed multipart part uploads according to `policy`
110 ///
111 /// The policy runs after the existing bounded request retries are
112 /// exhausted. A retry repeats the same logical part from the provider
113 /// operation boundary, allowing credentials and request signatures to be
114 /// refreshed. Initiating, completing, and aborting the multipart upload
115 /// retain their existing retry behavior.
116 #[must_use]
117 pub fn with_retry_policy(mut self, policy: Arc<dyn RetryPolicy>) -> Self {
118 self.extensions.insert(MultipartRetryPolicy(policy));
119 self
120 }
121
122 pub(crate) fn retry_policy(&self) -> Option<Arc<dyn RetryPolicy>> {
123 self.extensions
124 .get::<MultipartRetryPolicy>()
125 .map(|policy| Arc::clone(&policy.0))
126 }
127}
128
129pub(crate) struct MultipartRetry {
130 policy: Option<Arc<dyn RetryPolicy>>,
131 attempt: usize,
132 start: Instant,
133}
134
135impl MultipartRetry {
136 pub(crate) fn new(policy: Option<Arc<dyn RetryPolicy>>) -> Self {
137 Self {
138 policy,
139 attempt: 0,
140 start: Instant::now(),
141 }
142 }
143
144 pub(crate) async fn should_retry(&mut self, error: &Error) -> bool {
145 let Some(policy) = self.policy.as_ref() else {
146 return false;
147 };
148 let Some(failure) = classify_http_failure(error) else {
149 return false;
150 };
151
152 self.attempt += 1;
153 policy
154 .retry(RetryContext {
155 failure,
156 attempt: self.attempt,
157 elapsed: self.start.elapsed(),
158 })
159 .await
160 }
161}
162
163/// Find and normalize an HTTP failure in an [`Error`] source chain.
164///
165/// Provider errors may wrap a terminal [`RetryError`] or [`HttpError`] several
166/// levels deep. Returns `None` when the chain contains no failure representable
167/// by [`RetryFailure`].
168fn classify_http_failure(error: &Error) -> Option<RetryFailure> {
169 let mut current: &(dyn StdError + 'static) = error;
170 loop {
171 if let Some(error) = current.downcast_ref::<RetryError>() {
172 return classify_request_error(error.inner());
173 }
174 if let Some(error) = current.downcast_ref::<HttpError>() {
175 return Some(RetryFailure::Transport(error.kind()));
176 }
177 current = current.source()?;
178 }
179}
180
181/// Normalize a terminal request error for a [`RetryPolicy`].
182///
183/// Error responses retain their actual status, including successful statuses
184/// whose bodies contain a provider error. A bare redirect has no status or
185/// transport kind to expose and is therefore not policy-controlled.
186fn classify_request_error(error: &RequestError) -> Option<RetryFailure> {
187 match error {
188 RequestError::Status { status, .. } | RequestError::Response { status, .. } => {
189 Some(RetryFailure::Status(*status))
190 }
191 RequestError::Http(error) => Some(RetryFailure::Transport(error.kind())),
192 RequestError::BareRedirect => None,
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use parking_lot::Mutex;
200
201 #[derive(Debug, Default)]
202 struct RecordingPolicy(Mutex<Vec<RetryContext>>);
203
204 #[async_trait]
205 impl RetryPolicy for RecordingPolicy {
206 async fn retry(&self, context: RetryContext) -> bool {
207 self.0.lock().push(context);
208 true
209 }
210 }
211
212 #[derive(Debug, thiserror::Error)]
213 #[error("test error")]
214 struct TestError;
215
216 #[tokio::test]
217 async fn retries_http_errors() {
218 let policy = Arc::new(RecordingPolicy::default());
219 let mut retry = MultipartRetry::new(Some(Arc::clone(&policy) as Arc<dyn RetryPolicy>));
220 let error = Error::Generic {
221 store: "test",
222 source: Box::new(HttpError::new(HttpErrorKind::Timeout, TestError)),
223 };
224
225 assert!(retry.should_retry(&error).await);
226
227 let contexts = policy.0.lock();
228 assert_eq!(contexts.len(), 1);
229 assert_eq!(contexts[0].attempt, 1);
230 assert_eq!(
231 contexts[0].failure,
232 RetryFailure::Transport(HttpErrorKind::Timeout)
233 );
234 }
235
236 #[tokio::test]
237 async fn does_not_retry_non_http_errors() {
238 let policy = Arc::new(RecordingPolicy::default());
239 let mut retry = MultipartRetry::new(Some(Arc::clone(&policy) as Arc<dyn RetryPolicy>));
240 let error = Error::Generic {
241 store: "test",
242 source: Box::new(TestError),
243 };
244
245 assert!(!retry.should_retry(&error).await);
246 assert!(policy.0.lock().is_empty());
247 }
248
249 #[test]
250 fn classifies_error_responses_with_success_status() {
251 let error = RequestError::Response {
252 status: StatusCode::OK,
253 body: "InternalError".into(),
254 };
255
256 assert_eq!(
257 classify_request_error(&error),
258 Some(RetryFailure::Status(StatusCode::OK))
259 );
260 }
261}