Skip to content

Fix rate-limit errors aborting multi-page searches instead of waiting and retrying#103

Merged
shouze merged 7 commits intomainfrom
fix/auto-retry-on-rate-limit
Mar 9, 2026
Merged

Fix rate-limit errors aborting multi-page searches instead of waiting and retrying#103
shouze merged 7 commits intomainfrom
fix/auto-retry-on-rate-limit

Conversation

@shouze
Copy link
Contributor

@shouze shouze commented Mar 9, 2026

Root cause

Three distinct issues combined to produce the observed failure:

1. Long rate-limit waits threw immediately (primary fix)

fetchWithRetry in src/api-utils.ts correctly detected rate-limit responses and computed the reset delay from x-ratelimit-reset. However, when that delay exceeded MAX_AUTO_RETRY_WAIT_MS (10 s), it threw immediately instead of waiting. The paginatedFetch loop propagated the exception, aborting the search at ~90 % completion.

2. Secondary rate limits were not detected

GitHub secondary rate limits return 403 + Retry-After (without x-ratelimit-remaining: 0). isRateLimitExceeded did not recognise this pattern, so the response bypassed the retry logic and surfaced as an unhandled API error via throwApiError.

3. 422 error when total results = 1 000 (exact multiple of 100)

When the result set hit exactly 1 000 items (10 full pages), paginatedFetch attempted page 11 after the last full page. GitHub rejects this with 422 Cannot access beyond the first 1000 results.

Steps to reproduce (before the fix)

github-code-search --org fulll "lodash" --group-by-team-prefix squad-,chapter-
  Fetching results from GitHub… ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░  page 9/10
error: GitHub API rate limit exceeded. Please retry in 53 seconds.

Changes

src/api-utils.ts

  • isRateLimitExceeded: now also matches 403 + Retry-After (secondary rate limit).
  • getRetryDelayMs: adds 1 s clock-skew buffer to the x-ratelimit-reset computation.
  • fetchWithRetry: new optional 4th parameter onRateLimit?: (waitMs: number) => void. When provided and the wait exceeds MAX_AUTO_RETRY_WAIT_MS, the callback is invoked with the wait duration, the function sleeps for that duration without counting it as a retry attempt, then resumes. Without a callback, the existing throw behaviour is preserved.

src/api.ts

  • searchCode: accepts and forwards onRateLimit to fetchWithRetry.
  • fetchAllResults: accepts and forwards onRateLimit; adds early return [] guard when page > totalPages to prevent the page 11 / 422 error.

github-code-search.ts

  • Passes an onRateLimit callback to fetchAllResults that writes a yellow inline message to stderr:
    Rate limited — waiting 53 seconds, resuming automatically…
    

src/api-utils.test.ts

  • 4 new test cases: 403 + Retry-After within threshold (retry), 403 + Retry-After exceeding threshold with callback (no throw), onRateLimit waits not counted as retry attempts, secondary rate-limit 403 callback path.

Steps to verify (after the fix)

github-code-search --org fulll "lodash" --group-by-team-prefix squad-,chapter-
  Fetching results from GitHub… ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░  page 9/10
  Rate limited — waiting 53 seconds, resuming automatically…
  Fetching results from GitHub… ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓  page 10/10
  (TUI opens normally)
bun test               # 547 pass, 0 fail
bun run lint           # 0 warnings, 0 errors
bun run format:check   # no diff
bun run knip           # no issues
bun run build.ts       # binary compiles

Closes #102

… and retrying

Three root causes addressed:

1. fetchWithRetry threw immediately when x-ratelimit-reset indicated a wait
   longer than MAX_AUTO_RETRY_WAIT_MS (10 s), aborting the entire paginatedFetch
   loop and losing all results fetched so far.

   Add an optional onRateLimit callback to fetchWithRetry: when provided, the
   function calls the callback with the wait duration (ms), sleeps for that
   duration without counting it as a retry attempt, then resumes. Callers
   without a callback retain the existing throw behaviour.

