Skip to content

Commit 5dade4c

Browse files
fix: retry UploadPart on transient backend errors (#143)
1 parent 7c4fcb6 commit 5dade4c

2 files changed

Lines changed: 176 additions & 3 deletions

File tree

s3proxy/handlers/multipart/upload_part.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
StateMissingError,
2626
)
2727
from ...streaming import decode_aws_chunked_stream
28-
from ..base import BaseHandler
28+
from .. import base
29+
from ..base import BaseHandler, is_retryable_source_error
2930

3031
logger: BoundLogger = structlog.get_logger(__name__)
3132

@@ -236,6 +237,46 @@ async def handle_upload_part(self, request: Request, creds: S3Credentials) -> Re
236237
except Exception as e:
237238
return self._handle_generic_error(e, bucket, key, part_num, upload_id)
238239

240+
async def _upload_part_with_retry(
241+
self,
242+
client: S3Client,
243+
bucket: str,
244+
key: str,
245+
upload_id: str,
246+
internal_part_num: int,
247+
ciphertext: bytes | bytearray,
248+
*,
249+
client_part_num: int,
250+
) -> dict:
251+
"""UploadPart, retrying transient backend failures.
252+
253+
RGW can accept the part, commit to a 200, and only then fail with an
254+
<Error> body ("InternalError: The server did not respond in time").
255+
Neither botocore's retries nor rclone's low-level retries fire for that:
256+
both decide from the HTTP status line, which already read 200. Re-PUTting
257+
the same part number with the same ciphertext is idempotent, so retry here
258+
where the parsed error is actually visible.
259+
"""
260+
for attempt in range(1, base.SOURCE_READ_ATTEMPTS + 1):
261+
try:
262+
return await client.upload_part(
263+
bucket, key, upload_id, internal_part_num, ciphertext
264+
)
265+
except Exception as exc:
266+
if not is_retryable_source_error(exc) or attempt == base.SOURCE_READ_ATTEMPTS:
267+
raise
268+
logger.warning(
269+
"UPLOAD_PART_RETRY",
270+
bucket=bucket,
271+
key=key,
272+
client_part=client_part_num,
273+
internal_part=internal_part_num,
274+
attempt=attempt,
275+
error=str(exc),
276+
)
277+
await asyncio.sleep(base.SOURCE_READ_BACKOFF_SEC * (2 ** (attempt - 1)))
278+
raise AssertionError("unreachable")
279+
239280
async def _get_or_recover_state(
240281
self, client: S3Client, bucket: str, key: str, upload_id: str, part_num: int
241282
) -> MultipartUploadState:
@@ -462,7 +503,9 @@ async def _stream_and_upload_framed(
462503
)
463504

