Skip to content

fix(#6702): observe GitHub rate-limit state and name it in exhausted-retry errors - #6705

Merged
waynesun09 merged 1 commit into
mainfrom
fix-6702-ratelimit-instrumentation
Aug 31, 2026
Merged

fix(#6702): observe GitHub rate-limit state and name it in exhausted-retry errors#6705
waynesun09 merged 1 commit into
mainfrom
fix-6702-ratelimit-instrumentation

Conversation

@waynesun09

@waynesun09 waynesun09 commented Aug 27, 2026

Copy link
Copy Markdown
Member

Summary

Instrumentation step for the first of the two real causes behind the #6647 behaviour-test failures (diagnosed on #6697): the pool-org installation token's API budget is exhausted ~10 minutes into the suite, after which the harness waits poll blind. Two runs on different orgs (halfsend-10 and halfsend-06) showed the same onset; the second already had #6669's backoff.

Which limit fires (primary hourly vs secondary) and who spends the budget cannot be told from today's logs: the client never reads X-RateLimit-*, and its exhausted-retry error (403 retryable error after 5 attempts …) does not satisfy its own IsRateLimitError. This PR makes the numbers visible so the fix (conditional requests / circuit breaker / parallelism) is chosen from evidence rather than guessed — which is how #6697 went wrong the first time.

Related Issue

Refs #6702 (left open for the fix step)

Changes

  • forge.RateLimit + forge.RateLimitReporter: the last observed primary-quota state (X-RateLimit-Limit/Remaining/Reset/Resource), with a String() that renders absent fields as unknown rather than plausible zeros.
  • LiveClient records the headers of every response and implements the reporter.
  • Exhausted-retry error for 429 and retryable 403 is now rate limit: retryable error after N attempts on … [remaining=0/5000 reset=… resource=core], so IsRateLimitError matches it. The budget in the brackets is parsed from the response that failed; when that response carries no X-RateLimit-* (secondary-limit shape), the message says so and names the last observation with its age — a stale value is never presented as the failing response's budget. 5xx exhaustion is unchanged.
  • Org-pool acquisition (pkg/e2etest): with mint, tokens are minted per org so the limit is per org — a rate-limited org is now skipped for the next one instead of aborting the round (the round still records the limit, so if every org turns out limited the existing between-round back-off applies); the shared-PAT path is unchanged. Without this, making the exhausted-retry 403 recognisable would have activated a stale "limits are per user" assumption.
  • Behaviour suite: the composed pool driver logs [driver] rate limit after allocating|deallocating <org/repo>: remaining=… on every allocation and release (not just the first ensure per repo, which the ensured cache short-circuits), so the samples span the whole run.

Testing

  • go vet, gofmt; go test -race ./internal/forge/... ./pkg/behaviourtest/drivers/install/ ./pkg/e2etest/
  • TestDo_ObservesRateLimitHeaders, TestDo_ResponseWithoutRateLimitHeadersKeepsPreviousObservation, TestDo_RateLimitExhaustedErrorSelfIdentifies, TestDo_RateLimitExhaustedWithoutHeadersNamesLastObservation (secondary-limit shape), TestDo_ServerErrorExhaustedIsNotARateLimit, TestRateLimitString_UnknownFields, TestComposedDriver_SamplesRateLimitOnAllocateAndDeallocate
  • One E2E behaviour run to read the drain curve and the resource/limit on the 403s (this PR's purpose)

Follow-ups (not here)

  • Once ci(#6697): make harness-wait timeouts report why the wait failed #6698 lands, the harness-wait timeout diagnostics can append the reporter's state too; kept out to avoid a conflict with that PR.
  • forge.IsTransient still excludes exhausted rate limits, so steps/cleanup.go will not retry them; now that the error self-identifies, it could consult IsRateLimitError.

Checklist

  • PR title follows Conventional Commits (correct type, ! for breaking changes)
  • I wrote this contribution myself and can explain all changes in it

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Observe GitHub rate limits and classify exhausted retries

🐞 Bug fix ✨ Enhancement 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Track the latest GitHub REST rate-limit budget through a thread-safe forge reporter.
• Mark exhausted 403/429 retries as rate-limit errors with actionable budget context.
• Sample shared-token budgets before behaviour-test repository allocations to diagnose depletion.
Diagram

graph TD
  A["GitHub API"] -->|headers| B["LiveClient"] -->|stores| C["Rate state"] -->|appends| D["Retry error"] -->|recognized by| E["Rate-limit handling"]
  C -->|reports| F["Repo ensurer"] -->|samples| G["Suite logs"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Structured rate-limit error metadata
  • ➕ Avoids relying on error-message text for 403 classification
  • ➕ Lets callers inspect rate-limit state without parsing strings
  • ➖ Requires a broader APIError contract change
  • ➖ Increases migration and compatibility scope for an instrumentation-focused PR
2. HTTP transport-level observation
  • ➕ Captures headers independently of request helper implementation
  • ➕ Can be reused by additional GitHub request paths
  • ➖ Adds custom RoundTripper complexity
  • ➖ Retry error construction still needs access to synchronized observed state

Recommendation: Keep the PR's client-level reporter and minimal error-prefix approach for this diagnostic step because all REST calls already pass through do(), existing IsRateLimitError callers benefit immediately, and the focused scope reduces conflict risk. Consider structured error metadata later if rate-limit state becomes control-plane input rather than diagnostic context.

Files changed (5) +211 / -0

Enhancement (2) +37 / -0
forge.goDefine shared rate-limit reporting contracts +22/-0

Define shared rate-limit reporting contracts

• Adds a provider-neutral RateLimit value with log formatting and an optional RateLimitReporter interface. Callers can inspect the latest observed GitHub REST budget without expanding the core forge.Client contract.

internal/forge/forge.go

ensure.goLog API budget before repository allocation +15/-0

Log API budget before repository allocation

• Samples clients implementing RateLimitReporter before each repository ensure operation. This exposes the shared installation token's budget drain curve in behaviour-suite logs without affecting clients lacking the optional capability.

pkg/behaviourtest/drivers/install/ensure.go

Bug fix (1) +57 / -0
github.goObserve rate-limit headers and enrich exhausted retries +57/-0

Observe rate-limit headers and enrich exhausted retries

• Stores the latest valid X-RateLimit response headers under synchronization and exposes them through RateLimitReporter. Exhausted retryable 403 and 429 errors now identify themselves as rate limits and include the observed budget, while 5xx exhaustion remains unchanged.

internal/forge/github/github.go

Tests (2) +117 / -0
github_test.goCover rate observation and retry classification +89/-0

Cover rate observation and retry classification

• Verifies header parsing, initial reporter state, preservation when later responses omit headers, and budget formatting. Also confirms exhausted rate-limit retries satisfy IsRateLimitError while exhausted 5xx responses do not.

internal/forge/github/github_test.go

ensure_test.goVerify optional rate-limit logging +28/-0

Verify optional rate-limit logging

• Adds a reporter-capable test client and confirms logging occurs only after a rate-limit observation exists. The expected log includes remaining budget, limit, reset timestamp, and resource.

pkg/behaviourtest/drivers/install/ensure_test.go

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 27, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 9:31 PM UTC · Ended 9:46 PM UTC

Commit: 204f501 · View workflow run →

@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.07143% with 19 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/forge/forge.go 0.00% 11 Missing ⚠️
pkg/e2etest/testutil.go 0.00% 8 Missing ⚠️

📢 Thoughts on this report? Let us know!

@qodo-code-review

qodo-code-review Bot commented Aug 27, 2026

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Remediation recommended

1. Required E2E run missing ✗ Dismissed 📘 Rule violation ▣ Testability
Description
This PR changes internal/forge/, but its testing checklist leaves the E2E behavior run unchecked
and provides no evidence that make e2e-test passed. The required live install/uninstall coverage
therefore remains unverified for the new rate-limit behavior.
Code

internal/forge/github/github.go[R238-240]

+		if err == nil {
+			c.observeRateLimit(resp.Header)
+		}
Relevance

●●● Strong

Recent history accepts enforcing E2E coverage and explicitly treats internal/forge changes as
behavior-suite relevant.

PR-#2277
PR-#2793

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1062051 requires a successful make e2e-test run for changes under
internal/forge/. The changed response-handling code is under that path, the repository guide
repeats the requirement at docs/contributing/go-code.md[58-63], and the PR description explicitly
leaves its E2E behavior run unchecked.

Rule 1062051: Run end-to-end tests for critical internal modules before merge
internal/forge/github/github.go[238-240]
docs/contributing/go-code.md[58-63]

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

## Issue description
The PR modifies `internal/forge/` without evidence that the required `make e2e-test` run completed successfully.

## Issue Context
The repository's Go contribution guide requires E2E tests for changes under `internal/forge/`. Run the required target against the latest commit, address any failures, and record the successful result in the PR description or CI checks.

## Fix Focus Areas
- internal/forge/github/github.go[238-240]

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


2. Error reports unrelated budget 🐞 Bug ◔ Observability
Description
The exhausted-retry path reads the client-wide last observation, so if the final 403/429 lacks
rate-limit headers—or another concurrent request updates the client first—the error reports stale or
unrelated remaining/reset/resource values as if they described the failure. This can produce the
wrong diagnosis for the instrumentation this change adds, especially on the shared concurrent
behaviour-suite client.
Code

internal/forge/github/github.go[R296-298]

+				if rl, ok := c.RateLimit(); ok {
+					msg += " [" + rl.String() + "]"
+				}
Relevance

●●● Strong

Recent GitHub client history accepts findings that correct misleading error semantics and validate
response-specific behavior.

PR-#2595
PR-#5213

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
observeRateLimit stores one record for the entire client and deliberately retains it when a
response has no X-RateLimit-Remaining; the added exhausted-error branch then fetches that shared
record instead of parsing the final resp. The test explicitly proves that a headerless response
leaves an earlier observation active, while the mutex only prevents data races and does not
associate observations with requests.

internal/forge/github/github.go[50-80]
internal/forge/github/github.go[237-240]
internal/forge/github/github.go[283-300]
internal/forge/github/github_test.go[4442-4462]

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

## Issue description
Exhausted 403/429 errors append the client-wide last rate-limit observation rather than state from the response that exhausted retries. A headerless final response or concurrent request can therefore make the error report stale or unrelated budget data.

## Issue Context
Keep the client-wide observation for `RateLimitReporter`, but error diagnostics must be tied to the final response. Only append a budget when the failing response itself contains a valid `X-RateLimit-Remaining` value.

## Fix Focus Areas
- internal/forge/github/github.go[61-80]
- internal/forge/github/github.go[237-240]
- internal/forge/github/github.go[294-298]
- internal/forge/github/github_test.go[4442-4462]

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


Grey Divider

Context sources
✅ Compliance rules (platform): 62 rules
Review mode: ⚖️ Balanced

Grey Divider

Tip of the day
💡 Did you know, you can type 'qodo, fix this' on a finding and the fix lands right on your PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread internal/forge/github/github.go
Comment thread internal/forge/github/github.go Outdated
@waynesun09
waynesun09 force-pushed the fix-6702-ratelimit-instrumentation branch from 204f501 to 58137f4 Compare August 27, 2026 21:45
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 27, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 9:48 PM UTC · Completed 10:08 PM UTC

Commit: 58137f4 · View workflow run →

Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $7.38

@fullsend-ai-review fullsend-ai-review Bot added the risk/moderate PR risk: moderate label Aug 27, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 27, 2026

Copy link
Copy Markdown

Risk Assessment: moderate (2/5)

Details

Medium-sized additive change (357 lines, 8 files) adding rate-limit observability. No protected paths, no security-sensitive files, no CI/dependency changes. Forge core files are high-churn hotspots but changes are purely additive instrumentation. Well-scoped step 1 of a 2-step bug fix with clear issue linkage. Re-review anchoring: signals unchanged from prior assessment.

Previous run

Risk Assessment: moderate (2/5)

Details

Medium-sized additive change (332 lines, 8 files) adding rate-limit observability to the GitHub client and behaviour-test harness. No protected paths, no security-sensitive files, no CI/dependency changes. The forge core files are high-churn hotspots with many recent authors and fix commits, elevating git-history risk. However, the change is purely additive instrumentation (step 1 of 2) with no behavioral modification, well-scoped to the linked issue, and includes tests. Overall moderate risk.

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 27, 2026

Copy link
Copy Markdown

Review

Findings

Low

  • [comment-style] internal/forge/forge.go:348 — The RateLimitReporter godoc comment includes return-value semantics ("ok is false until the client has seen a response carrying the headers") on the interface type doc rather than on the RateLimit() method. For a single-method interface this is borderline; consider moving the ok semantics to the method's godoc on LiveClient.RateLimit.
Previous run

Review

Findings

Low

  • [edge-case] pkg/e2etest/testutil.go:104 — When cfg.useMint is true and all orgs in the pool are individually rate-limited, sawRateLimit is never set to true, so the polling loop starts without exponential backoff. The same pattern repeats in the polling loop where roundRateLimited stays false. This could cause faster-than-desired polling in the degenerate case where all per-org installation tokens are simultaneously exhausted, though this is unlikely since per-org tokens have independent budgets.

  • [scope-creep] pkg/e2etest/testutil.go:104 — The acquireOrg mint-continuation logic (continuing to the next org instead of breaking on rate-limit for mint tokens) is a behavioral fix rather than pure instrumentation. However, this is a necessary companion fix: making IsRateLimitError match exhausted-retry errors would regress pool acquisition for mint mode without it.

  • [import-ordering] pkg/behaviourtest/drivers/install/composed.go:7 — The internal/forge import is placed in the stdlib import group without a blank-line separator. Every other file in this package separates stdlib from internal imports with a blank line.

  • [import-ordering] pkg/behaviourtest/drivers/install/composed_test.go:6 — Same import grouping violation in the test file.

  • [commit-convention] PR title uses fix(#6702) but the primary change is instrumentation/observability. The improved error identification and mint-mode pool behavior may make fix defensible, though refactor(#6702) would be more precise for the instrumentation scope.


Labels: PR modifies the forge client rate-limit handling and e2e test infrastructure

fullsend-ai-review[bot]

This comment was marked as outdated.

@fullsend-ai-review fullsend-ai-review Bot added ready-for-merge All reviewers approved — ready to merge component/e2e End-to-end tests labels Aug 27, 2026
@waynesun09
waynesun09 force-pushed the fix-6702-ratelimit-instrumentation branch from 58137f4 to 1d5edd0 Compare August 28, 2026 00:05
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 28, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 12:07 AM UTC · Ended 12:10 AM UTC

Commit: 1d5edd0 · View workflow run →

…retry errors

Two behaviour-test runs on different pool orgs went blind ~10 minutes
into the suite: every listing behind the harness waits failed on 403
until the wait timed out (#6697, #6702). Diagnosing which limit fired
and who spent the budget was impossible from the logs because the
GitHub client never read X-RateLimit-Remaining/Reset, and the error it
returns after exhausting retries — "403 retryable error after 5
attempts" — does not even satisfy its own IsRateLimitError.

Record the X-RateLimit-* headers of every response on the LiveClient
and expose them through forge.RateLimitReporter; prefix the
exhausted-retry error for 429 and retryable 403 with "rate limit:" and
append the last observed budget, so IsRateLimitError matches it and
callers that only see the error can tell a rate limit from a generic
403 (the org-pool acquisition already backs off on IsRateLimitError
and now gets to). The behaviour suite's ensurer logs the budget before
every allocation, which samples the drain across the whole run.

This is the instrumentation step of #6702; the fix (conditional
requests, a remaining-budget circuit breaker, or lower parallelism) is
chosen from the numbers this produces.

Refs #6702

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 28, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 12:12 AM UTC · Completed 12:31 AM UTC

Commit: 1479dbe · View workflow run →

Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $8.15

@waynesun09

Copy link
Copy Markdown
Member Author

For the core reviewer: this is ready and auto-merge is armed — your approval is the only remaining gate (CODEOWNERS; I can't approve my own PR).

What to look at, in order of substance:

  1. pkg/e2etest/testutil.go acquireOrg — the only behavioural change: with mint (per-org installation tokens) a rate-limited org is skipped for the next one instead of aborting the round; the round still records the limit so the existing back-off applies if every org is limited. Shared-PAT path unchanged. Without this, making the exhausted-retry 403 satisfy IsRateLimitError would have activated the loop's stale "limits are per user" assumption.
  2. internal/forge/github/github.go do() — the exhausted-retry error for 429/retryable 403 now starts with rate limit: and carries the budget parsed from the failing response's own headers (or says they were absent, naming the last observation with its age).
  3. Everything else is observation plumbing (forge.RateLimit, LiveClient.RateLimit(), [driver] rate limit … samples on allocate/release).

The samples already paid for themselves on this PR's own behaviour run: core primary quota drained 4772 → 3347 of 5000 in ~6 min (~235 req/min) — recorded on #6702; that is what the cause-(1) fix will be sized from.

@waynesun09
waynesun09 added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit 0ff7105 Aug 31, 2026
42 of 44 checks passed
@waynesun09
waynesun09 deleted the fix-6702-ratelimit-instrumentation branch August 31, 2026 07:20
@fullsend-ai-retro

fullsend-ai-retro Bot commented Aug 31, 2026

Copy link
Copy Markdown

🤖 Finished Retro · ✅ Success · Started 7:22 AM UTC · Completed 7:37 AM UTC

Commit: 1479dbe · View workflow run →

Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $3.67

@fullsend-ai-retro

Copy link
Copy Markdown

Retro: PR #6705 — Rate-limit instrumentation

Workflow: Human-authored PR by waynesun09 adding rate-limit observability to the GitHub client (refs #6702). No code, triage, or fix agent was involved — only the fullsend review agent.

Timeline: PR opened 2026-08-27. qodo-code-review caught a real concurrency bug (error messages reported a stale client-wide rate-limit observation instead of the failing response's own headers) on the initial commit. The author fixed it within ~2.5 hours and addressed all review findings across two follow-up pushes. The fullsend review agent completed two reviews ($7.38 on commit 58137f4, $8.15 on final commit 1479dbe), approving both times with valid low-severity findings (import ordering, edge-case handling, scope-creep acknowledgment, comment style). Two additional review runs were cancelled on intermediate commits. Human reviewer rh-hemartin approved 2026-08-31; merged same day.

Review quality: Appropriate. All review agent findings were valid and acted upon. The agent never saw the original concurrency bug because its first run was cancelled before completing — by the time it reviewed (commit 58137f4), the fix was already in place. The second completed review cost $8.15 but surfaced only one new low-severity finding beyond what the first review had already flagged.

Evidence for existing issues:

Proposals filed

waynesun09 added a commit that referenced this pull request Sep 1, 2026
The harness-wait poll loop (and its diagnostics) re-request the same
workflow-runs/jobs/artifacts URLs every few seconds, from up to a
dozen concurrent scenarios sharing one installation token. #6705's
instrumentation measured that traffic draining the primary quota
~235 req/min, exhausting it ~20 minutes into a suite run.

GitHub does not count a 304 response against the primary rate-limit
budget (verified against the live API: repeated If-None-Match
requests left X-RateLimit-Remaining unchanged, only the initial
uncached GET consumed one unit). This adds a small conditional-GET
cache to LiveClient (etagCache, opt-in per path via getCached) and
wires it into the five GET endpoints the harness-wait poll loop and
its diagnostics use: ListWorkflowRuns, ListRecentWorkflowRuns,
ListWorkflowRunJobs, ListWorkflowRunArtifacts, and
ListRepositoryArtifacts. Unchanged results between polls now cost
nothing; a status change still forces a full re-fetch, since GitHub
issues a new ETag whenever the underlying data changes.

do() grows a variadic requestHeader option so getConditional can set
If-None-Match without touching its other 28 call sites. The cache is
capped (etagCacheLimit) since a long suite run touches many distinct
run/job/artifact URLs.

Deliberately out of scope: a remaining-budget circuit breaker (the
issue's second candidate). If post-merge behaviour runs still show
#6698's 403 diagnostics after this lands, that's the next step, sized
with real data instead of a guessed threshold.

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
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 ready-for-merge All reviewers approved — ready to merge risk/moderate PR risk: moderate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants