Skip to content

fix: retry transient source-read and segment-copy failures in UploadPartCopy#133

Merged
ServerSideHannes merged 2 commits into
mainfrom
fix/copy-source-read-retry
Jul 16, 2026
Merged

fix: retry transient source-read and segment-copy failures in UploadPartCopy#133
ServerSideHannes merged 2 commits into
mainfrom
fix/copy-source-read-retry

Conversation

@ServerSideHannes

Copy link
Copy Markdown
Owner

Problem

During the 2026-07-16 Scylla backup, 12/13 hosts' upload jobs failed. Root cause chain:

  1. A passthrough UploadPartCopy runs ~572 server-side segment copies plus an MD5 pass that re-reads the whole source in sequential 8 MiB frame GETs.
  2. The backend (Hetzner) drops long-lived connections: a frame GET dies mid-body with ClientPayloadError: received 8331264 of 8388636 bytes (deterministic offset, ~4 min in), or an UploadPartCopy streams a 200 then embeds InternalError: The server did not respond in time.
  3. botocore's retry layer only covers the request up to response headers — body-read failures have no retry at all — so one truncated read fails the entire client part, rclone burns a job retry pass, and eventually the whole backup run errors.

Fix

  • read_source_bytes() in handlers/base.py: ranged GET + full body read with retry (4 attempts, exponential backoff) on transient transport/backend errors. Used by _iter_multipart_plaintext frame reads and _download_encrypted_single.
  • _stream_raw_source_with_resume() in multipart/copy.py: the raw copy-source stream resumes with a ranged GET at the exact byte offset already yielded — bytes are never re-fetched or duplicated.
  • Passthrough copy_segment: retries upload_part_copy on retryable errors (same part number + range → idempotent).
  • Retry classification (is_retryable_source_error) covers aiohttp payload/connection errors, botocore timeout/streaming errors, and S3 error codes InternalError/SlowDown/RequestTimeout/ServiceUnavailable/BadGateway. NoSuchKey/AccessDenied and other client errors are never retried.
  • Env knobs: S3PROXY_SOURCE_READ_ATTEMPTS (default 4), S3PROXY_SOURCE_READ_BACKOFF (default 0.5s).

Tests

tests/unit/test_source_read_retry.py (8 tests): truncated-body retry & give-up, non-retryable passthrough, error classification, stream resume at exact offset (whole-object and ranged), non-retryable propagation, and an end-to-end passthrough copy that survives one 500'd segment copy + one truncated frame GET and round-trips the plaintext. Full unit suite: 585 passed.

🤖 Generated with Claude Code

…artCopy

A single truncated frame GET (backend drops a long-lived connection
mid-body: ClientPayloadError 'received 8331264 of 8388636 bytes') or a
200-with-embedded-InternalError UploadPartCopy failed the whole client
part. botocore's retry layer only covers a request up to the response
headers, so body-read failures had no retry at all. During the
2026-07-16 Scylla backup this failed 12/13 hosts' upload jobs.

- read_source_bytes(): full-body ranged GET with retry on transient
  transport/backend errors, used by frame reads and single-object reads
- _stream_raw_source_with_resume(): raw copy-source streaming resumes
  with a ranged GET at the exact byte offset already yielded
- passthrough copy_segment: retry upload_part_copy on retryable errors
  (same part number + range, safe to re-issue)
- new env knobs: S3PROXY_SOURCE_READ_ATTEMPTS (4), S3PROXY_SOURCE_READ_BACKOFF (0.5s)
@ServerSideHannes
ServerSideHannes merged commit dcd4c8c into main Jul 16, 2026
11 checks passed
@ServerSideHannes
ServerSideHannes deleted the fix/copy-source-read-retry branch July 16, 2026 07:52
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