Skip to content

fix(github): detect primary rate limits returned as 403 - #2776

Merged
ralphbean merged 2 commits into
mainfrom
fix/rate-limit-detection-and-backoff
Jul 1, 2026
Merged

fix(github): detect primary rate limits returned as 403#2776
ralphbean merged 2 commits into
mainfrom
fix/rate-limit-detection-and-backoff

Conversation

@ralphbean

Copy link
Copy Markdown
Member

Summary

  • Broadens isRetryable() to match "rate limit" in 403 response bodies (not just "secondary rate limit"), so do() applies the 60s+ backoff on primary rate limits that GitHub returns as 403 instead of 429.
  • Adds IsRateLimitError() helper to detect rate-limit errors through wrapped error chains.
  • Uses IsRateLimitError() in acquireOrg() to break out of the inner org loop on rate-limit errors — trying more orgs under the same per-user quota just burns API calls and prevents recovery.

Context

CI failure on #2766 — the e2e test hit a primary rate limit returned as HTTP 403 with body "API rate limit exceeded for user ID ...". Because isRetryable() only checked for "secondary rate limit", do() never retried. Then acquireOrg() blasted through all 12 orgs in rapid succession (~20ms apart), each hitting the same user-level limit, repeating every 30 seconds for 10 minutes. The rapid-fire attempts prevented the rate limit from ever recovering.

Independent of #2764 (retry count + jitter) — that PR improves retry behavior for errors that do() does retry; this PR fixes the case where do() wasn't retrying at all.

Test plan

  • TestIsRetryable_PrimaryRateLimitAs403 — verifies 403 with "API rate limit exceeded" is retryable
  • TestIsRetryable_403NotRateLimit — verifies non-rate-limit 403s are still non-retryable
  • TestIsRateLimitError — verifies detection through wrapped errors, 429, primary-as-403, secondary-as-403
  • TestAcquireOrg_RateLimitSkipsRemainingOrgs — verifies only 1 org is attempted before breaking out on rate limit
  • All existing tests pass

🤖 Generated with Claude Code

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix GitHub primary rate limit detection (403) and reduce org-pool thrashing

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Treat GitHub 403 "rate limit" bodies as retryable so do() backs off and retries.
• Add IsRateLimitError() to detect rate limits through wrapped APIError chains.
• Stop acquireOrg() from trying additional orgs after a rate limit to preserve quota.
Diagram

