Retry 429 on every request, 502 on reads only - #172
Conversation
A 502 comes from the proxy in front of Corgea, not from the API, so it is equally the answer for "the request never arrived" and for "the request was processed and the reply was lost coming back". Replaying every method on that status turned one `corgea scan` into a scan per attempt: both `POST /start-scan` and `POST /scan-upload` are creates, and a create the API committed before the proxy lost its answer becomes a second scan when it is sent again. Gate the replay on the method. GET and the other safe methods keep it, and so does PATCH: every PATCH the CLI sends is an upload chunk naming the byte range it fills in `Upload-Offset`, so a re-sent chunk overwrites the range it already wrote. POST is sent once and its 502 goes to the caller. `send` reads the method off the request it built; `send_with_retries` takes a closure, so its call sites now declare which method they send. Co-authored-by: Ibrahim Rahhal <ibrahim.rahhal3636@gmail.com>
Co-authored-by: Ibrahim Rahhal <ibrahim.rahhal3636@gmail.com>
| .send() | ||
| }); | ||
| let response = | ||
| utils::api::send_with_retries("a scan report chunk upload", &Method::POST, || { |
There was a problem hiding this comment.
high: Offset-addressed report chunks no longer retry 502 responses
This call passes POST, so send_with_retries immediately takes its non-replayable path and never applies GatewayRetries. However, this request is an offset-addressed chunk upload using Upload-Offset and Upload-Length, which the README explicitly identifies as safe to replay. A transient 502 now aborts an otherwise retryable large report upload. Replayability must account for this operation rather than only its HTTP method.
Proof or reproduction:
let response = send_with_retries("chunk", &Method::POST, || post_chunk());
// Stub responses: 502, then 200
// Actual: one request and 502 returned.
// Expected for offset-addressed chunks: two requests and 200 returned.
There was a problem hiding this comment.
No merge blockers on 20e0bdf.
Checked the merge-base diff against main (b6b5707...20e0bdf) and the six send_with_retries call sites. is_replayable is method.is_safe() || PATCH, and every site passes the method it actually sends: POST /start-scan, source/report/git-config/scan-upload POSTs, and the archive PATCH that carries Upload-Offset. DebugRequestBuilder::send returns a non-replayable method on the first response, so a cloneable JSON POST cannot pick up the 502 schedule that GET and PATCH still use.
The incident is pinned. send_with_retries_leaves_a_post_at_one_attempt and a_rejected_scan_start_is_not_sent_again fail the stub on a second POST /start-scan. The source walk still stops on the first 502; a_rejected_source_upload_stops_the_whole_upload_walk now expects one /code-upload and reports both files unsent. The wait GETs and blast_upload_replays_an_archive_chunk_the_gateway_rejects still require the old retry counts, so the retries that are safe to keep are still there.
retry_on_network_error (is_connect / is_timeout) still wraps a POST. That path predates this change and is outside the 502-replay bug. rust-tests is green. No prior review threads.
Sent by Cursor Automation: pr-flow
There was a problem hiding this comment.
Automated review risk: 4/5.
The retry policy regresses resilience for offset-addressed scan report chunks by classifying them solely as non-replayable POST requests.
Critical or high-priority changes must be addressed.
Automatic approval was not submitted: automated review found critical or high-priority findings.
The offset argument covers the archive bytes: a chunk names the range it fills, so a replay overwrites rather than appends. It does not cover the transfer's completion. The chunk that fills the last of Upload-Length is the one that answers with scan_id, and an archive under CHUNK_SIZE is a single chunk, so on most repos the retried chunk is also the request that creates the scan -- which is safe only while the API keys that scan to the transfer_id already in the URL. Co-authored-by: Ibrahim Rahhal <ibrahim.rahhal3636@gmail.com>
The offset argument only covered the archive bytes. The chunk that fills the last of Upload-Length is also the one that answers with scan_id, and an archive under CHUNK_SIZE is a single chunk, so on most repos the retried chunk is the request that creates the scan -- the same exposure the POSTs were excluded for, and safe only if the API keys the scan to the transfer. Retrying is now limited to the safe methods, which leaves no replayable caller of send_with_retries: its gateway loop could never fire, and the thread-local guard existed only to stop that loop nesting inside the one in send. Both are gone, and the six upload call sites now say what they already resolved to -- retry_on_network_error. One rule in one place: send replays a read, and nothing replays a write. Co-authored-by: Ibrahim Rahhal <ibrahim.rahhal3636@gmail.com>
The two statuses worth retrying promise different things. A 502 comes from the proxy, so it cannot say whether the API acted, which is why a write's 502 is now the answer. A 429 is the rate limiter declining the request before the API sees it: nothing was created, so re-sending finishes the same work rather than duplicating it, and a rate-limited upload that failed the command would otherwise leave a pipeline to be re-run by hand. So the retry decision takes the method and the status together -- should_retry -- rather than testing the status alone. is_gateway_error becomes is_transient_error and covers both, which is also what the source upload walk wants: it stops early on a 502 because a write is never replayed, and on a 429 because the retries are already spent. A 429 usually names its own window, and ignoring it means spending the whole schedule inside it and failing anyway, so Retry-After is honored when present. Capped at two minutes per pause so an unverifiable header cannot stall a pipeline, and floored at the schedule so a retry cannot become a hammer. Co-authored-by: Ibrahim Rahhal <ibrahim.rahhal3636@gmail.com>


Why
Scans were showing up in projects several times over from a single
corgea scan.#171 made every API call replay itself on
502 Bad Gateway. A 502 comes from the proxy in front of Corgea rather than from the API, so it says nothing about whether the API handled the request — it is equally the answer for "the request never arrived" and for "the request was processed and the reply was lost coming back". Every write the CLI sends creates something, and three of them create a scan:POST /api/v1/start-scanmints a new transfer, so a replay starts a second upload.PATCH /api/v1/start-scan/<transfer_id>/is offset-addressed, so the archive bytes never double — but the chunk that fills the last ofUpload-Lengthis the one that answers withscan_id, andCHUNK_SIZEis 50 MB, so on any repo that zips under that the single chunk is the scan-creating request.POST /api/v1/scan-uploadtakes a whole third-party report.When the API committed one of those and the proxy lost the reply, sending it again did not finish the first scan. It started another one. With three replays in the schedule, one flaky write became up to four records.
There was never a whole-scan retry, to be clear about the original bug's scope. Retries are per request. It is just that the request being retried was sometimes the one that creates the scan.
The mirror-image gap was that
429 Too Many Requestswas not retried at all, so a rate limit failed the whole pipeline.What changed
The retry decision now takes the status and the method together, because the two statuses worth retrying promise different things about what happened to the request:
429 Too Many Requests502 Bad GatewayRe-sending after a 429 finishes the same work; re-sending a write after a 502 risks doing it twice.
is_gateway_errorbecomesis_transient_errorand covers both, which is also what the source-upload walk wants: it stops early on a 502 because a write is never replayed, and on a 429 because the retries are already spent.A 429 usually names its own window, and ignoring that means spending the whole schedule inside it and failing anyway, so
Retry-Afteris honored when present. It is capped at two minutes for any one pause, so a header the CLI cannot sanity-check cannot stall a pipeline, and floored at the schedule, so a retry cannot become a hammer. Only the delta-seconds form is read; the HTTP-date form would need the CLI to trust its own clock against the server's.Writes keep the pre-existing network-error retries (
is_connect/is_timeout), where nothing reached Corgea at all./code-uploadis in fact keyed byrun_idandpath, so its 502 would be safe to replay on its own merits. It is excluded anyway: a method-level rule is one thing to reason about at every call site, whereas a per-endpoint exemption list is a thing to get wrong later. It can be added deliberately if it turns out to matter.Also bumps the CLI to
1.14.1.Evidence
Five scenarios, each run against the pre-fix binary and this one, with a stub that answers 502 after committing (modelling the lost reply a client cannot distinguish) and 429 without committing at all.
retry_policy_502_and_429.log
Reads are unchanged. A 502 on
POST /start-scangoes from three transfers to one and on the archivePATCHfrom three scans to one. A 429 on either goes from failing the scan outright to completing it.The
PATCH/502 figure assumes completing a transfer twice mints two scans. That is an assumption about the API the CLI cannot check, which is the reason not to replay: if completion is keyed to thetransfer_idthe replay was harmless, and if it is not, it was the bug. Sending it once is correct either way.Tests
Thirteen unit tests and seven end-to-end. The policy assertions were each checked against a deliberately broken build — retrying 429 only for safe methods, ignoring
Retry-After, and dropping its cap — and all three mutations were caught.retry_policy_tests.log
The end-to-end tests run the real binary against an ordered stub that fails the plan on an unexpected request, so they pin the exact attempt count:
a_rate_limited_scan_start_goes_through_on_a_retryproves the rebuilt multipart form is re-sent and the scan completes, whilea_rejected_scan_start_is_not_sent_againanda_rejected_archive_chunk_is_not_sent_againprove a 502 on a write is the answer. Bothwaittests and the two walk-stop tests are unchanged in intent../harness ciis green: strict clippy, format check, dep audit, and 835 tests over the coverage gate.retry_policy_ci.log
To show artifacts inline, enable in settings.