464505
upload_start = time.monotonic()
465-
resp = await client.upload_part(bucket, key, upload_id, ipn, ciphertext)
506+
resp = await self._upload_part_with_retry(
507+
client, bucket, key, upload_id, ipn, ciphertext, client_part_num=part_num
508+
)
466509
del ciphertext
467510
etag = resp["ETag"].strip('"')
468511
logger.info(
@@ -603,7 +646,15 @@ async def _upload_internal_part_with_semaphore(
603646
del data # Free memory
604647

605648
# Upload
606-
resp = await client.upload_part(bucket, key, upload_id, internal_part_num, ciphertext)
649+
resp = await self._upload_part_with_retry(
650+
client,
651+
bucket,
652+
key,
653+
upload_id,
654+
internal_part_num,
655+
ciphertext,
656+
client_part_num=client_part_num,
657+
)
607658
etag = resp["ETag"].strip('"')
608659
del ciphertext # Free memory
609660

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""UploadPart retry on transient backend errors.
2+
3+
Prod incident 2026-07-28: the Scylla backup of `main.companies` failed on 10 of
4+
13 racks, leaving ~2.4TiB un-uploaded. Every failure was a ~6.2GiB SSTable and
5+
every error was ``InternalError: The server did not respond in time.`` with
6+
``status code: 200`` -- RGW commits to a 200, streams it, then puts the failure
7+
in the body.
8+
9+
Three retry layers all miss that shape because each decides from the HTTP status
10+
line, which already read 200:
11+
* rclone's ``LowLevelRetries`` gates on status 429/500/503 (backend/s3/s3.go)
12+
* rclone's string fallback only matches transport phrases ("use of closed
13+
network connection", "unexpected EOF reading trailer"), not ``InternalError``
14+
* botocore's ``max_attempts`` in S3Client sees the same 200
15+
16+
#133 added this retry to UploadPartCopy and #138 to CompleteMultipartUpload, but
17+
UploadPart -- the operation a Scylla backup actually uses (all PUT, no COPY) --
18+
was left bare. At 50MB rclone chunks a 6.2GiB file is ~762 internal PUTs, so one
19+
unretried transient failure loses the whole multi-GB file.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import pytest
25+
from botocore.exceptions import ClientError
26+
27+
from s3proxy.handlers import base
28+
from s3proxy.handlers.multipart.upload_part import UploadPartMixin
29+
30+
31+
@pytest.fixture(autouse=True)
32+
def _no_backoff(monkeypatch):
33+
monkeypatch.setattr(base, "SOURCE_READ_BACKOFF_SEC", 0.0)
34+
35+
36+
def _client_error(code: str, message: str | None = None) -> ClientError:
37+
return ClientError({"Error": {"Code": code, "Message": message or code}}, "UploadPart")
38+
39+
40+
class _FlakyUploadClient:
41+
"""Backend whose first ``fail_times`` upload_part calls raise ``error_code``."""
42+
43+
def __init__(self, fail_times: int = 0, error_code: str = "InternalError"):
44+
self._fail_times = fail_times
45+
self._error_code = error_code
46+
self.attempts = 0
47+
self.seen = []
48+
49+
async def upload_part(self, bucket, key, upload_id, part_number, body):
50+
self.attempts += 1
51+
self.seen.append((part_number, bytes(body)))
52+
if self._fail_times > 0:
53+
self._fail_times -= 1
54+
if self._error_code == "InternalError":
55+
raise _client_error("InternalError", "The server did not respond in time.")
56+
raise _client_error(self._error_code)
57+
return {"ETag": f'"etag-{part_number}"'}
58+
59+
60+
async def _upload(client, **kw):
61+
return await UploadPartMixin._upload_part_with_retry(
62+
UploadPartMixin.__new__(UploadPartMixin),
63+
client,
64+
"bucket",
65+
"key",
66+
"upload-id",
67+
kw.pop("internal_part_num", 7),
68+
kw.pop("ciphertext", b"ciphertext-bytes"),
69+
client_part_num=kw.pop("client_part_num", 1),
70+
)
71+
72+
73+
@pytest.mark.asyncio
74+
async def test_retries_late_200_internal_error():
75+
"""The exact prod shape: InternalError in a 200 body, then success."""
76+
client = _FlakyUploadClient(fail_times=1)
77+
resp = await _upload(client)
78+
assert resp["ETag"] == '"etag-7"'
79+
assert client.attempts == 2
80+
81+
82+
@pytest.mark.asyncio
83+
async def test_retry_reuses_same_part_number_and_bytes():
84+
"""Re-PUT must be idempotent - same part number, same ciphertext."""
85+
client = _FlakyUploadClient(fail_times=2)
86+
await _upload(client, internal_part_num=3, ciphertext=b"abc")
87+
assert client.attempts == 3
88+
assert client.seen == [(3, b"abc")] * 3
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_gives_up_after_attempt_cap():
93+
"""Exhausting attempts re-raises rather than looping forever."""
94+
client = _FlakyUploadClient(fail_times=base.SOURCE_READ_ATTEMPTS)
95+
with pytest.raises(ClientError):
96+
await _upload(client)
97+
assert client.attempts == base.SOURCE_READ_ATTEMPTS
98+
99+
100+
@pytest.mark.asyncio
101+
async def test_non_retryable_error_is_not_retried():
102+
"""A real client error must fail fast, not burn attempts."""
103+
client = _FlakyUploadClient(fail_times=1, error_code="AccessDenied")
104+
with pytest.raises(ClientError):
105+
await _upload(client)
106+
assert client.attempts == 1
107+
108+
109+
@pytest.mark.asyncio
110+
async def test_success_path_makes_one_call():
111+
client = _FlakyUploadClient()
112+
resp = await _upload(client)
113+
assert resp["ETag"] == '"etag-7"'
114+
assert client.attempts == 1
115+
116+
117+
@pytest.mark.parametrize("code", ["InternalError", "SlowDown", "ServiceUnavailable"])
118+
@pytest.mark.asyncio
119+
async def test_retryable_codes(code):
120+
client = _FlakyUploadClient(fail_times=1, error_code=code)
121+
await _upload(client)
122+
assert client.attempts == 2

0 commit comments

Comments
 (0)