Skip to content

fix(github): wait out primary rate limits and drop per-repo empty-check call#93

Merged
fabio-gos-sonarsource merged 3 commits into
mainfrom
fix/github-rate-limit-and-empty-check
Jul 8, 2026
Merged

fix(github): wait out primary rate limits and drop per-repo empty-check call#93
fabio-gos-sonarsource merged 3 commits into
mainfrom
fix/github-rate-limit-and-empty-check

Conversation

@fabio-gos-sonarsource

Copy link
Copy Markdown
Contributor

Problem

When analyzing a large GitHub organization, many repositories that should have been counted were silently dropped. In one reported run against a GitHub Enterprise Server org, 7,219 repos were discovered but only 4,559 analyzed2,636 skipped, every one logged as:

❌ repo <name>: skipped — empty-check failed:
❌ Failed to check repository <name> is empty - : GET .../commits: 403 API rate limit of 10000 still exceeded ... not making remote request. [rate reset in 27m57s]

All 2,636 failures occurred in the same second — the moment the account's primary hourly API rate limit was exhausted. From that instant golc raced through the rest of the repo list, marking each as "not analyzed", instead of waiting ~28 min for the reset.

Root causes (both fixed here)

  1. Primary rate limit was never handled. The code only caught the secondary/"abuse" limit (*github.AbuseRateLimitError). The primary hourly limit surfaces as *github.RateLimitError — including go-github's pre-emptive short-circuit ("…not making remote request") — which was never waited out. On that error reposIfEmpty returned an error and the caller did notAnalyzedCount++; continue, permanently dropping the repo.

  2. An extra ListCommits call per repo. reposIfEmpty made a dedicated API call just to detect empty repos — doubling quota burn (~2.2 calls/repo observed) and being the exact call that failed above.

Changes

  • Primary limits (the reported bug): the go-github client context now carries SleepUntilPrimaryRateLimitResetWhenRateLimited, so the client transparently sleeps until the reset and retries — at both the pre-emptive short-circuit and live-403 paths. Applied at all three context origins in getgithub.go.
  • Secondary limits: the existing handler is made robust with errors.As (instead of a bare type assertion) and bounded by a retry cap (maxSecondaryRateLimitRetries).
  • Empty-check: replaced reposIfEmpty's ListCommits call with repo.GetSize() == 0, read from data already returned by the listing API — matching the existing check in processRepositoryBranches. Removes the extra call, the error-drop path, and the now-dead function. This roughly halves API usage per repo, so a fixed quota reaches ~2× the repositories.