2. Secondary rate limits (403 + Retry-After, no x-ratelimit-remaining header)
   were not detected by isRateLimitExceeded, so they bypassed fetchWithRetry's
   retry logic and surfaced as unhandled API errors.

   Extend isRateLimitExceeded to treat any 403 with a Retry-After header as a
   (secondary) rate limit.

3. When total_count is an exact multiple of 100 (e.g. 1000 results / 10 pages),
   paginatedFetch would attempt page 11 after the last full page, which GitHub
   rejects with 422 "Cannot access beyond the first 1000 results".

   Guard fetchAllResults with an early return [] when page > totalPages.

Thread the onRateLimit callback through searchCode → fetchAllResults → the
entry point, where it writes a yellow message to stderr so the user sees the
pause. Also add 1 s clock-skew buffer to x-ratelimit-reset computation.

Fixes #102
Copilot AI review requested due to automatic review settings March 9, 2026 01:20
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

Coverage after merging fix/auto-retry-on-rate-limit into main will be

96.68%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   aggregate.ts100%100%100%100%
   api-utils.ts98.36%100%100%98.15%61, 69
   api.ts94.42%100%100%93.69%317–321, 377, 394, 63–69
   cache.ts98.08%100%100%97.87%28
   completions.ts99.35%100%100%99.29%252
   group.ts100%100%100%100%
   output.ts99.12%100%94.74%99.52%58
   render.ts94.05%100%88.89%94.33%153, 177–182, 184–186, 188–189, 216, 380–381, 424–427
   upgrade.ts88.46%100%94.44%87.89%127–128, 148–155, 158–164, 169, 174, 210–213
src/render
   filter-match.ts97.44%100%92.31%100%
   filter.ts100%100%100%100%
   highlight.ts96.62%100%90.40%99.31%284–285
   rows.ts97.87%100%100%97.73%54–55
   selection.ts100%100%100%100%
   summary.ts100%100%100%100%

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves the CLI’s resilience during multi-page GitHub code searches by handling both primary and secondary rate limits more gracefully and preventing pagination past GitHub’s 1000-result cap.

Changes:

  • Extend rate-limit detection to include secondary rate limits (403 + Retry-After) and add a small clock-skew buffer for x-ratelimit-reset.
  • Add an optional onRateLimit callback pathway to allow long waits to be surfaced to users and automatically resumed (without throwing).
  • Prevent requesting an extra page when total_count is an exact multiple of 100 (e.g., 1000 results → avoid page 11 / 422).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
src/api-utils.ts Enhances rate-limit detection/delay calculation and adds callback-based long-wait handling to fetchWithRetry.
src/api.ts Threads onRateLimit through search/pagination and guards against page overflow beyond computed total pages.
src/api-utils.test.ts Adds tests covering secondary rate-limit handling and the onRateLimit long-wait path.
github-code-search.ts Plumbs an onRateLimit callback from the CLI to show a user-visible “waiting…” message.

When --group-by-team-prefix was used, fetchRepoTeams called
fetchWithRetry without an onRateLimit callback, causing an
immediate throw instead of waiting and retrying.

- Thread onRateLimit through fetchRepoTeams (list teams + repos pages)
- Extract the rate-limit countdown into a named const onRateLimit
  so it is reused by both fetchAllResults and fetchRepoTeams
- Fix broken test (maxRetries=1 → 5 with new longWaitAttempts cap)
- Add test: throws after maxRetries long-wait attempts
- Add test: page-11 guard for total_count=1000 (10 full pages)
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

Coverage after merging fix/auto-retry-on-rate-limit into main will be

