fix(cursor-review): wait out a throttle before the landed-review read, and decline the fallback when the answer is still unknown - #280
Open
mattmillerai wants to merge 2 commits into
Conversation
… read (BE-12691) BE-12612 stopped a throttled POST being misread as a read-only token and routed it into the landed-review read — but that read goes out on the very token GitHub just throttled, immediately, so it was liable to be throttled in its turn. The answer that produced was UNKNOWN, and UNKNOWN on the inline path posted the body-only fallback: a duplicate review whenever the throttled first POST was one the API went on to serve, and a review cannot be un-posted. Two halves. `gh_post_review` now passes `-i`, so the response headers reach `gh_response_headers` (stdout only — stderr can carry an echo of the POSTed review body under GH_DEBUG=api, and a finding must not be able to dictate how long this process sleeps). `confirm_landed` — the one seam all three failure paths read the PR through — waits `Retry-After`, else the `X-RateLimit-Reset` window, else GitHub's stated one-minute minimum, clamped to 90s, before asking. And what is still UNKNOWN after that wait no longer reposts: the findings go to the job summary under POST_FAILED_SUMMARY_NOTE, `posted=false` keeps the fresh-review gate red rather than green over a write nobody confirmed, and the step goes red. Confirmed ABSENT under a throttle waits once more and then posts the fallback as before; 408/425/5xx/no-status keep the immediate read and never wait, since none of them says a rate window is closed.
mattmillerai
marked this pull request as ready for review
September 9, 2026 04:17
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 9 finding(s).
| Severity | Count |
|---|---|
| 🟡 Medium | 4 |
| 🟢 Low | 5 |
Panel: 6/6 reviewers contributed findings.
…every call on the recovery path (BE-12691) Addresses the nine findings the review panel raised on #280. Medium: - Share ONE deadline between the two waits a throttled inline POST can cost. The second wait was recomputed from the first response's now-stale headers, so a `Retry-After` was sat out twice over — and on the `X-RateLimit-Reset` rule the recomputed window had gone negative during the first sleep, fell through, and spent a flat 60s after the window it had just waited out demonstrably reopened. The remainder is now normally zero, which also keeps the confirmed-ABSENT answer fresh instead of ~150s stale at the fallback POST. - Bound `gh_post_review` with GH_POST_REVIEW_TIMEOUT_SECONDS. Both POSTs precede `write_step_summary`, so a wedged one took the round out of BOTH channels when the job timed out; a timeout now becomes a status-less CompletedProcess that routes into the undecided path. - Stop calling the API inside a declared embargo. A `Retry-After` longer than THROTTLE_DELAY_MAX_SECONDS was clamped to 90s and then retried anyway, which is what escalates a secondary limit for the whole installation. It now skips the wait, the read and the fallback outright and degrades to the job summary. - Treat a MESSAGELESS 403 carrying a parseable `Retry-After` as a throttle. An edge, WAF or GHES proxy can answer that way; read by wording alone it took the read-only degrade — green, no read — over a write GitHub may have served. Deliberately narrow: an affirmative refusal ("Resource not accessible by integration") still wins over the header. Low: - POST_UNCONFIRMED_SUMMARY_NOTE for the paths that cannot claim the API rejected the write. The old note said "rejected", which invites the re-trigger that creates the duplicate the check exists to avoid. Applied to all three undecided-under-throttle paths, including the fallback's own via `outcome`. - Keep the reset-window arithmetic in int: a ~309-digit `X-RateLimit-Reset` raised OverflowError, killing the poster before the job summary. - Fall through on `remaining > 0`, not `>= 0`. A window that lapsed within the last second floored to 0 and became the 1s floor — an immediate retry on a just-throttled token — instead of the documented default. - Scope the README and the workflow budget comment to the throttle path; the fallback still goes out on every other undecided failure. Tests: 531 pass (23 new). The harness gains a fake monotonic clock advanced by the stubbed sleep, without which a shared deadline is unobservable.
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.
STACKED — merging lands on
matt/be-12612-narrow-read-only-403-guard(owned by @mattmillerai, PR #277), NOTmain. Review it after #277; the base moves tomainonce #277 merges. Do not read anything below as "ready to merge" againstmain.ELI-5
When GitHub tells the review bot "you're going too fast", the bot used to immediately ask GitHub "hey, did my review actually post?" — on the same connection GitHub had just told it to slow down on. That question usually got throttled too, so the bot got a shrug, assumed the worst, and posted the review again. If the first one had actually gone through, the PR now had two identical reviews and nobody could delete either.
Now the bot waits the amount of time GitHub asked for (capped at 90 seconds) before asking. And if it still can't get a straight answer, it stops writing: the findings go to the run's job summary instead, and the check goes red so nobody mistakes an unverified write for a delivered review.
What changed
.github/cursor-review/post-review.py:gh_post_reviewpasses-i.gh apiprints only the response body by default and offers no knob for a single header, so this is the only wayRetry-After/X-RateLimit-*become reachable. Everything else about the call is unchanged (evidence below). The response goes to stdout, which nothing but the new parser reads.gh_response_headers(result)parses that stdout: requires theHTTP/x.y nnnstatus line, stops at the blank line (so the JSON body is never read as a header), splits on the first colon, lower-cases names.{}on anything else. It reads stdout rather than stderr deliberately — underGH_DEBUG=apistderr carries an echo of the POSTed review body, so scraping headers from there would let a finding that quotes aRetry-After:line dictate how long the process sleeps.is_throttle(result)— 429 by status, or a 403 carrying one of the throttle wordingsis_throttled_403already matches. 408/425 stay out: they are inRETRYABLE_4XX_STATUSESbecause an edge can raise them on a served request, but neither says a rate window is closed, so neither buys anything by waiting.throttle_delay_seconds(result, now=None)—Retry-After, elseX-RateLimit-Remaining: 0+ a parseableX-RateLimit-Reset(rounded up, sincetime.time()is fractional and truncating would wake up before the window reopens), else 60s (GitHub's "wait for at least one minute"). Clamped to[1, 90]. Non-numeric and negative values fall to the next rule rather than being guessed at.confirm_landed(...)— the one seam all three failure paths now read the PR through, so the backoff cannot be added to two and forgotten on the third. It sleeps only under a throttle and is otherwisereview_already_postedverbatim.emit_delivery(False); findings to the job summary underPOST_FAILED_SUMMARY_NOTE;SystemExit(1). Confirmed-ABSENT under a throttle waits once more and then posts the fallback exactly as before.Round 2 — the nine panel findings (all fixed, threads resolved):
throttle_backoff_deadlinecomputes an absolute monotonic deadline once;wait_for_backoffsleeps only the remainder. The second wait used to recompute a fresh full delay from the first response's now-stale headers, so aRetry-Afterwas sat out twice — and on theX-RateLimit-Resetrule the recomputed window had gone negative during the first sleep, fell through, and spent a flat 60s after the window it had just waited out demonstrably reopened. The remainder is now normally zero, which also collapses the ~150s staleness of the confirmed-ABSENT answer down to read latency.gh_post_reviewis bounded byGH_POST_REVIEW_TIMEOUT_SECONDS. Both POSTs precedewrite_step_summary, so a wedged one took the round out of both channels. A timeout becomes a status-lessCompletedProcess— not a throttle, not a read-only token — so it routes into the undecided path and the PR gets asked.throttle_exceeds_budgetcompares the unclamped declared delay against the cap; longer than that and the run makes no further call at all — no wait, no read, no fallback — and goes straight to the job summary and red.Retry-Afteris a throttle.is_read_only_token_errornow excludes onis_throttlerather thanis_throttled_403, so an edge/WAF/GHES refusal with a bare status stops degrading green over a write GitHub may have served.POST_UNCONFIRMED_SUMMARY_NOTEreplaces the "the API rejected the request" claim on every path that cannot make it — including the fallback POST's own throttled-and-unreadable case, which neededpost_or_degradeto report via a new optionaloutcomedict what itsFalsecould not say.reset - math.floor(now)), so a ~309-digitX-RateLimit-Resetcannot raiseOverflowErrorand kill the poster before the job summary; andremaining > 0rather than>= 0, so a window that lapsed within the last second falls through to the default instead of becoming the 1s floor.Docs: a delivery/fallback subsection in
.github/cursor-review/README.md, scoped to distinguish the throttle path from every other undecided failure, and thepostjob's budget comment incursor-review.ymlrestated for the bounded waits, POSTs and reads.Judgment calls
posted=falseon an unconfirmed write, nottrue. A throttle can be raised on a request GitHub served, so atruehere would be a guess that greens the fresh-review gate over nothing. False is the honest and fail-closed direction: worst case a human re-triggers the label on a review that did land.is_throttle's 403 arm conjoins the status (status == 403 and is_throttled_403(...)) rather than callingis_throttled_403alone as the plan's formula reads. That function's own docstring states it is "only ever consulted once the status is already known to be 403" — its allowlist is a substring match overgh's error line, and a 422 validation message is free to quote "rate limit". Strictly narrower than the literal formula, identical on every path that actually reaches it, and it satisfies every case the plan enumerates.2 × 90 (waits) + 2 × 60 (reads) + 2 × 60 (POSTs) = 420s, inside thepostjob'stimeout-minutes: 10alongside the artifact downloads and the token mint. The docstring and the workflow comment both state the real number.Reversed in round 2 — the reviewer was right. Clamping an hour-long embargo to 90s and then calling anyway is a request issued inside a window GitHub explicitly closed, which is what escalates a secondary limit for the whole App installation, not just this run. The cap is still 90s for windows the job can sit out; a longer declared window now skips the wait, the read and the fallback entirely and degrades to the job summary and a red check. Same end state as the old reasoning wanted, reached without the offending call — and in seconds rather than after 90s of sleep that bought nothing. The 60s default is unaffected: it is this repo's guess, not an embargo.Retry-Afteris capped at 90s even when GitHub asks for an hour.Retry-Afteras a throttle. As written that would let a proxy-attached header override an affirmative refusal ("Resource not accessible by integration", an SSO/IP-allowlist block, an archived repo), trading its green degrade for a doomed wait, a doomed read and a permanently red check — the exact regression BE-12612 narrowed this family to avoid. The arm therefore fires only on a messageless 403 (gh'sHTTP 403 (https://…)rendering), which is the scenario the finding actually describes.gh_error_is_messagelessfails closed: anything it cannot fully strip reads as messageful and keeps today's classification.test_a_throttled_post_confirmed_absent_waits_again_then_falls_backasserted the double-wait finding 5 identifies as a bug; it is now..._falls_back_without_a_second_wait. Two note assertions moved toPOST_UNCONFIRMED_SUMMARY_NOTE. All three pinned behaviour this round deliberately inverts, and all three were added by this PR — no coverage frommainor from fix(cursor-review): match the read-only-token 403 by message and let a throttled 403 reach the landed-review check #277 was weakened.test_a_standing_403_never_waits, also this PR's, is kept and is what bounds the new header arm.test_a_throttled_403_with_an_unreadable_list_tags_nothingasserted the old "UNKNOWN posts the fallback" behaviour; that is the exact behaviour this change inverts, and fix(cursor-review): match the read-only-token 403 by message and let a throttled 403 reach the landed-review check #277'sis_read_only_token_errordocstring names it as a deferred residual to be closed here. It is nowtest_a_throttled_403_with_an_unreadable_list_declines_the_fallback, and a new sibling test pins that UNKNOWN from any other cause (5xx, dropped connection, 408/425) still posts the fallback untagged, so the narrowing is bounded.Verification of the
-ipremiseThe change rests on a claim about
ghbehaviour, so it was checked against the real binary rather than asserted — a non-mutating 404 probe (gh api -i /repos/Comfy-Org/github-workflows/pulls/99999999) on gh 2.92.0:1;gh: Not Found (HTTP 404)— byte-identical, sogh_http_status,gh_error_line,is_throttled_403andis_read_only_token_error(all of which read stderr) are untouched;HTTP/2.0 404 Not Found, CRLF-terminated headers in Go's canonical casing (X-Ratelimit-Reset, not theX-RateLimit-ResetGitHub documents), a blank line, then the JSON body.That real capture is what
GH_INCLUDE_STDOUTin the tests is transcribed from, and the parser was run against the captured bytes directly. The CRLF handling and the lower-casing both exist because of what that probe returned, not because of what the plan predicted.Residual
POST /pulls/{n}/reviewswas never exercised end to end. Inducing a live secondary rate limit means hammering GitHub's API, which is exactly the resource-exhausting action that is out of bounds — so the throttle responses are synthetic in the tests, while thegh -ioutput shape they are built on is a verbatim capture from the live API (above). What remains unproven by observation: that GitHub's throttled review-POST response actually carriesRetry-After(the code falls through to the 60s default if it does not, which is a correct outcome either way) and that the 6.5-minute worst case fits the runner's real timing.cursor-review.ymland.github/cursor-review/are watched surfaces and this PR is behavioral, so consumers'uses:pins must move after merge via thebump-cursor-review-callersfleet. Deliberately noSkip-caller-bump: truetrailer.is_throttled_403,gh_error_lineorRETRYABLE_4XX_STATUSES, all of which this hangs off — this branch needs a rebase andis_throttlemay need to follow. It cannot merge before fix(cursor-review): match the read-only-token 403 by message and let a throttled 403 reach the landed-review check #277.maincommit the plan names as its baseline (e29cf0c) was read and matches.Retry-After; one carrying neither a message nor a usable header is still indistinguishable from a standing refusal and still degrades green.)Retry-Afterskip every remaining API call, but a real primary rate limit onPOST /pulls/{n}/reviewswas no more reachable in this run than a secondary one was; the header values it keys on are constructed in the tests.gh_post_review's timeout was not observed firing against a real wedged connection — only against aTimeoutExpiredraised by a stubbedsubprocess.run. What is verified is the shape it produces and how every classifier reads it.Provenance
python3 -m unittest discover -s .github/cursor-review/tests -p 'test_*.py': 531 passed, 0 failed (23 new this round, 3 round-1 tests rewritten);python3 .github/workflow-pins/check_workflow_pins.py: OK, 11 workflows, 0 exempt;python3 .github/agents-md-integrity/check_agents_md.py --root .: passed, 2 pre-existing warnings (AGENTS.md 155 lines, no CODEOWNERS);actionlint .github/workflows/cursor-review.yml: one finding,job.workflow_sha, pre-existing and identical on the base branch;python3 -m py_compileon the changed script. Round 1'sgh api -ilive probe is unchanged and still the basis for the response-shape fixtures; no new live API call was made this round.