graph TD
A["acquireOrg()"] --> B["tryCreateLock()"] --> C{"Rate limit?"}
C -->|"yes"| D["Skip remaining orgs"]
C -->|"no"| E["Continue / 422 handling"]
B --> F["LiveClient.do()"] --> G["isRetryable()"] --> H["Backoff + retry"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use rate-limit response headers instead of body substring matching
  • ➕ More structured/less brittle than substring search
  • ➕ Can distinguish primary limits via X-RateLimit-* (when present)
  • ➖ Not always present/usable for GitHub 403 rate-limit variants; body text may still be the only signal
  • ➖ Would require more plumbing (header capture) through error paths
2. Introduce typed sentinel errors for rate limits in the client layer
  • ➕ Callers can reliably errors.Is(err, ErrRateLimited) without parsing messages
  • ➕ Centralizes classification and avoids scattering heuristics
  • ➖ Requires refactoring how APIError is constructed/returned across client methods
  • ➖ May be overkill for a narrow fix; larger change surface

Recommendation: The PR’s approach is a pragmatic, low-change fix: broaden isRetryable() to cover GitHub’s primary-as-403 behavior and add IsRateLimitError() to prevent org-pool thrashing. Consider a follow-up to incorporate X-RateLimit headers or sentinel errors if rate-limit handling expands further, but this change is appropriately scoped for the reported CI failure mode.

Files changed (4) +161 / -7

Bug fix (2) +37 / -7
testutil.goBreak org acquisition loop early on GitHub rate-limit errors +12/-2

Break org acquisition loop early on GitHub rate-limit errors

• Updates 'acquireOrg()' to detect rate-limit errors via 'gh.IsRateLimitError(err)' and break out of the org iteration for that round. This avoids consuming additional API calls on other orgs when the effective quota is per-user.

e2e/admin/testutil.go

github.goDetect rate limits via error-chain helper and broaden 403 retryability +25/-5

Detect rate limits via error-chain helper and broaden 403 retryability

• Introduces 'IsRateLimitError(err)' to recognize 429 and 403 rate-limit errors by unwrapping to 'APIError' and checking for "rate limit" in 403 messages. Expands 'isRetryable()' to treat 403 bodies containing "rate limit" (primary or secondary) as retryable, enabling backoff behavior in 'do()'.

internal/forge/github/github.go

Tests (2) +124 / -0
lock_test.goAdd regression test to ensure org-pool stops after rate limit +46/-0

Add regression test to ensure org-pool stops after rate limit

• Adds 'TestAcquireOrg_RateLimitSkipsRemainingOrgs', using a fake forge client that always returns a GitHub 403 rate-limit 'APIError'. The test asserts only the first org is attempted before logging a skip message, preventing rapid-fire attempts across the pool.

e2e/admin/lock_test.go

github_test.goAdd unit tests for primary-as-403 retryability and IsRateLimitError +78/-0

Add unit tests for primary-as-403 retryability and IsRateLimitError

• Adds coverage for GitHub primary rate limits returned as 403 with "API rate limit exceeded" and ensures non-rate-limit 403s remain non-retryable (and return the body). Adds a table-driven test verifying 'IsRateLimitError()' works for 429, primary-as-403, secondary-as-403, and wrapped errors.

internal/forge/github/github_test.go

@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown

Site preview

Preview: https://12c91f97-site.fullsend-ai.workers.dev

Commit: db969ddda5f4c77fae51cc91cd119b328bfadf77

@fullsend-ai-review

fullsend-ai-review Bot commented Jun 30, 2026

Copy link
Copy Markdown

🤖 Review · ❌ Terminated · Started 11:48 AM UTC · Ended 12:02 PM UTC
Commit: 104508d · View workflow run →

@fullsend-ai-review

fullsend-ai-review Bot commented Jun 30, 2026

Copy link
Copy Markdown

🤖 Finished Review · ❌ Failure · Started 11:48 AM UTC · Completed 12:02 PM UTC
Commit: 61a8f04 · View workflow run →

@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.90909% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/forge/github/github.go 90.90% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@qodo-code-review

qodo-code-review Bot commented Jun 30, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 51 rules

Grey Divider


Action required

1. Rate-limit chain misdetected 🐞 Bug ☼ Reliability
Description
IsRateLimitError only treats 403s as rate limits when APIError.Message contains "rate limit", but
LiveClient.do() returns a synthesized 403 APIError after retry exhaustion whose Message is
"retryable error after ..." (no "rate limit"). As a result, acquireOrg may not break out on
persistent rate limits and will run additional full retry cycles against more orgs, wasting time and
quota.
Code

internal/forge/github/github.go[R105-117]

+func IsRateLimitError(err error) bool {
+	var apiErr *APIError
+	if !errors.As(err, &apiErr) {
+		return false
+	}
+	if apiErr.StatusCode == http.StatusTooManyRequests {
+		return true
+	}
+	if apiErr.StatusCode == http.StatusForbidden {
+		lower := strings.ToLower(apiErr.Message)
+		return strings.Contains(lower, "rate limit")
+	}
+	return false
Relevance

⭐⭐⭐ High

Repo has history of accepting stronger rate-limit detection heuristics; synthesized “retryable error
after…” messages exist.

PR-#1612
PR-#2342

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
IsRateLimitError only checks apiErr.Message for "rate limit" on 403s, but LiveClient.do() can
generate a final APIError message after retries that does not include that text, even though the
response was classified as retryable (rate limit). This makes acquireOrg’s new early-break
potentially ineffective in real retry-exhaustion scenarios.

internal/forge/github/github.go[102-118]
internal/forge/github/github.go[153-182]
internal/forge/github/github.go[194-226]
e2e/admin/testutil.go[73-107]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`IsRateLimitError()` only matches 403 rate limits by substring search on `APIError.Message` ("rate limit"). However, when `LiveClient.do()` exhausts retries for a retryable 403, it returns an `APIError` with a synthesized message ("retryable error after ..."), which does not include "rate limit". That makes `gh.IsRateLimitError(err)` return false for the very rate-limit scenario it is intended to catch, so `acquireOrg()` may continue iterating other orgs and trigger additional expensive retry cycles.

## Issue Context
- `do()` decides retryability via `isRetryable()` based on status + headers/body.
- On the last attempt, `do()` returns `&APIError{StatusCode: resp.StatusCode, Message: msg}` where `msg` is a generated string.
- The PR’s new early-break in `acquireOrg()` depends on `IsRateLimitError(err)` being true.

## Fix Focus Areas
- internal/forge/github/github.go[105-117]

### Suggested implementation directions (pick one)
1) Expand `IsRateLimitError()` to also treat `403` APIErrors whose message starts with/contains "retryable error after" as rate limit (since `do()` only synthesizes that message for retryable responses).

OR

2) Preserve rate-limit classification in the synthesized error returned from `do()` (e.g., prefix the synthesized `Message` with "rate limit" for retryable 403/429, or wrap with a dedicated sentinel error), so `IsRateLimitError()` stays accurate without relying on brittle string matching.