96.52%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   aggregate.ts100%100%100%100%
   api-utils.ts94.96%100%93.33%95.16%61, 69, 82–83, 88–89
   api.ts94.55%100%100%93.86%318–322, 383, 400, 63–69
   cache.ts98.08%100%100%97.87%28
   completions.ts99.35%100%100%99.29%252
   group.ts100%100%100%100%
   output.ts99.12%100%94.74%99.52%58
   render.ts94.05%100%88.89%94.33%153, 177–182, 184–186, 188–189, 216, 380–381, 424–427
   upgrade.ts88.46%100%94.44%87.89%127–128, 148–155, 158–164, 169, 174, 210–213
src/render
   filter-match.ts97.44%100%92.31%100%
   filter.ts100%100%100%100%
   highlight.ts96.62%100%90.40%99.31%284–285
   rows.ts97.87%100%100%97.73%54–55
   selection.ts100%100%100%100%
   summary.ts100%100%100%100%

@shouze shouze requested a review from Copilot March 9, 2026 01:47
Replace urlStr.includes("raw.githubusercontent.com") with strict
hostname equality check new URL(urlStr).hostname === "raw.githubusercontent.com"
to prevent false positives from attacker-controlled URLs containing
the substring (e.g. evil.com/raw.githubusercontent.com/...).

