Skip to content

fix: retry CompleteMultipartUpload on transient backend errors#138

Open
ServerSideHannes wants to merge 1 commit into
mainfrom
fix/retry-complete-multipart-upload
Open

fix: retry CompleteMultipartUpload on transient backend errors#138
ServerSideHannes wants to merge 1 commit into
mainfrom
fix/retry-complete-multipart-upload

Conversation

@ServerSideHannes

@ServerSideHannes ServerSideHannes commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Problem

Prod incident 2026-07-22: a Scylla backup run showed non-zero "Failed" bytes for main.companies on 7/13 nodes. Traced through scylla-manager-agent -> rclone -> this proxy -> Hetzner backend:

Hetzner streams a 200 OK for CompleteMultipartUpload then can fail mid-response, embedding an <Error>InternalError</Error> in the body ("The server did not respond in time.") - a documented S3-family quirk for this specific operation. Nothing in the stack retried it:

  • botocore's own client retry (max_attempts=3, mode=adaptive) only treats HTTP 5xx status codes and a small error-code allowlist (RequestTimeout, RequestTimeoutException, PriorRequestNotComplete) as transient - neither a 200 status nor InternalError qualifies.
  • _handle_complete_error only special-cased EntityTooSmall; every other code (including InternalError) was a bare raise.

rclone saw this as a failed upload and scylla-manager had to re-upload the entire multi-GB SSTable instead of just retrying one API call.

Fix

Wrap complete_multipart_upload in a bounded retry, reusing the existing is_retryable_source_error classifier from base.py (already used for the analogous UploadPartCopy/GetObject retry path).

A plain retry isn't safe on its own: if the backend actually finished assembling the object before the response died, the upload_id is already invalidated (S3 semantics: it dies once completion succeeds), so the retry surfaces NoSuchUpload even though the object is fine. Added a head_object-based check so a NoSuchUpload on retry is only treated as a real failure if the object doesn't actually exist with the expected size - otherwise it's treated as the success it actually was.

Configurable via S3PROXY_COMPLETE_RETRY_ATTEMPTS (default 4) / S3PROXY_COMPLETE_RETRY_BACKOFF (default 0.5s), matching the existing S3PROXY_SOURCE_READ_* env vars.

Tests

tests/unit/test_complete_multipart_retry.py - 5 cases:

  • transient error retried then succeeds
  • exhausts retry budget on a persistent transient error (raises, object never assembled)
  • non-retryable error (e.g. AccessDenied) is not retried at all
  • retry hits NoSuchUpload but the object was actually already assembled -> recovered as success (the exact prod failure shape)
  • retry hits NoSuchUpload and the object genuinely doesn't exist -> still fails (the safety net doesn't mask real failures)

Confirmed these fail without the fix (AttributeError, method/constants don't exist pre-patch). Full tests/unit suite (596 tests) passes.

Hetzner streams a 200 for CompleteMultipartUpload then can fail mid-response
with an embedded InternalError ("The server did not respond in time.") - a
documented S3-family quirk for this operation. Nothing retried it: botocore's
own retry checker only treats HTTP 5xx status and a small error-code allowlist
as transient (neither covers a 200 status with an error body), and the
handler bare-raised everything except EntityTooSmall. Scylla backups saw
these as failed multi-GB SSTable uploads and had to re-upload the whole file.

Add a bounded retry (reusing base.is_retryable_source_error) around
complete_multipart_upload. A plain retry isn't safe by itself: if the backend
actually finished assembling the object before the response died, the
upload_id is already invalidated, so the retry surfaces NoSuchUpload even
though the object is fine. Guard that case with a head_object check before
treating NoSuchUpload-after-retry as a real failure.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant