This is an automated email from the ASF dual-hosted git repository.

alamb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs-object-store.git


The following commit(s) were added to refs/heads/main by this push:
     new c37db72  feat: retry failed multipart part uploads (#849)
c37db72 is described below

commit c37db72b92b78608291c1b33feb79bb5bb6d2d1c
Author: Chris <[email protected]>
AuthorDate: Wed Sep 9 11:43:46 2026 -0700

    feat: retry failed multipart part uploads (#849)
    
    * Retry multipart part uploads with configurable policies
    
    * Fix retry feature gate for HTTP-only builds
    
    * Remove Copy from RetryContext
    
    ---------
    
    Co-authored-by: Andrew Lamb <[email protected]>
---
 src/aws/mod.rs      | 108 +++++++++++++++++++---
 src/azure/client.rs |  16 +++-
 src/azure/mod.rs    |  28 +++++-
 src/gcp/mod.rs      |  25 +++--
 src/lib.rs          |   5 +-
 src/retry.rs        | 261 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 415 insertions(+), 28 deletions(-)

diff --git a/src/aws/mod.rs b/src/aws/mod.rs
index dd6c05e..8fcb440 100644
--- a/src/aws/mod.rs
+++ b/src/aws/mod.rs
@@ -44,6 +44,7 @@ use crate::client::CredentialProvider;
 use crate::client::get::GetClientExt;
 use crate::client::list::{ListClient, ListClientExt};
 use crate::multipart::{MultipartStore, PartId};
+use crate::retry::{MultipartRetry, RetryPolicy};
 use crate::signer::{SignedUrlOptions, Signer};
 use crate::util::{STRICT_ENCODE_SET, validate_signed_url_extras};
 use crate::{
@@ -313,6 +314,7 @@ impl ObjectStore for AmazonS3 {
         location: &Path,
         opts: PutMultipartOptions,
     ) -> Result<Box<dyn MultipartUpload>> {
+        let retry_policy = opts.retry_policy();
         let upload_id = self.client.create_multipart(location, opts).await?;
 
         Ok(Box::new(S3MultiPartUpload {
@@ -322,6 +324,7 @@ impl ObjectStore for AmazonS3 {
                 location: location.clone(),
                 upload_id: upload_id.clone(),
                 parts: Default::default(),
+                retry_policy,
             }),
         }))
     }
@@ -498,6 +501,7 @@ struct UploadState {
     location: Path,
     upload_id: String,
     client: Arc<S3Client>,
+    retry_policy: Option<Arc<dyn RetryPolicy>>,
 }
 
 #[async_trait]
@@ -507,17 +511,26 @@ impl MultipartUpload for S3MultiPartUpload {
         self.part_idx += 1;
         let state = Arc::clone(&self.state);
         Box::pin(async move {
-            let part = state
-                .client
-                .put_part(
-                    &state.location,
-                    &state.upload_id,
-                    idx,
-                    PutPartPayload::Part(data),
-                )
-                .await?;
-            state.parts.put(idx, part);
-            Ok(())
+            let mut retry = MultipartRetry::new(state.retry_policy.clone());
+            loop {
+                match state
+                    .client
+                    .put_part(
+                        &state.location,
+                        &state.upload_id,
+                        idx,
+                        PutPartPayload::Part(data.clone()),
+                    )
+                    .await
+                {
+                    Ok(part) => {
+                        state.parts.put(idx, part);
+                        return Ok(());
+                    }
+                    Err(error) if retry.should_retry(&error).await => continue,
+                    Err(error) => return Err(error),
+                }
+            }
         })
     }
 
@@ -727,6 +740,8 @@ mod tests {
     #[cfg(feature = "reqwest")]
     use crate::client::SpawnedReqwestConnector;
     use crate::client::get::GetClient;
+    #[cfg(feature = "reqwest")]
+    use crate::client::mock_server::MockServer;
     use crate::client::retry::RetryContext;
     use crate::integration::*;
     use crate::tests::*;
@@ -734,6 +749,12 @@ mod tests {
     use base64::prelude::BASE64_STANDARD;
     use http::HeaderMap;
     use http::HeaderValue;
+    #[cfg(feature = "reqwest")]
+    use http::Response;
+    #[cfg(feature = "reqwest")]
+    use http::header::ETAG;
+    #[cfg(feature = "reqwest")]
+    use std::sync::atomic::{AtomicUsize, Ordering};
 
     const NON_EXISTENT_NAME: &str = "nonexistentname";
 
@@ -779,6 +800,71 @@ mod tests {
         store
     }
 
+    #[cfg(feature = "reqwest")]
+    #[derive(Debug, Default)]
+    struct RetryOnce(AtomicUsize);
+
+    #[cfg(feature = "reqwest")]
+    #[async_trait]
+    impl RetryPolicy for RetryOnce {
+        async fn retry(&self, _: crate::retry::RetryContext) -> bool {
+            self.0.fetch_add(1, Ordering::SeqCst) == 0
+        }
+    }
+
+    #[cfg(feature = "reqwest")]
+    #[tokio::test]
+    async fn retries_multipart_part_operation() {
+        let mock = MockServer::new().await;
+        mock.push(
+            Response::builder()
+                .status(StatusCode::OK)
+                .body(
+                    
"<InitiateMultipartUploadResult><UploadId>upload-id</UploadId></InitiateMultipartUploadResult>"
+                        .to_string(),
+                )
+                .unwrap(),
+        );
+        mock.push(
+            Response::builder()
+                .status(StatusCode::SERVICE_UNAVAILABLE)
+                .body(String::new())
+                .unwrap(),
+        );
+        mock.push(
+            Response::builder()
+                .status(StatusCode::OK)
+                .header(ETAG, "etag")
+                .body(String::new())
+                .unwrap(),
+        );
+
+        let store = AmazonS3Builder::new()
+            .with_endpoint(mock.url())
+            .with_bucket_name("test-bucket")
+            .with_region("us-east-1")
+            .with_allow_http(true)
+            .with_skip_signature(true)
+            .with_retry(crate::RetryConfig {
+                max_retries: 0,
+                ..Default::default()
+            })
+            .build()
+            .unwrap();
+        let policy = Arc::new(RetryOnce::default());
+        let opts = PutMultipartOptions::default()
+            .with_retry_policy(Arc::clone(&policy) as Arc<dyn RetryPolicy>);
+
+        let mut upload = store
+            .put_multipart_opts(&Path::from("multipart"), opts)
+            .await
+            .unwrap();
+        upload.put_part(PutPayload::from("data")).await.unwrap();
+
+        assert_eq!(policy.0.load(Ordering::SeqCst), 1);
+        mock.shutdown().await;
+    }
+
     #[tokio::test]
     async fn write_multipart_file_with_signature() {
         maybe_skip_integration!();
diff --git a/src/azure/client.rs b/src/azure/client.rs
index c5b0a52..54da8c1 100644
--- a/src/azure/client.rs
+++ b/src/azure/client.rs
@@ -780,16 +780,20 @@ impl AzureClient {
         )
     }
 
+    /// Generate the identity of a block in a multipart upload
+    pub(crate) fn new_block_id() -> String {
+        let block_id = u128::from_be_bytes(rand::rng().random());
+        format!("{block_id:032x}")
+    }
+
     /// PUT a block 
<https://learn.microsoft.com/en-us/rest/api/storageservices/put-block>
     pub(crate) async fn put_block(
         &self,
         path: &Path,
-        _part_idx: usize,
+        content_id: &str,
         payload: PutPayload,
     ) -> Result<PartId> {
-        let part_idx = u128::from_be_bytes(rand::rng().random());
-        let content_id = format!("{part_idx:032x}");
-        let block_id = BASE64_STANDARD.encode(&content_id);
+        let block_id = BASE64_STANDARD.encode(content_id);
 
         self.put_request(path, payload)
             .query(&[("comp", "block"), ("blockid", &block_id)])
@@ -797,7 +801,9 @@ impl AzureClient {
             .send()
             .await?;
 
-        Ok(PartId { content_id })
+        Ok(PartId {
+            content_id: content_id.into(),
+        })
     }
 
     /// PUT a block list 
<https://learn.microsoft.com/en-us/rest/api/storageservices/put-block-list>
diff --git a/src/azure/mod.rs b/src/azure/mod.rs
index 91e4a96..e13fe0a 100644
--- a/src/azure/mod.rs
+++ b/src/azure/mod.rs
@@ -45,6 +45,7 @@ use url::Url;
 use crate::client::get::GetClientExt;
 use crate::client::list::{ListClient, ListClientExt};
 use crate::client::{CredentialProvider, crypto_provider};
+use crate::retry::{MultipartRetry, RetryPolicy};
 pub use credential::{AzureAccessKey, AzureAuthorizer, authority_hosts};
 
 mod builder;
@@ -106,6 +107,7 @@ impl ObjectStore for MicrosoftAzure {
         location: &Path,
         opts: PutMultipartOptions,
     ) -> Result<Box<dyn MultipartUpload>> {
+        let retry_policy = opts.retry_policy();
         Ok(Box::new(AzureMultiPartUpload {
             part_idx: 0,
             opts,
@@ -113,6 +115,7 @@ impl ObjectStore for MicrosoftAzure {
                 client: Arc::clone(&self.client),
                 location: location.clone(),
                 parts: Default::default(),
+                retry_policy,
             }),
         }))
     }
@@ -279,6 +282,7 @@ struct UploadState {
     location: Path,
     parts: Parts,
     client: Arc<AzureClient>,
+    retry_policy: Option<Arc<dyn RetryPolicy>>,
 }
 
 #[async_trait]
@@ -287,10 +291,23 @@ impl MultipartUpload for AzureMultiPartUpload {
         let idx = self.part_idx;
         self.part_idx += 1;
         let state = Arc::clone(&self.state);
+        let content_id = AzureClient::new_block_id();
         Box::pin(async move {
-            let part = state.client.put_block(&state.location, idx, 
data).await?;
-            state.parts.put(idx, part);
-            Ok(())
+            let mut retry = MultipartRetry::new(state.retry_policy.clone());
+            loop {
+                match state
+                    .client
+                    .put_block(&state.location, &content_id, data.clone())
+                    .await
+                {
+                    Ok(part) => {
+                        state.parts.put(idx, part);
+                        return Ok(());
+                    }
+                    Err(error) if retry.should_retry(&error).await => continue,
+                    Err(error) => return Err(error),
+                }
+            }
         })
     }
 
@@ -392,10 +409,11 @@ impl MultipartStore for MicrosoftAzure {
         &self,
         path: &Path,
         _: &MultipartId,
-        part_idx: usize,
+        _: usize,
         data: PutPayload,
     ) -> Result<PartId> {
-        self.client.put_block(path, part_idx, data).await
+        let content_id = AzureClient::new_block_id();
+        self.client.put_block(path, &content_id, data).await
     }
 
     async fn complete_multipart(
diff --git a/src/gcp/mod.rs b/src/gcp/mod.rs
index 2dbb0c7..d33b7e8 100644
--- a/src/gcp/mod.rs
+++ b/src/gcp/mod.rs
@@ -43,6 +43,7 @@ use std::time::Duration;
 use crate::CopyOptions;
 use crate::client::{CredentialProvider, crypto_provider};
 use crate::gcp::credential::GCSAuthorizer;
+use crate::retry::{MultipartRetry, RetryPolicy};
 use crate::signer::{SignedUrlOptions, Signer};
 use crate::util::validate_signed_url_extras;
 use crate::{
@@ -117,6 +118,7 @@ struct UploadState {
     path: Path,
     multipart_id: MultipartId,
     parts: Parts,
+    retry_policy: Option<Arc<dyn RetryPolicy>>,
 }
 
 #[async_trait]
@@ -126,12 +128,21 @@ impl MultipartUpload for GCSMultipartUpload {
         self.part_idx += 1;
         let state = Arc::clone(&self.state);
         Box::pin(async move {
-            let part = state
-                .client
-                .put_part(&state.path, &state.multipart_id, idx, payload)
-                .await?;
-            state.parts.put(idx, part);
-            Ok(())
+            let mut retry = MultipartRetry::new(state.retry_policy.clone());
+            loop {
+                match state
+                    .client
+                    .put_part(&state.path, &state.multipart_id, idx, 
payload.clone())
+                    .await
+                {
+                    Ok(part) => {
+                        state.parts.put(idx, part);
+                        return Ok(());
+                    }
+                    Err(error) if retry.should_retry(&error).await => continue,
+                    Err(error) => return Err(error),
+                }
+            }
         })
     }
 
@@ -168,6 +179,7 @@ impl ObjectStore for GoogleCloudStorage {
         location: &Path,
         opts: PutMultipartOptions,
     ) -> Result<Box<dyn MultipartUpload>> {
+        let retry_policy = opts.retry_policy();
         let upload_id = self.client.multipart_initiate(location, opts).await?;
 
         Ok(Box::new(GCSMultipartUpload {
@@ -177,6 +189,7 @@ impl ObjectStore for GoogleCloudStorage {
                 path: location.clone(),
                 multipart_id: upload_id.clone(),
                 parts: Default::default(),
+                retry_policy,
             }),
         }))
     }
diff --git a/src/lib.rs b/src/lib.rs
index 1dd749e..57713be 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -727,6 +727,8 @@ pub mod memory;
 pub mod path;
 pub mod prefix;
 pub mod registry;
+#[cfg(any(feature = "aws-base", feature = "azure-base", feature = "gcp-base"))]
+pub mod retry;
 #[cfg(feature = "cloud-base")]
 pub mod signer;
 #[cfg(feature = "tokio")]
@@ -2023,7 +2025,8 @@ pub struct PutMultipartOptions {
     /// Implementation-specific extensions. Intended for use by 
[`ObjectStore`] implementations
     /// that need to pass context-specific information (like tracing spans) 
via trait methods.
     ///
-    /// These extensions are ignored entirely by backends offered through this 
crate.
+    /// Cloud backends offered through this crate use extensions installed by 
methods such as
+    /// `PutMultipartOptions::with_retry_policy`. Other extensions are ignored.
     ///
     /// They are also excluded from [`PartialEq`] and [`Eq`].
     pub extensions: Extensions,
diff --git a/src/retry.rs b/src/retry.rs
new file mode 100644
index 0000000..2aa46e6
--- /dev/null
+++ b/src/retry.rs
@@ -0,0 +1,261 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Retry policies for HTTP-backed multipart uploads
+//!
+//! Each multipart part first uses the backend's existing bounded request retry
+//! loop. If those retries are exhausted, a [`RetryPolicy`] can restart the
+//! entire part operation. Restarting at this boundary rebuilds the request,
+//! including fetching credentials and generating a new signature.
+//!
+//! Configure a policy with [`PutMultipartOptions::with_retry_policy`]. The
+//! policy applies independently to each part and does not change the retry
+//! behavior of multipart initiation, completion, or abort requests.
+
+use crate::client::retry::{RequestError, RetryError};
+use crate::client::{HttpError, HttpErrorKind};
+use crate::{Error, PutMultipartOptions};
+use async_trait::async_trait;
+use http::StatusCode;
+use std::error::Error as StdError;
+use std::fmt::Debug;
+use std::sync::Arc;
+use std::time::Duration;
+#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
+use std::time::Instant;
+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
+use web_time::Instant;
+
+/// A normalized HTTP failure passed to a [`RetryPolicy`]
+///
+/// Backends wrap request failures in provider-specific [`Error`] variants.
+/// Before invoking a policy, `object_store` reduces a supported failure to an
+/// HTTP response status or transport error kind. Failures that cannot be
+/// represented by this enum are returned without invoking the policy.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[non_exhaustive]
+pub enum RetryFailure {
+    /// The HTTP status of a response that the backend classified as failed
+    ///
+    /// This is usually a non-success status. Some services, including S3, can
+    /// report an error in the body of a successful response. In that case this
+    /// contains the actual successful status, such as `200 OK`.
+    Status(StatusCode),
+
+    /// The request failed before producing a usable HTTP response
+    ///
+    /// The kind distinguishes failures such as connection errors, timeouts,
+    /// interrupted requests, and response decoding errors.
+    Transport(HttpErrorKind),
+}
+
+/// The failure and retry state for one operation attempt
+///
+/// For multipart uploads, an attempt is one complete call to upload a part,
+/// including its bounded request retries. Each part has an independent attempt
+/// count and elapsed time.
+//
+// Note: deliberately does not implement `Copy` so that non-`Copy` details,
+// such as the underlying error, can be added in the future without a
+// breaking change.
+#[derive(Debug, Clone, PartialEq, Eq)]
+#[non_exhaustive]
+pub struct RetryContext {
+    /// The normalized HTTP failure
+    pub failure: RetryFailure,
+
+    /// The failed operation attempt number, starting at one
+    pub attempt: usize,
+
+    /// Time since the first operation attempt started
+    ///
+    /// This includes time spent in requests and in earlier policy calls.
+    pub elapsed: Duration,
+}
+
+/// Controls whether and when a failed operation should be attempted again
+///
+/// The policy is shared by concurrently uploaded parts. Implementations must
+/// manage their own retry limit and backoff. `object_store` does not add a 
delay
+/// or impose an outer retry limit after this method returns `true`.
+#[async_trait]
+pub trait RetryPolicy: Debug + Send + Sync + 'static {
+    /// Decide whether to attempt the failed operation again
+    ///
+    /// Because this method is asynchronous, implementations can wait using
+    /// their own backoff strategy, clock, or sleeper before returning `true`.
+    /// Returning `false` returns the current operation error to the caller.
+    async fn retry(&self, context: RetryContext) -> bool;
+}
+
+#[derive(Clone)]
+struct MultipartRetryPolicy(Arc<dyn RetryPolicy>);
+
+impl PutMultipartOptions {
+    /// Retry failed multipart part uploads according to `policy`
+    ///
+    /// The policy runs after the existing bounded request retries are
+    /// exhausted. A retry repeats the same logical part from the provider
+    /// operation boundary, allowing credentials and request signatures to be
+    /// refreshed. Initiating, completing, and aborting the multipart upload
+    /// retain their existing retry behavior.
+    #[must_use]
+    pub fn with_retry_policy(mut self, policy: Arc<dyn RetryPolicy>) -> Self {
+        self.extensions.insert(MultipartRetryPolicy(policy));
+        self
+    }
+
+    pub(crate) fn retry_policy(&self) -> Option<Arc<dyn RetryPolicy>> {
+        self.extensions
+            .get::<MultipartRetryPolicy>()
+            .map(|policy| Arc::clone(&policy.0))
+    }
+}
+
+pub(crate) struct MultipartRetry {
+    policy: Option<Arc<dyn RetryPolicy>>,
+    attempt: usize,
+    start: Instant,
+}
+
+impl MultipartRetry {
+    pub(crate) fn new(policy: Option<Arc<dyn RetryPolicy>>) -> Self {
+        Self {
+            policy,
+            attempt: 0,
+            start: Instant::now(),
+        }
+    }
+
+    pub(crate) async fn should_retry(&mut self, error: &Error) -> bool {
+        let Some(policy) = self.policy.as_ref() else {
+            return false;
+        };
+        let Some(failure) = classify_http_failure(error) else {
+            return false;
+        };
+
+        self.attempt += 1;
+        policy
+            .retry(RetryContext {
+                failure,
+                attempt: self.attempt,
+                elapsed: self.start.elapsed(),
+            })
+            .await
+    }
+}
+
+/// Find and normalize an HTTP failure in an [`Error`] source chain.
+///
+/// Provider errors may wrap a terminal [`RetryError`] or [`HttpError`] several
+/// levels deep. Returns `None` when the chain contains no failure 
representable
+/// by [`RetryFailure`].
+fn classify_http_failure(error: &Error) -> Option<RetryFailure> {
+    let mut current: &(dyn StdError + 'static) = error;
+    loop {
+        if let Some(error) = current.downcast_ref::<RetryError>() {
+            return classify_request_error(error.inner());
+        }
+        if let Some(error) = current.downcast_ref::<HttpError>() {
+            return Some(RetryFailure::Transport(error.kind()));
+        }
+        current = current.source()?;
+    }
+}
+
+/// Normalize a terminal request error for a [`RetryPolicy`].
+///
+/// Error responses retain their actual status, including successful statuses
+/// whose bodies contain a provider error. A bare redirect has no status or
+/// transport kind to expose and is therefore not policy-controlled.
+fn classify_request_error(error: &RequestError) -> Option<RetryFailure> {
+    match error {
+        RequestError::Status { status, .. } | RequestError::Response { status, 
.. } => {
+            Some(RetryFailure::Status(*status))
+        }
+        RequestError::Http(error) => 
Some(RetryFailure::Transport(error.kind())),
+        RequestError::BareRedirect => None,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use parking_lot::Mutex;
+
+    #[derive(Debug, Default)]
+    struct RecordingPolicy(Mutex<Vec<RetryContext>>);
+
+    #[async_trait]
+    impl RetryPolicy for RecordingPolicy {
+        async fn retry(&self, context: RetryContext) -> bool {
+            self.0.lock().push(context);
+            true
+        }
+    }
+
+    #[derive(Debug, thiserror::Error)]
+    #[error("test error")]
+    struct TestError;
+
+    #[tokio::test]
+    async fn retries_http_errors() {
+        let policy = Arc::new(RecordingPolicy::default());
+        let mut retry = MultipartRetry::new(Some(Arc::clone(&policy) as 
Arc<dyn RetryPolicy>));
+        let error = Error::Generic {
+            store: "test",
+            source: Box::new(HttpError::new(HttpErrorKind::Timeout, 
TestError)),
+        };
+
+        assert!(retry.should_retry(&error).await);
+
+        let contexts = policy.0.lock();
+        assert_eq!(contexts.len(), 1);
+        assert_eq!(contexts[0].attempt, 1);
+        assert_eq!(
+            contexts[0].failure,
+            RetryFailure::Transport(HttpErrorKind::Timeout)
+        );
+    }
+
+    #[tokio::test]
+    async fn does_not_retry_non_http_errors() {
+        let policy = Arc::new(RecordingPolicy::default());
+        let mut retry = MultipartRetry::new(Some(Arc::clone(&policy) as 
Arc<dyn RetryPolicy>));
+        let error = Error::Generic {
+            store: "test",
+            source: Box::new(TestError),
+        };
+
+        assert!(!retry.should_retry(&error).await);
+        assert!(policy.0.lock().is_empty());
+    }
+
+    #[test]
+    fn classifies_error_responses_with_success_status() {
+        let error = RequestError::Response {
+            status: StatusCode::OK,
+            body: "InternalError".into(),
+        };
+
+        assert_eq!(
+            classify_request_error(&error),
+            Some(RetryFailure::Status(StatusCode::OK))
+        );
+    }
+}

Reply via email to