Add/adjust a unit test to cover the `do()` retry-exhaustion error shape (403 + synthesized message) and confirm `IsRateLimitError()` returns true for it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

2. Misleading org-pool log ✓ Resolved 🐞 Bug ◔ Observability
Description
When acquireOrg breaks early due to a detected rate limit, it still proceeds into the polling path
and logs "All %d orgs are locked", which is not necessarily true and obscures the real failure mode.
This makes rate-limit incidents harder to diagnose from logs.
Code

e2e/admin/testutil.go[R78-83]

+			// Rate limits are per-user, not per-org — trying more orgs
+			// just burns quota and delays recovery. Break immediately.
+			if gh.IsRateLimitError(err) {
+				logf("[org-pool] Hit rate limit, skipping remaining orgs this round")
+				break
+			}
Relevance

⭐⭐⭐ High

Team previously accepted org-pool lock-debug/log accuracy improvements; misleading “all locked” log
likely to be fixed.

PR-#1215
PR-#1612

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new rate-limit break was added inside the first-pass loop, but the function still
unconditionally logs and behaves as though it exhausted org options due to locks immediately
afterward.

e2e/admin/testutil.go[73-112]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`acquireOrg()` now breaks out of the first-pass org loop when it detects a rate limit, but then unconditionally logs "All %d orgs are locked, polling..." and enters the polling logic. This message is misleading in the rate-limit case.

## Issue Context
The loop exit reason matters for diagnostics; a rate limit is different from all-orgs-locked.

## Fix Focus Areas
- e2e/admin/testutil.go[75-112]

### Suggested fix
Track whether the loop ended due to rate limiting (e.g., `hitRateLimit := true`) and adjust the subsequent log line accordingly (e.g., "Hit rate limit; polling with timeout ..."), or return/propagate the rate-limit error directly if that’s preferred behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +105 to +117
func IsRateLimitError(err error) bool {
var apiErr *APIError
if !errors.As(err, &apiErr) {
return false
}
if apiErr.StatusCode == http.StatusTooManyRequests {
return true
}
if apiErr.StatusCode == http.StatusForbidden {
lower := strings.ToLower(apiErr.Message)
return strings.Contains(lower, "rate limit")
}
return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Rate-limit chain misdetected 🐞 Bug ☼ Reliability

IsRateLimitError only treats 403s as rate limits when APIError.Message contains "rate limit", but
LiveClient.do() returns a synthesized 403 APIError after retry exhaustion whose Message is
"retryable error after ..." (no "rate limit"). As a result, acquireOrg may not break out on
persistent rate limits and will run additional full retry cycles against more orgs, wasting time and
quota.
Agent Prompt
## Issue description
`IsRateLimitError()` only matches 403 rate limits by substring search on `APIError.Message` ("rate limit"). However, when `LiveClient.do()` exhausts retries for a retryable 403, it returns an `APIError` with a synthesized message ("retryable error after ..."), which does not include "rate limit". That makes `gh.IsRateLimitError(err)` return false for the very rate-limit scenario it is intended to catch, so `acquireOrg()` may continue iterating other orgs and trigger additional expensive retry cycles.

## Issue Context
- `do()` decides retryability via `isRetryable()` based on status + headers/body.
- On the last attempt, `do()` returns `&APIError{StatusCode: resp.StatusCode, Message: msg}` where `msg` is a generated string.
- The PR’s new early-break in `acquireOrg()` depends on `IsRateLimitError(err)` being true.

## Fix Focus Areas
- internal/forge/github/github.go[105-117]

### Suggested implementation directions (pick one)
1) Expand `IsRateLimitError()` to also treat `403` APIErrors whose message starts with/contains "retryable error after" as rate limit (since `do()` only synthesizes that message for retryable responses).