Net effect: a transient rate limit no longer causes permanent data loss, and the tool burns far less quota to begin with. Works identically for GitHub Cloud and Enterprise Server (driven by GitHub's standard rate-limit reset semantics).

Testing

  • go vet ./pkg/devops/getgithub/ — clean
  • go build ./... — clean
  • go test ./pkg/devops/getgithub/ — pass (obsolete reposIfEmpty panic test replaced with size-based empty-check tests)
  • gofmt — clean

Note: SonarQube agentic analysis could not run in this environment — the authenticated SonarCloud org (sonarcloud-demos) lacks access to project sonar-solutions_sonar-golc.

🤖 Generated with Claude Code

…-check call

When analyzing a large GitHub org, repositories processed after the account's
primary hourly API rate limit was exhausted were silently skipped: every
remaining repo failed the empty-check with a 403 and was counted as
"not analyzed" (e.g. 2,636 of 7,219 repos dropped in one reported run, all at
the same second the 10k/hr limit was hit).

Two root causes, both fixed here:

1. Only the secondary ("abuse") rate limit was handled; the primary hourly
   limit surfaces as *github.RateLimitError and was never waited out, so the
   tool neither paused for the reset nor retried — it dropped the repo. Now the
   go-github client context carries SleepUntilPrimaryRateLimitResetWhenRateLimited,
   so primary limits are transparently slept-through and retried at both the
   pre-emptive short-circuit and live-403 paths. Secondary-limit handling is
   made robust (errors.As) and bounded by a retry cap.

2. reposIfEmpty made an extra ListCommits call per repo purely to detect empty
   repos — doubling API-quota burn and adding the failing call above. Emptiness
   is now read from repo.GetSize() (already returned by the listing API),
   matching the existing check in processRepositoryBranches. This removes the
   call, the error-drop path, and the dead function.

Verified with go vet, go build ./..., the getgithub package test suite, and gofmt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread pkg/devops/getgithub/getgithub.go Outdated
fabio-gos-sonarsource and others added 2 commits July 8, 2026 12:58
Follow-up to the size-based empty check. GitHub computes repository size
asynchronously, so a repo that DOES contain commits can transiently report
size 0 (right after creation/import, or on GHES where size recalculation lags).
Treating size 0 as unconditionally empty would silently drop such repos — the
same symptom this branch set out to remove, via a different trigger (raised by
review on PR #93).

repoIsEmpty now treats size 0 as a *candidate* only: repos with size > 0 skip
straight to analysis (zero-cost fast path, unchanged), while size-0 repos are
confirmed with a single ListCommits(PerPage:1) guarded by the primary
rate-limit sleep and secondary-limit retry. If the check is inconclusive, the
repo is treated as non-empty and analyzed rather than dropped. Applied at all
three call sites (GetReposGithub, GetGithubLanguages, processRepositoryBranches),
and skipped-as-empty is now logged at INFO for operator auditability.

Net API cost stays far below the original (which called ListCommits for every
repo): the confirm call runs only for the small set of size-0 repos.

Note: the review's specific suggestion — repo.GetPushedAt().IsZero() — was
tested and rejected: a freshly created truly-empty repo also reports a non-null
pushed_at, so it does not distinguish empty from non-empty.

Verified live against the GitHub API: a fresh repo with one commit (size 0) is
now analyzed, while a truly empty repo is still detected as empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…es retry

Raises new-code coverage on PR #93 (was 22.7%, gate needs 80%). Adds unit tests
using the existing httptest fake-GHES pattern:

- secondaryRateLimitPause: nil / unrelated / abuse-with-RetryAfter /
  abuse-without-RetryAfter (default) / wrapped-error cases (100%).
- withRateLimitSleep: asserts the primary rate-limit sleep flag is set on ctx.
- repoIsEmpty: size>0 fast path (no API call), and the size-0 confirm branch —
  has-commits, no-commits, "Git Repository is empty." (409), and inconclusive
  error → treated as non-empty (87.5%).
- getAllBranches: success, secondary rate-limit retry-then-succeed, and
  non-rate-limit error propagation (94.1%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jul 8, 2026

Copy link
Copy Markdown

@fabio-gos-sonarsource
fabio-gos-sonarsource merged commit 8180d5e into main Jul 8, 2026
4 checks passed
@fabio-gos-sonarsource
fabio-gos-sonarsource deleted the fix/github-rate-limit-and-empty-check branch July 8, 2026 11:15
@gitar-bot

gitar-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 1 resolved / 1 findings

Implements transparent primary rate limit handling and replaces redundant API calls with size-based repository checks, resolving the empty-check misclassification and data loss issues.

✅ 1 resolved
Edge Case: Size-based empty check may misclassify non-empty repos as empty

📄 pkg/devops/getgithub/getgithub.go:332 📄 pkg/devops/getgithub/getgithub.go:1123 📄 pkg/devops/getgithub/getgithub.go:551
The empty-repo detection was switched from a ListCommits call to repo.GetSize() == 0 in GetReposGithub (getgithub.go:332) and GetGithubLanguages (getgithub.go:1123). This removes the extra API call (a good goal), but the GitHub size field (kilobytes) is not a fully reliable proxy for emptiness. GitHub computes/updates repository size asynchronously, so a repository that actually contains commits can transiently report size: 0 — notably right after creation/import and on some GitHub Enterprise Server versions where size recalculation lags. In those cases the repo would now be counted as emptyRepo and skipped from analysis, which is the same class of silent-drop symptom this PR set out to eliminate, just triggered by a different condition.

This pattern already exists in processRepositoryBranches (getgithub.go:551), so the change is at least consistent with the codebase. Still, it is worth being explicit about the trade-off. Consider treating size == 0 as a candidate empty repo and confirming via a cheap fallback only for those (e.g. repo.GetPushedAt().IsZero() / a single ListCommits with PerPage:1 guarded by the new rate-limit handling) rather than dropping unconditionally, or at minimum logging skipped-as-empty repos at a visible level so operators can audit false positives.

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

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.

1 participant