Scope GitHub rate-limit backoff per installation - #444
Conversation
Carry credential quota identity through API callers, persist per-resource installation backoffs asynchronously, and probe with the rejected token. Keep healthy installations polling and separate connected-user quotas. Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>
Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>
🕙 Outdated review — superseded by a newer review below🤖 OS review · request changes · quality 3/5 · risk lowSafe once the new REST gate in 🟢 Risk low · recovery in minutes
2 inline comments below. Reviewed 🔁 2 findings → owning session · fix round 1/6 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| data: null, | ||
| error: "GitHub App credential unavailable", | ||
| }; | ||
| if (await ghRateLimited("rest", credential)) |
There was a problem hiding this comment.
🟠 P2 — New REST gate on githubRequest blocks all bot writes for an installation after any rate-limit note, including secondary limits
Before this PR githubRequest never consulted the REST backoff: it only recorded rejections (old code went from if (!token) straight into the fetch). This PR adds a pre-flight ghRateLimited("rest", credential) that returns a synthetic 429 without calling GitHub. That is not part of the stated goal (scoping the existing backoff per installation) and it changes behavior for every write helper built on it: submitReview, postIssueComment, editIssueComment, replyToReviewComment, supersedeReviewComment, routes/issues.ts issue creation.
Concrete failure: a review posts a burst of comments and GitHub answers one with 403 "You have exceeded a secondary rate limit...". isGhRateLimitMsg matches (/rate limit/i), x-ratelimit-remaining is not 0 but x-ratelimit-reset is present on every response, so note() takes the header path and sets rest backoff to the primary reset (+30 s, up to ~1 h). Secondary limits clear in about a minute, but from now on every githubRequest for that installation returns 429 locally: submitReview returns false (review.ts:1313 just console.warns and the inline findings are dropped), postOrEditComment returns null, getPrAutomationDetails throws for the scheduler. Pre-PR, the burst failed for ~60 s and subsequent writes succeeded.
The same misattribution already existed for the read-side gates, but writes were deliberately ungated (the removed module header even called out not suppressing "healthy REST acknowledgements ... comments, or writes"). Smallest fix: remove this gate from githubRequest (keep the noteGhRateLimited recording) so the PR stays a pure re-keying, or gate only when x-ratelimit-remaining === "0" was observed rather than on any message match. Please confirm if gating writes was intentional.
There was a problem hiding this comment.
Fixed in b0d7be0: removed the new pre-flight REST gate from githubRequest while retaining installation-scoped rejection recording. This preserves the existing write-attempt behavior rather than treating every recorded limit as permission to suppress writes. Added a regression proving a POST still reaches GitHub and succeeds during a known REST backoff. Full bun run check passed.
There was a problem hiding this comment.
| log(); | ||
| })().finally(() => this.probes.delete(probeKey)); | ||
| this.probes.set(probeKey, probe); | ||
| await probe; |
There was a problem hiding this comment.
⚪ P3 — note() now blocks the rejected caller on the 10 s probe instead of running it detached
The old noteGhRateLimited set the 15-minute fallback synchronously and let the /rate_limit probe run in the background. The new note() does await probe, and every call site now awaits noteGhRateLimited, so the first caller to hit a headerless rejection is held for the probe fetch (AbortSignal.timeout(10_000)) plus two persists. That includes githubRequest (whose own comment notes it runs under a per-PR lock with no TTL) and ghJson inside the PR-cache sweep. The fallback deadline is already visible to other callers before the probe starts, so nothing needs the probe result. Advisory: set the fallback, persist, then void probe (keeping the probes map for dedup) and expose the promise only to tests, so the rejected request returns immediately.
There was a problem hiding this comment.
Fixed in b0d7be0: the fallback becomes active and the per-credential/resource probe runs detached again, so noteGhRateLimited no longer holds a rejected caller on the reset fetch. Tests explicitly hold two probes open and require both note calls to return before releasing them, while retaining deduplication and late-header protection. A test-only drain ensures probes finish before fixture teardown. Full bun run check passed.
Keep installation-scoped backoff advisory for the write helper and return once the fallback is set instead of awaiting the reset probe. Cover writes during backoff and slow detached probes with regression tests. Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>
🕙 Outdated review — superseded by a newer review below🤖 OS review · approve · quality 4/5 · risk lowSafe to merge. Both prior findings (REST write gate, blocking probe) are fixed in b0d7be0 and verified; the rest of the diff is unchanged and still consistent. 🟢 Risk low · recovery in minutes
Reviewed |
Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>
Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>
🕙 Outdated review — superseded by a newer review below🤖 OS review · approve · quality 4/5 · risk lowSafe to merge. Since the last review, only merges from main landed. They don't change this PR's diff, and new main code that calls 🟢 Risk low · recovery in minutes
Reviewed |
Preserve installation-scoped rate-limit handling while integrating upstream changes and the already-landed deterministic executor test fixture. Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>
🕙 Outdated review — superseded by a newer review below🤖 OS review · approve · quality 4/5 · risk lowSafe to merge. The only new commit is a merge of main. The rate-limit code is unchanged since the last review, and the executor test file now in the diff matches origin/main exactly. 🟢 Risk low · recovery in minutes
Reviewed |
Co-authored-by: Michiel Westerbeek <happylinks@gmail.com>
🤖 OS review · approve · quality 4/5 · risk lowSafe to merge. The only new commit merges current main and touches no feature files. The earlier REST write gate (P2) and blocking probe (P3) are both fixed. 🟢 Risk low · recovery in minutes
Reviewed |
Summary
Fixes the cross-installation backoff described in issue #290.
ghcalls.Persistence now uses lazy asynchronous reads and serialized atomic writes. Legacy unscoped deadlines are intentionally discarded on upgrade: they cannot safely be attributed to an installation. New scoped deadlines survive restarts.
Verification
014ecd0b7b1838f0b0b98af3ca5b8e6af4f9afc0, treeae490e7c9467e3903b35c5c93fba25d170aad0c2, integrating main atffbc9a87ca6db0d913d85c2681bfc4018492eb87.OPENSESSION_TEST_JOBS=2 bun run checkpassed: formatting, type-checking, compiler/lint, 918 isolated unit-test files, and 7 strict transcript snapshot tests (16 assertions).Refs #290
Started by Michiel Westerbeek in this OS session