Retry patch API connections reset mid-handshake - #610
Mikola Lysenko (mikolalysenko) wants to merge 1 commit into
Conversation
The JSON retry loop repeated only 429/503; any transport error was
final on the first failure. CI e2e legs talk to the production patch
API, and a load balancer resetting a fresh connection ("client error
(Connect): Connection reset by peer") failed the whole leg, e.g. the
pnpm safety e2e on #563.
A connection reset, aborted or closed while being established is now
retried under the existing budget, backoff and run-wide window. No
byte of the request has gone out at that point, so it is safe for the
batch POST too. Refused connections, timeouts, DNS and TLS certificate
errors, and failures after the request was sent stay final at once, so
dead-port tests and stalled reads behave as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1QpyLyaVEx88TT3UwW1L1
|
bugbot run Generated by Claude Code |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit efb631f. Configure here.
|
[burn-down agent] Labeled Ready for review at
Generated by Claude Code |
|
Reviewed Validated the transport classifier, fresh request construction, shared HTTP/transport retry budget and window, and unchanged timeout/post-send error boundaries. Locally, 28 distinct tests passed: 17 API retry tests, 6 timeout tests, and 5 independent probes covering post-send resets/truncated bodies, TLS EOF, exhausted windows, mixed failures, and timeouts with retries enabled. Exact-head CI has 332 successful checks and 8 skipped, with no pending or failing checks. Bugbot is clean, no review threads remain unresolved, and the branch merges cleanly with current |
Problem
The patch API's JSON retry loop (
api::retry,ApiClient::send_json_request) retries only HTTP 429 and 503. Any transport error is final on the first failure. CI's e2e legs and the per-package-manager compatibility matrices call the production patch API (patches-api.socket.dev). So when a load balancer resets one fresh connection, the CLI command fails and the whole leg goes red.Evidence (CI history 2026-09-28 → 2026-10-02):
e2e (ubuntu-latest, e2e_safety_pnpm), branch from Pipenv recognizes hosted PyPI patch URLs with two private grammars that disagree with the shared one #563).socket-patch getfailed with:Error: Network error: error sending request for url (https://patches-api.socket.dev/patch/view/80630680-…): client error (Connect): Connection reset by peer (os error 104).It went green on re-run of the same SHA.
pdm-compatibility.ymlhad 15 failed runs and 14 re-runs in 200 runs. That is 17 failed native legs in 15 runs over the last 2 days, each with exactly one case failing and a different PDM version / shape each time. The failing checks areappliedExactlyOne×7,rescanIdempotent×5,refusalCodeReported×4 and relock-rescan ×1. Each one needs a patch-API call that returned nothing usable. On refused-lock cells, the scan reported no refusal code and took 23–27 s where its siblings took 2–8 s. The harness prints only check names, and the artifacts can't be downloaded from this sandbox, so the PDM link is likely but not proven from logs.error sending request for url (failures. This fixes the shared cause in the client.Root cause
send_json_requestturned everyreqwestsend error intoApiError::Networkat once. A connection the peer resets during TCP connect or the TLS handshake is a transient blip, and the request has not been sent yet. It was still treated like a real answer.Fix
api::retry::is_retryable_transportreturns true only for a connect-phase error (is_connect() && !is_timeout()) whose cause chain holds an I/OConnectionReset,ConnectionAbortedorUnexpectedEof.Otherio::Error, andio::Error::sourceskips that inner error, so the walk also looks throughget_ref().send_json_requestretries such an error under the existing policy:max_retries(3,SOCKET_API_MAX_RETRIES,0= off)get_json(search, patch view),post_jsonand the proxy batch POST. No request bytes have gone out at connect time, so retrying a POST is safe.fetch_bloband the vendoring service are untouched. The vendoring service already retries transport errors.Proof
a_connection_reset_mid_handshake_is_retried_then_reportedincrates/socket-patch-core/tests/api_retry_e2e.rsuses a local TLS endpoint that RSTs every connection after the ClientHello. That reproduces the CI error locally:hyper_util Error(Connect, Custom{Other, Os{ConnectionReset}}). The test asserts:Network error: …texta_refused_connection_is_not_retriedasserts no retry for a refused connection.left: 1, right: 4, so the error was not retried. After the fix,api_retry_e2epassed 50/50 runs in a loop (17 tests, about 0.1 s).cargo test -p socket-patch-core --lib api::: 265 passed.socket-patch-clibinariescli,get,apply,scan,scan_api_retry_e2e,remove_rollback_api_overrides,cli_get_silent,cli_scan_silent: 372 passed, 0 failed. These exercise the dead-port, proxy-fallback and retry paths.cargo clippy -p socket-patch-core --all-targets -- -D warningsgives no findings in the touched files. Its 9 existing errors in other files are the same onmainwith this toolchain.rustfmtwas run only on the touched files.Where tests run
No test is removed or moved. The new tests are in the existing
api_retry_e2ebinary, whichci.yml'stestjob runs. No workflow or job names change.🤖 Generated with Claude Code
https://claude.ai/code/session_01F1QpyLyaVEx88TT3UwW1L1
Generated by Claude Code