Skip to content

fix: retry CompleteMultipartUpload on transient backend errors - #138

Merged
ServerSideHannes merged 1 commit into
mainfrom
fix/retry-complete-multipart-upload
Jul 23, 2026
Merged

fix: retry CompleteMultipartUpload on transient backend errors#138
ServerSideHannes merged 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.
@ServerSideHannes
ServerSideHannes merged commit c1d8d4d into main Jul 23, 2026
11 checks passed
@ServerSideHannes
ServerSideHannes deleted the fix/retry-complete-multipart-upload branch July 23, 2026 14:17
ServerSideHannes added a commit that referenced this pull request Jul 28, 2026
RGW can accept a part, commit to a 200, stream it, and only then put the
failure in the body ("InternalError: The server did not respond in time.",
status code: 200). Three retry layers all miss that shape because each decides
from the HTTP status line, which already read 200:

  - rclone's LowLevelRetries gates on status 429/500/503 (backend/s3/s3.go)
  - rclone's string fallback matches only transport phrases ("use of closed
    network connection", "unexpected EOF reading trailer"), not InternalError
  - botocore's max_attempts in S3Client sees the same 200

#133 added this retry to UploadPartCopy and #138 to CompleteMultipartUpload,
but plain UploadPart was left bare -- and it is the only one of the three a
Scylla backup uses (267 PUT, 0 UploadPartCopy measured against the backup
bucket). At 50MB rclone chunks a 6.2GB SSTable is ~762 internal PUTs, so one
unretried transient failure loses the whole multi-GB file.

2026-07-28 prod: 10 of 13 racks failed, ~2.4TiB never uploaded, every failure
on main.companies (47 of 48 large files).

Reuses the existing base.SOURCE_READ_ATTEMPTS machinery from #133, so no new
tunables. Safe at both call sites: the full ciphertext is still in memory when
upload_part is called (the del happens after), so a retry re-PUTs identical
bytes to the same internal part number.
ServerSideHannes added a commit that referenced this pull request Jul 28, 2026
RGW can accept a part, commit to a 200, stream it, and only then put the
failure in the body ("InternalError: The server did not respond in time.",
status code: 200). Three retry layers all miss that shape because each decides
from the HTTP status line, which already read 200:

  - rclone's LowLevelRetries gates on status 429/500/503 (backend/s3/s3.go)
  - rclone's string fallback matches only transport phrases ("use of closed
    network connection", "unexpected EOF reading trailer"), not InternalError
  - botocore's max_attempts in S3Client sees the same 200

#133 added this retry to UploadPartCopy and #138 to CompleteMultipartUpload,
but plain UploadPart was left bare -- and it is the only one of the three a
Scylla backup uses (267 PUT, 0 UploadPartCopy measured against the backup
bucket). At 50MB rclone chunks a 6.2GB SSTable is ~762 internal PUTs, so one
unretried transient failure loses the whole multi-GB file.

2026-07-28 prod: 10 of 13 racks failed, ~2.4TiB never uploaded, every failure
on main.companies (47 of 48 large files).

Reuses the existing base.SOURCE_READ_ATTEMPTS machinery from #133, so no new
tunables. Safe at both call sites: the full ciphertext is still in memory when
upload_part is called (the del happens after), so a retry re-PUTs identical
bytes to the same internal part number.
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