Reported by GitHub Advanced Security code scanning (issue #18).
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

Coverage after merging fix/auto-retry-on-rate-limit into main will be

96.52%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   aggregate.ts100%100%100%100%
   api-utils.ts94.96%100%93.33%95.16%61, 69, 82–83, 88–89
   api.ts94.55%100%100%93.86%318–322, 383, 400, 63–69
   cache.ts98.08%100%100%97.87%28
   completions.ts99.35%100%100%99.29%252
   group.ts100%100%100%100%
   output.ts99.12%100%94.74%99.52%58
   render.ts94.05%100%88.89%94.33%153, 177–182, 184–186, 188–189, 216, 380–381, 424–427
   upgrade.ts88.46%100%94.44%87.89%127–128, 148–155, 158–164, 169, 174, 210–213
src/render
   filter-match.ts97.44%100%92.31%100%
   filter.ts100%100%100%100%
   highlight.ts96.62%100%90.40%99.31%284–285
   rows.ts97.87%100%100%97.73%54–55
   selection.ts100%100%100%100%
   summary.ts100%100%100%100%

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

- abortableDelay: extract abort handler to named var so it is removed
  via removeEventListener when the timer fires, preventing listener
  accumulation across many retries

- fetchWithRetry / searchCode / fetchAllResults / fetchRepoTeams:
  narrow onRateLimit type from (void | Promise<void>) to Promise<void>
  and document that the callback MUST sleep for at least waitMs ms;
  a log-only callback would exhaust longWaitAttempts immediately

- github-code-search.ts: add activeCooldown deduplication — concurrent
  Promise.all callers in fetchRepoTeams that hit a rate limit together
  now share the same countdown promise instead of interleaving stderr

- Tests: make all onRateLimit callbacks explicitly async so they
  satisfy the new Promise<void> return-type contract
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

Coverage after merging fix/auto-retry-on-rate-limit into main will be

96.52%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   aggregate.ts100%100%100%100%
   api-utils.ts95%100%93.75%95.16%61, 69, 82–83, 87–88
   api.ts94.55%100%100%93.86%318–322, 383, 400, 63–69
   cache.ts98.08%100%100%97.87%28
   completions.ts99.35%100%100%99.29%252
   group.ts100%100%100%100%
   output.ts99.12%100%94.74%99.52%58
   render.ts94.05%100%88.89%94.33%153, 177–182, 184–186, 188–189, 216, 380–381, 424–427
   upgrade.ts88.46%100%94.44%87.89%127–128, 148–155, 158–164, 169, 174, 210–213
src/render
   filter-match.ts97.44%100%92.31%100%
   filter.ts100%100%100%100%
   highlight.ts96.62%100%90.40%99.31%284–285
   rows.ts97.87%100%100%97.73%54–55
   selection.ts100%100%100%100%
   summary.ts100%100%100%100%

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

- isRateLimitExceeded: include 429 (was only catching 403), so the
  'rate limit exceeded' message correctly covers all HTTP rate-limit
  responses while 503 long-backoff gets its own message
- Long-wait error message differentiates 429/403 rate limit from 503
  server backoff: 'GitHub API requested a long backoff before retrying.'
- options.signal: replace unsafe cast (as AbortSignal | undefined) with
  nullish coalescing (?? undefined) to preserve null-safety
- Test callback: updated to async to match Promise<void> contract
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

Coverage after merging fix/auto-retry-on-rate-limit into main will be

96.52%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   aggregate.ts100%100%100%100%
   api-utils.ts95.07%100%93.75%95.24%64, 72, 85–86, 90–91
   api.ts94.55%100%100%93.86%318–322, 383, 400, 63–69
   cache.ts98.08%100%100%97.87%28
   completions.ts99.35%100%100%99.29%252
   group.ts100%100%100%100%
   output.ts99.12%100%94.74%99.52%58
   render.ts94.05%100%88.89%94.33%153, 177–182, 184–186, 188–189, 216, 380–381, 424–427
   upgrade.ts88.46%100%94.44%87.89%127–128, 148–155, 158–164, 169, 174, 210–213
src/render
   filter-match.ts97.44%100%92.31%100%
   filter.ts100%100%100%100%
   highlight.ts96.62%100%90.40%99.31%284–285
   rows.ts97.87%100%100%97.73%54–55
   selection.ts100%100%100%100%
   summary.ts100%100%100%100%

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

abortableDelay (api-utils.ts):
  - Attach the abort listener BEFORE starting the timer so an abort
    that fires between the initial signal.aborted check and addEventListener
    is not missed (was a critical race condition)
  - Re-check signal.aborted immediately after addEventListener; if aborted
    in the race window, remove the listener and reject synchronously

activeCooldown (github-code-search.ts):
  - Track cooldownUntil timestamp alongside activeCooldown
  - Piggyback only when cooldownUntil >= desiredEnd (current countdown
    covers the required wait); otherwise start a fresh countdown for the
    longer duration so no caller retries before its required deadline
  - Reset cooldownUntil = 0 in .finally() alongside activeCooldown
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

Coverage after merging fix/auto-retry-on-rate-limit into main will be

96.38%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   aggregate.ts100%100%100%100%
   api-utils.ts93.20%100%93.75%93.13%100–102, 64, 72, 85–86, 90–91
   api.ts94.55%100%100%93.86%318–322, 383, 400, 63–69
   cache.ts98.08%100%100%97.87%28
   completions.ts99.35%100%100%99.29%252
   group.ts100%100%100%100%
   output.ts99.12%100%94.74%99.52%58
   render.ts94.05%100%88.89%94.33%153, 177–182, 184–186, 188–189, 216, 380–381, 424–427
   upgrade.ts88.46%100%94.44%87.89%127–128, 148–155, 158–164, 169, 174, 210–213
src/render
   filter-match.ts97.44%100%92.31%100%
   filter.ts100%100%100%100%
   highlight.ts96.62%100%90.40%99.31%284–285
   rows.ts97.87%100%100%97.73%54–55
   selection.ts100%100%100%100%
   summary.ts100%100%100%100%

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

@shouze
Copy link
Contributor Author

shouze commented Mar 9, 2026

Addressed in commit 512795f:

  1. JSDoc isRateLimitExceeded — updated to list all three patterns (429, 403+x-ratelimit-remaining:0, 403+Retry-After).

  2. PR description vs implementation — the description said fetchWithRetry "invokes the callback, then sleeps for that duration", but the callback owns the sleep. The description has been clarified: the onRateLimit callback must perform the sleep (i.e. fetchWithRetry only awaits the returned Promise<void> — whoever supplies the callback is responsible for the wait).

  3. activeCooldown supersession race — the for-loop approach was replaced with a single while (true) loop that re-reads cooldownUntil on every tick. When a concurrent caller needs a longer wait, we now just extend cooldownUntil in-place and return the already-running activeCooldown promise instead of spawning a second loop. This guarantees:

    • Only one loop writes to stderr at a time.
    • .finally() fires exactly once when the longest required wait is fulfilled.
    • No activeCooldown/cooldownUntil reset occurs while a longer wait is still pending.

@shouze shouze requested a review from Copilot March 9, 2026 02:29
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

Coverage after merging fix/auto-retry-on-rate-limit into main will be

96.38%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   aggregate.ts100%100%100%100%
   api-utils.ts93.20%100%93.75%93.13%101–103, 65, 73, 86–87, 91–92
   api.ts94.55%100%100%93.86%318–322, 383, 400, 63–69
   cache.ts98.08%100%100%97.87%28
   completions.ts99.35%100%100%99.29%252
   group.ts100%100%100%100%
   output.ts99.12%100%94.74%99.52%58
   render.ts94.05%100%88.89%94.33%153, 177–182, 184–186, 188–189, 216, 380–381, 424–427
   upgrade.ts88.46%100%94.44%87.89%127–128, 148–155, 158–164, 169, 174, 210–213
src/render
   filter-match.ts97.44%100%92.31%100%
   filter.ts100%100%100%100%
   highlight.ts96.62%100%90.40%99.31%284–285
   rows.ts97.87%100%100%97.73%54–55
   selection.ts100%100%100%100%
   summary.ts100%100%100%100%

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment on lines +228 to +232
// fetchRepoTeams). If a countdown is already running and covers the required
// wait, new callers piggyback on it. If a new caller needs a *longer* wait
// A single shared countdown loop: if a new caller needs a longer wait while
// the loop is already running, we extend cooldownUntil and the loop picks up
// the new deadline on its next tick — no second loop is ever started.
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new rate-limit handler comment block has a duplicated/incomplete sentence ("If a new caller needs a longer wait" followed by another sentence starting the same thought). Please rewrite this comment to be a single coherent explanation of the shared cooldown logic.

Suggested change
// fetchRepoTeams). If a countdown is already running and covers the required
// wait, new callers piggyback on it. If a new caller needs a *longer* wait
// A single shared countdown loop: if a new caller needs a longer wait while
// the loop is already running, we extend cooldownUntil and the loop picks up
// the new deadline on its next tick — no second loop is ever started.
// fetchRepoTeams). If a countdown is already running and already covers the
// required wait, new callers piggyback on it. If a new caller needs a longer
// wait while the loop is already running, we extend cooldownUntil and the
// countdown loop picks up the new deadline on its next tick — no second loop
// is ever started.

Copilot uses AI. Check for mistakes.
Comment on lines +247 to +257
activeCooldown = (async () => {
// Start on a fresh line so the countdown doesn't overwrite the progress bar
process.stderr.write("\n");
while (true) {
const remaining = cooldownUntil - Date.now();
if (remaining <= 0) break;
process.stderr.write(
`\r ${pc.yellow("Rate limited")} — resuming in ${formatRetryWait(remaining)}\u2026${" ".repeat(10)}`,
);
await new Promise((r) => setTimeout(r, 1_000));
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onRateLimit prints an updating countdown every second using \r. When stderr is not a TTY (common in CI/log capture), these updates typically won’t overwrite a single line and can spam logs for long waits (e.g. minutes). Consider detecting process.stderr.isTTY and, when false, printing a single message (or a much lower-frequency update) and sleeping without per-second redraws.

Copilot uses AI. Check for mistakes.
@shouze shouze merged commit c1f40c9 into main Mar 9, 2026
10 checks passed
@shouze shouze deleted the fix/auto-retry-on-rate-limit branch March 9, 2026 02:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rate limit errors abort multi-page searches instead of waiting and retrying

2 participants