Fix multipart upload part retries missing connection exceptions - #1436
Merged
Conversation
The per-part PUT retry passed requests.exceptions.ConnectionError as the retryable exception, but the PUT is issued via an httpx.Client, which never raises that exception type. A transient httpx connection error (connect timeout, read timeout, reset, etc.) therefore skipped the 20-minute backoff window entirely and failed the part immediately, falling back only on the much coarser whole-upload-attempt retry. Swap in the existing RETRYABLE_CONNECTION_EXCEPTIONS list already used correctly on the download side.
Contributor
There was a problem hiding this comment.
Pull request overview
This pull request fixes part-level retry behavior for async multipart uploads by aligning the retryable exception allowlist with the actual HTTP client in use (httpx.Client). This improves resilience for large uploads by ensuring transient connection-level failures during individual part PUTs correctly use the existing time-boxed retry/backoff logic, rather than failing a part immediately and forcing a full upload-attempt restart.
Changes:
- Use
RETRYABLE_CONNECTION_EXCEPTIONS(httpx-aware) for_put_part_with_retryinstead ofrequests.exceptions.ConnectionError. - Remove the now-unused
requestsimport from the async multipart upload implementation. - Add unit tests covering connection-error retry, retryable HTTP status retry, presigned URL refresh on 403, and immediate failure for non-retryable exceptions.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
synapseclient/core/upload/multipart_upload_async.py |
Fixes part PUT retry behavior by using the shared httpx-compatible retry exception list. |
tests/unit/synapseclient/core/upload/test_multipart_upload_async.py |
Adds regression and path-coverage tests for _handle_part / _put_part_with_retry retry logic. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
_put_part_with_retry (sync path) only retried requests.exceptions.ConnectionError, missing requests.exceptions.Timeout and ChunkedEncodingError -- both genuine transient failures a slow/stalled part PUT can raise. Use the same RETRYABLE_CONNECTION_EXCEPTIONS list already applied to the async path and the rest of the codebase, restoring parity between the sync and async upload implementations.
linglp
approved these changes
Jul 29, 2026
Parametrize the async httpx regression test across ConnectError, ReadError, ReadTimeout, ConnectTimeout, and RemoteProtocolError, and merge the two structurally-identical sync tests (connection_error/timeout_error) into one parametrized test covering ConnectionError, ConnectionResetError, Timeout, ChunkedEncodingError, ReadTimeout, and ConnectTimeout.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem:
A user reported an upload failing with
SynapseUploadFailedException: Part upload failedonly 3 seconds into a 10.5GB transfer — far too fast to have exhausted the intended part-level retry window.Tracing the retry logic in
multipart_upload_async.pyfound the root cause:_put_part_with_retry(async path) passesretry_exceptions=[requests.exceptions.ConnectionError]towith_retry_time_based, but thesessionperforming the PUT is anhttpx.Client, not arequests.Session. httpx never raisesrequests.exceptions.ConnectionError— it raiseshttpx.ConnectError,httpx.ReadTimeout,httpx.RemoteProtocolError, etc. The retry matcher (_is_retryable) checks the caught exception's class/class-name against the supplied allowlist, so none of httpx's exception types ever match, and the retry helper's default-filling logic only substitutes a default when the caller's list is empty — it doesn't merge in the correct list when a (wrong) list is already supplied.Net effect: any genuine transient network error during a part PUT (a real socket/connect/read/timeout failure, not an HTTP status code) skipped the entire time-boxed backoff window and failed the part immediately. The only resilience left was the coarser whole-upload-attempt retry (
MAX_RETRIES = 7in_multipart_upload_async), which restarts the entireUploadAttemptAsyncrather than just retrying the one failed PUT.While auditing every
retry_exceptions=call site for the same class of bug, the sync upload path (multipart_upload.py) turned out to have a milder version of the same gap: it correctly usesrequests.exceptions.ConnectionError(since it genuinely usesrequests.Session), but only that one exception type — missingrequests.exceptions.TimeoutandChunkedEncodingError, both of which a slow/stalled part PUT can legitimately raise. The download side (download_async.py,download_threads.py) andclient.py's standard retry params already use the full, correct list.Solution:
synapseclient/core/upload/multipart_upload_async.py: swappedretry_exceptions=[requests.exceptions.ConnectionError]for the existingRETRYABLE_CONNECTION_EXCEPTIONSlist (already defined incore/retry.pyand used correctly on the download side). Removed the now-unusedimport requests.synapseclient/core/upload/multipart_upload.py: widened the sync path'sretry_exceptions=[requests.exceptions.ConnectionError]to the sameRETRYABLE_CONNECTION_EXCEPTIONSlist, restoring parity with the async path and the rest of the codebase.Testing:
tests/unit/synapseclient/core/upload/test_multipart_upload_async.py(new file) covers each distinct path throughUploadAttemptAsync._handle_part/_put_part_with_retry:httpx.ConnectErroron the first PUT, success on the second — confirmed this test fails without the fix (the exception propagates immediately with zero retries) and passes with it.ValueError) → fails immediately with no retry (confirms the fix doesn't over-widen retry behavior).tests/unit/synapseclient/core/upload/unit_test_multipart_upload.py: addedtest_handle_part__timeout_error, a regression test for the sync path —requests.exceptions.Timeouton the first PUT, success on the second. Confirmed this fails without the sync-path fix and passes with it.Full upload unit test suite (
tests/unit/synapseclient/core/upload/, 29 tests) passes, andpre-commit run --all-files(ruff, isort, black, bandit) passes clean.