Skip to main content

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}