OR

2) Preserve rate-limit classification in the synthesized error returned from `do()` (e.g., prefix the synthesized `Message` with "rate limit" for retryable 403/429, or wrap with a dedicated sentinel error), so `IsRateLimitError()` stays accurate without relying on brittle string matching.

Add/adjust a unit test to cover the `do()` retry-exhaustion error shape (403 + synthesized message) and confirm `IsRateLimitError()` returns true for it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@fullsend-ai-review

fullsend-ai-review Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review

Findings

Medium

  • [naming-convention] internal/forge/github/github.goIsRateLimitError is exported (capitalized), but all existing error-checking helpers in this package are private (e.g., isBranchProtectionError, isAlreadyExistsError, isNoChangesError, isNotFound). The established pattern places public error checkers in internal/forge/forge.go with sentinel errors (e.g., ErrNotFound/IsNotFound, ErrForbidden/IsForbidden). The function must be exported since e2e/admin/testutil.go calls it cross-package, but the current placement bypasses the forge abstraction.
    Remediation: Either make IsRateLimitError private and move the rate-limit break logic into the github package, or add forge.ErrRateLimited and forge.IsRateLimited following the existing sentinel error pattern.

  • [logic-error] internal/forge/github/github.go — The retryDelay function applies secondaryRateLimitBackoff (60s+) for ALL retryable 403 responses. With isRetryable now catching primary rate limits returned as 403 (via the broadened "rate limit" body check), primary-as-403 responses without a Retry-After header will get the 60s+ secondary rate limit backoff instead of shorter exponential backoff. Primary rate limits typically recover faster and have known reset times via X-RateLimit-Reset. The comment in retryDelay still says "For secondary rate limits (403)" which is now inaccurate.
    Remediation: Differentiate primary vs secondary rate limits in retryDelay. For primary-as-403 (body contains "API rate limit exceeded"), use shorter exponential backoff or consult the X-RateLimit-Reset header. Update the comment to reflect both primary and secondary 403 handling.


Labels: Bug fix for rate-limit detection in the GitHub forge client with e2e test coverage changes.

Previous run

Review

Findings

High

  • [logic-error] e2e/admin/testutil.go — PR needs rebase. On current main, acquireOrg was refactored from (ctx, client forge.Client, token, runID, pool, timeout, logf) to (ctx, cfg envConfig, runID, pool, timeout, logf), and separate acquireOrgWithClient (line 162) and acquireOrgFromClient (line 168) functions now exist. The diff targets the old signature and will not apply cleanly. After rebase, the IsRateLimitError break must be added to all 4 org-iteration loops: 2 in acquireOrg (lines 82 and 133) and 2 in acquireOrgFromClient (lines 175 and 208). The new test TestAcquireOrg_RateLimitSkipsRemainingOrgs also calls acquireOrg(ctx, fake, ...) with the old signature — it should call acquireOrgWithClient instead.
    Remediation: Rebase onto current main. Update the new test to use acquireOrgWithClient. Add IsRateLimitError break to all 4 org-iteration loops.

Medium

  • [logic-error] internal/forge/github/github.go:224 — The retryDelay function applies secondaryRateLimitBackoff (60s+) for ALL retryable 403 responses. With isRetryable now catching primary rate limits returned as 403, these will also get 60s+ backoff instead of shorter exponential backoff. Primary rate limits have known reset times via X-RateLimit-Reset and often recover faster. The Retry-After header path (line 218) mitigates many cases, but when that header is absent on a primary-as-403, the backoff is unnecessarily conservative. The comment at line 223 still says "For secondary rate limits (403)" which is now inaccurate.

Low

  • [edge-case] internal/forge/github/github.go:201 — Broadening isRetryable's 403 body check from "secondary rate limit" to "rate limit" is correct for the bug being fixed, but is broader than necessary. While the risk of false positives is low in practice (non-rate-limit 403s like SAML SSO or abuse detection use different phrasing), a more precise disjunction ("api rate limit" || "secondary rate limit") would be more defensive.

  • [design-smell] internal/forge/github/github.go — The forge package uses sentinel errors with package-level helpers (forge.IsNotFound, forge.IsForbidden, forge.IsBranchProtected) for cross-forge error checking. The new IsRateLimitError is placed in the github package instead, requiring callers to import the GitHub-specific package. Consider adding forge.ErrRateLimited and forge.IsRateLimited for consistency. (Acknowledged: rate-limit detection spans multiple HTTP codes with body inspection, which is more complex than existing sentinel mappings.)


Labels: Bug fix for rate-limit detection in the GitHub forge client, with e2e test changes.

GitHub sometimes returns primary rate limits as HTTP 403 with body
text "API rate limit exceeded" instead of the expected 429. The
isRetryable() check only matched "secondary rate limit" in 403 bodies,
so these primary-as-403 errors passed straight through without any
retry or backoff.

Additionally, acquireOrg() had no rate-limit awareness — when one org
hit a rate limit, it would immediately try all remaining orgs in the
pool, burning through the same per-user quota. This prevented the
rate limit from ever recovering.

Two fixes:
- Broaden isRetryable() to match "rate limit" (not just "secondary
  rate limit") in 403 response bodies, so do() applies the 60s+
  backoff before returning to callers.
- Add IsRateLimitError() helper and use it in acquireOrg() to break
  out of the inner org loop on rate-limit errors, since trying more
  orgs under the same user quota is pointless.

Signed-off-by: Ryan Bean <rbean@redhat.com>
Assisted-by: Claude claude-opus-4-6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
@ralphbean
ralphbean force-pushed the fix/rate-limit-detection-and-backoff branch from 61a8f04 to d27f367 Compare July 1, 2026 18:30
@ralphbean
ralphbean requested a review from a team as a code owner July 1, 2026 18:30
@fullsend-ai-review

fullsend-ai-review Bot commented Jul 1, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 6:34 PM UTC · Ended 6:43 PM UTC
Commit: 0123b0b · View workflow run →

@fullsend-ai-review

fullsend-ai-review Bot commented Jul 1, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 6:46 PM UTC · Ended 6:47 PM UTC
Commit: 0123b0b · View workflow run →

The rebase brought in the envConfig refactor from main, which changed
acquireOrg's signature. Update the rate-limit test to call
acquireOrgWithClient instead. Also add the IsRateLimitError break
to acquireOrgFromClient so the test-facing code path has the same
rate-limit awareness as the production acquireOrg.

Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
@ralphbean
ralphbean force-pushed the fix/rate-limit-detection-and-backoff branch from c5cbc52 to db969dd Compare July 1, 2026 18:47
@ralphbean
ralphbean enabled auto-merge July 1, 2026 18:47
@fullsend-ai-review

fullsend-ai-review Bot commented Jul 1, 2026

Copy link
Copy Markdown

🤖 Review · ❌ Terminated · Started 6:50 PM UTC · Ended 7:05 PM UTC
Commit: 0123b0b · View workflow run →

@qodo-code-review

Copy link
Copy Markdown

CI Feedback 🧐

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: e2e

Failed stage: Run e2e tests [❌]

Failed test name: TestAdminInstallUninstall

Failure summary:

The GitHub Action failed because the E2E Go test suite exited with a failing test, causing make
e2e-test to return a non-zero status (Makefile:137, exit code 2).
- TestAdminInstallUninstall failed
in e2e/admin/admin_test.go:
- The test repeatedly polled for an “enrollment PR” and logged Attempt
7 through Attempt 20: enrollment PR not yet visible (admin_test.go:197).
- It then hit an
assertion failure: Expected value not to be nil with message enrollment PR should exist for
test-repo (error trace points to /e2e/admin/admin_test.go:271 and /e2e/admin/admin_test.go:197).

Additional context in the logs shows GitHub API rate limiting during org lock acquisition (e.g.,
github api: 403 API rate limit exceeded), which may contribute to flakiness/delays in PR visibility,
but the direct failure is the missing enrollment PR assertion in TestAdminInstallUninstall.

Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

20:  Contents: read
21:  Metadata: read
22:  ##[endgroup]
23:  Secret source: Actions
24:  Prepare workflow directory
25:  Prepare all required actions
26:  Getting action download info
27:  Download action repository 'actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0' (SHA:9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)
28:  Download action repository 'actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c' (SHA:4a3601121dd01d1626a1e23e37211e3254c1c06c)
29:  Download action repository 'google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093' (SHA:7c6bc770dae815cd3e89ee6cdf493a5fab2cc093)
30:  Download action repository 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' (SHA:043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)
31:  Complete job name: e2e
32:  ##[group]Run if [ "$EVENT_NAME" = "merge_group" ]; then
33:  �[36;1mif [ "$EVENT_NAME" = "merge_group" ]; then�[0m
34:  �[36;1m  FILES=$(gh api "repos/${REPO}/compare/${MERGE_GROUP_BASE}...${MERGE_GROUP_HEAD}" --jq '.files[].filename') || {�[0m
35:  �[36;1m    echo "::warning::Failed to fetch merge group files — running e2e tests as a precaution"�[0m
36:  �[36;1m    echo "relevant=true" >> "$GITHUB_OUTPUT"�[0m
37:  �[36;1m    exit 0�[0m
38:  �[36;1m  }�[0m
39:  �[36;1m  FILE_COUNT=$(echo "$FILES" | wc -l)�[0m
40:  �[36;1m  if [ "$FILE_COUNT" -ge 300 ]; then�[0m
41:  �[36;1m    echo "::warning::Compare API returned $FILE_COUNT files (possible truncation at 300) — running e2e tests as a precaution"�[0m
42:  �[36;1m    echo "relevant=true" >> "$GITHUB_OUTPUT"�[0m
43:  �[36;1m    exit 0�[0m
44:  �[36;1m  fi�[0m
45:  �[36;1melse�[0m
46:  �[36;1m  FILES=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename') || {�[0m
47:  �[36;1m    echo "::warning::Failed to fetch PR files — running e2e tests as a precaution"�[0m
48:  �[36;1m    echo "relevant=true" >> "$GITHUB_OUTPUT"�[0m
...

545:  admin_test.go:197: Attempt 7: enrollment PR not yet visible
546:  admin_test.go:197: Attempt 8: enrollment PR not yet visible
547:  admin_test.go:197: Attempt 9: enrollment PR not yet visible
548:  admin_test.go:197: Attempt 10: enrollment PR not yet visible
549:  admin_test.go:197: Attempt 11: enrollment PR not yet visible
550:  admin_test.go:197: Attempt 12: enrollment PR not yet visible
551:  admin_test.go:197: Attempt 13: enrollment PR not yet visible
552:  admin_test.go:197: Attempt 14: enrollment PR not yet visible
553:  admin_test.go:197: Attempt 15: enrollment PR not yet visible
554:  admin_test.go:197: Attempt 16: enrollment PR not yet visible
555:  admin_test.go:197: Attempt 17: enrollment PR not yet visible
556:  admin_test.go:197: Attempt 18: enrollment PR not yet visible
557:  admin_test.go:197: Attempt 19: enrollment PR not yet visible
558:  admin_test.go:197: Attempt 20: enrollment PR not yet visible
559:  admin_test.go:197: 
560:  Error Trace:	/home/runner/work/fullsend/fullsend/e2e/admin/admin_test.go:271
561:  /home/runner/work/fullsend/fullsend/e2e/admin/admin_test.go:197
562:  Error:      	Expected value not to be nil.
563:  Test:       	TestAdminInstallUninstall
564:  Messages:   	enrollment PR should exist for test-repo
565:  cleanup.go:288: [cleanup] Deleting repo halfsend-02/.fullsend
566:  lock.go:196: [e2e-lock] Lock released (run: 52ea8ab2)
567:  --- FAIL: TestAdminInstallUninstall (232.15s)
568:  === RUN   TestVendorFromSubdirectory
...

698:  lock.go:134: [e2e-lock] Lock repo test-org-3/e2e-lock created, writing run ID
699:  lock.go:167: [e2e-lock] Lock acquired (run: run-2)
700:  --- PASS: TestAcquireOrg_SkipsLockedOrg (0.00s)
701:  === RUN   TestAcquireOrg_AllLockedTimesOut
702:  testutil.go:190: [org-pool] Trying to acquire test-org-1...
703:  lock.go:122: [e2e-lock] Attempting to create lock repo test-org-1/e2e-lock
704:  lock.go:126: [e2e-lock] Lock repo test-org-1/e2e-lock already exists (repo locked by another run)
705:  testutil.go:190: [org-pool] Trying to acquire test-org-2...
706:  lock.go:122: [e2e-lock] Attempting to create lock repo test-org-2/e2e-lock
707:  lock.go:126: [e2e-lock] Lock repo test-org-2/e2e-lock already exists (repo locked by another run)
708:  lock.go:122: [e2e-lock] Attempting to create lock repo test-org-1/e2e-lock
709:  lock.go:126: [e2e-lock] Lock repo test-org-1/e2e-lock already exists (repo locked by another run)
710:  lock.go:122: [e2e-lock] Attempting to create lock repo test-org-2/e2e-lock
711:  lock.go:126: [e2e-lock] Lock repo test-org-2/e2e-lock already exists (repo locked by another run)
712:  --- PASS: TestAcquireOrg_AllLockedTimesOut (1.00s)
713:  === RUN   TestAcquireOrg_PropagatesErrors
714:  testutil.go:190: [org-pool] Trying to acquire test-org-1...
715:  lock.go:122: [e2e-lock] Attempting to create lock repo test-org-1/e2e-lock
716:  testutil.go:193: [org-pool] Error trying test-org-1: creating lock repo in test-org-1: rate limited
717:  lock.go:122: [e2e-lock] Attempting to create lock repo test-org-1/e2e-lock
718:  --- PASS: TestAcquireOrg_PropagatesErrors (1.00s)
719:  === RUN   TestAcquireOrg_RateLimitSkipsRemainingOrgs
720:  lock_test.go:163: [org-pool] Trying to acquire test-org-1...
721:  lock_test.go:163: [e2e-lock] Attempting to create lock repo test-org-1/e2e-lock
722:  lock_test.go:163: [org-pool] Error trying test-org-1: creating lock repo in test-org-1: github api: 403 API rate limit exceeded for user ID 12345
723:  lock_test.go:163: [org-pool] Hit rate limit, skipping remaining orgs this round
724:  lock_test.go:163: [e2e-lock] Attempting to create lock repo test-org-1/e2e-lock
725:  lock_test.go:163: [org-pool] Hit rate limit, skipping remaining orgs this round
726:  --- PASS: TestAcquireOrg_RateLimitSkipsRemainingOrgs (2.00s)
727:  FAIL
728:  FAIL	github.com/fullsend-ai/fullsend/e2e/admin	258.046s
729:  FAIL
730:  make: *** [Makefile:137: e2e-test] Error 1
731:  ##[error]Process completed with exit code 2.
732:  ##[group]Run actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a

@fullsend-ai-review fullsend-ai-review Bot added requires-manual-review Review requires human judgment type/bug Confirmed defect in existing behavior component/e2e End-to-end tests labels Jul 1, 2026
@fullsend-ai-review

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 6:50 PM UTC · Completed 7:05 PM UTC
Commit: db969dd · View workflow run →

@ralphbean
ralphbean added this pull request to the merge queue Jul 1, 2026

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review Squad — 4 findings (MEDIUM)

Agents: 6 (2x Claude, 2x Claude, 1x Gemini, 1x Codex) | Models: Claude, Gemini, Codex

The HIGH finding (do() retry-exhaustion loses rate-limit signal for IsRateLimitError()) is already covered by an existing review comment — skipped.

See inline comments for MEDIUM findings.

Assisted-by: Claude (review), Gemini (review), Codex (review)

return false, nil
}
if strings.Contains(strings.ToLower(string(data)), "secondary rate limit") {
if strings.Contains(strings.ToLower(string(data)), "rate limit") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

MEDIUM — Broadened "rate limit" match could be more precise (3-agent consensus: Claude, Gemini)

Changing from "secondary rate limit" to "rate limit" correctly catches primary-as-403, but is broader than needed. No known GitHub 403 messages trigger false positives today, but a more targeted disjunction would be more defensive:

lower := strings.ToLower(string(data))
if strings.Contains(lower, "rate limit exceeded") || strings.Contains(lower, "secondary rate limit") {
    return true, nil
}

This covers both known patterns ("API rate limit exceeded for user ID ..." and "You have exceeded a secondary rate limit") with less ambiguity. Acceptable as-is if the team prefers resilience to unknown variants.

Assisted-by: Claude (review), Gemini (review)

Comment thread e2e/admin/testutil.go
for _, org := range shuffled {
acquired, err := tryCreateLock(ctx, client, org, runID, logf)
if err != nil {
if gh.IsRateLimitError(err) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

MEDIUM — Missing error log before rate-limit check (3-agent consensus: Claude)

This is the only one of the 4 identical IsRateLimitError + break blocks that does NOT log the error before checking. The other 3 sites (lines 90, 148, 194) all have:

logf("[org-pool] Error trying %s: %v", org, err)

Without it, errors in this code path are silently swallowed — a rate-limit break logs "skipping remaining" but never shows the actual error.

 if err != nil {
+    logf("[org-pool] Error trying %s: %v", org, err)
     if gh.IsRateLimitError(err) {

Assisted-by: Claude (review)

// Secondary rate limits may include a Retry-After header, or may only be
// identifiable by the response body containing "secondary rate limit".
// GitHub uses 429 for primary rate limits and 403 for both primary and
// secondary rate limits. Rate-limit 403s may include a Retry-After header,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

MEDIUM — Stale secondaryRateLimitBackoff naming and comments (5-agent consensus: Claude, Gemini, Codex)

The updated comment here correctly reflects that 403 covers both primary and secondary rate limits. However, the variable secondaryRateLimitBackoff (line 231) and its doc comment still say "secondary rate limits", and the retryDelay inline comment (line 248) says "For secondary rate limits (403)". These are now inaccurate since isRetryable() routes all 403 rate limits through that backoff.

Suggest renaming secondaryRateLimitBackoffrateLimitBackoff403 and updating the downstream comments to match:

// rateLimitBackoff is the minimum backoff for rate-limit 403s
// (primary or secondary) when no Retry-After header is present.
var rateLimitBackoff = 60 * time.Second

Assisted-by: Claude (review), Gemini (review), Codex (review)

Comment thread e2e/admin/testutil.go
if gh.IsRateLimitError(err) {
logf("[org-pool] Hit rate limit, skipping remaining orgs this round")
break
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

MEDIUM — Misleading "All orgs are locked" log after rate-limit break (2-agent consensus: Claude)

When this first-pass loop breaks due to a rate limit, execution falls through to line 126 which logs "All %d orgs are locked, polling with timeout %s". The orgs aren't locked — the loop exited due to a rate limit. This makes incident diagnosis harder from logs.

Suggest tracking whether the break was rate-limit-induced:

hitRateLimit := false
for _, org := range shuffled {
    // ...
    if gh.IsRateLimitError(err) {
        logf("[org-pool] Hit rate limit, skipping remaining orgs this round")
        hitRateLimit = true
        break
    }
}
if hitRateLimit {
    logf("[org-pool] Rate limited, polling with timeout %s", timeout)
} else {
    logf("[org-pool] All %d orgs are locked, polling with timeout %s", len(pool), timeout)
}

Assisted-by: Claude (review)

Merged via the queue into main with commit dad3731 Jul 1, 2026
28 of 30 checks passed
@ralphbean
ralphbean deleted the fix/rate-limit-detection-and-backoff branch July 1, 2026 19:14
@fullsend-ai-retro

fullsend-ai-retro Bot commented Jul 1, 2026

Copy link
Copy Markdown

🤖 Finished Retro · ✅ Success · Started 7:18 PM UTC · Completed 7:27 PM UTC
Commit: db969dd · View workflow run →

@fullsend-ai-retro

Copy link
Copy Markdown

Retro: PR #2776 — fix(github): detect primary rate limits returned as 403

Overall: A well-scoped human-authored bug fix that went through 4 review agent runs (1 failure, 2 cancelled, 1 success) before merging. Two improvement opportunities identified.

Timeline

  • 2026-06-30 11:45 — PR created by ralphbean
  • 2026-06-30 11:48–12:02 — Review agent run 1: failure (still posted findings including HIGH: needs rebase)
  • 2026-06-30 11:50 — Qodo review found a real bug: IsRateLimitError won't catch synthesized 403s from do() after retry exhaustion
  • 2026-07-01 08:02 — Human (rh-hemartin) approved
  • 2026-07-01 18:30–18:47 — Two force pushes (rebase), auto-merge enabled
  • 2026-07-01 18:34–18:47 — Review runs 2 & 3 cancelled (superseded by rapid pushes)
  • 2026-07-01 18:50–19:05 — Review run 4 succeeded
  • 2026-07-01 19:05 — PR entered merge queue
  • 2026-07-01 19:08–19:10 — Review squad posted 4 MEDIUM findings (after merge queue entry)
  • 2026-07-01 19:14 — Merged

Skipped proposals (covered by existing issues)

  • Redundant review dispatches on rapid pushes → #1418, #1422
  • Auto re-dispatch on review failures → #2711
  • Review agent missing cross-file analysis the review squad catches → #1525 (partially overlaps proposal 2 below but that issue is broader; the specific error-chain gap is novel enough to propose separately)

Proposals filed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component/e2e End-to-end tests requires-manual-review Review requires human judgment type/bug Confirmed defect in existing behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants