Desktop app's GhRestClient burns the user's GitHub GraphQL rate limit: ~640 points per session focus, ~2,000 per turn start

Status Fixed / completed
Reported on v2.1.234
Maintainer reply None cached
Activity 3 comments · opened Aug 20, 2026 · closed Aug 24, 2026

Summary

The Claude Code desktop app (macOS) makes very large bursts of GitHub GraphQL API calls through its internal GhRestClient, authenticated as the user, and exhausts the user's personal 5,000 points/hr GraphQL rate limit within minutes of normal use. Measured costs:

  • ~640 points for a single session focus in the sidebar (setFocusedSession / preview warm-up)
  • ~1,970 points for sending one message / starting a turn
  • windows repeatedly reach 10,000+ used (2× the limit, indicating high parallelism)

Because GitHub's GraphQL limit is per-user across all tokens, this starves everything else that shares the identity: gh CLI in sessions (a plain gh pr checks costs 1 point), the GitHub MCP server, and the app's own CI widgets — sessions then see "GitHub API rate limit exceeded (5,000/hr shared across all tools and agents)" and CI watchers fail silently. Likely related symptom: #78222.

Environment

  • Claude desktop app 1.32885.1 (macOS, Darwin 25.5.0), Claude Code CLI 2.1.234
  • ~30 local sessions in one project over recent days; the app has ~38 session↔PR bindings across 5 repos (log shows 648 LocalSessions.getPrChecks entries, prNumber, dismissBoundPr)

Evidence

~/Library/Logs/Claude/main.log — the app's own GraphQL client hitting the wall repeatedly since Aug 18:

2026-08-18 17:55:29 [warn] [GhRestClient] GraphQL errors { '0': { type: 'RATE_LIMIT', code: 'graphql_rate_limit', message: 'API rate limit already exceeded for user ID 3220689.' } }
2026-08-18 19:57:11 [warn] [GhRestClient] GraphQL errors { ... }
2026-08-19 21:42:12 [warn] [GhRestClient] GraphQL errors { ... }
2026-08-20 08:30:14 [warn] [GhRestClient] GraphQL errors { ... }

Controlled measurement (sampling gh api rate_limit --jq .resources.graphql.used every 10–15s, correlated with main.log timestamps and lsof snapshots showing only the Claude process holding GitHub TCP connections at spike time):

  • Fresh window at 12:47:41 → used=0
  • 12:49:02 log: setFocusedSession on an idle session → 12:49:12: +640 points
  • 12:49:26 log: sendMessage to that session → 12:50:59: +1,970 points (plus +310 at 12:51:15 as the turn spun up)
  • With all sessions idle and no UI interaction, usage is flat (~2 points/min) — the burst is entirely UI/turn-event driven

Ruled out by measurement during the same investigation: user shell commands (each gh pr checks/view/list = 1 point), SessionStart hooks (0 points), CI workflows (repo-scoped tokens; no runs during bursts), cron/launchd jobs, OAuth apps (GitHub settings show only GitHub CLI recently used), and the cloud "Autofix pull requests" feature (bursts continue with it disabled and correlate with the local process).

What did NOT mitigate it

  • Archiving finished PR-bound sessions (burst quantum unchanged at ~640 → fan-out appears not proportional to active session count)
  • Disabling claude.ai → Settings → Claude Code → "Autofix pull requests"

Expected

Session focus / turn start should cost O(1–10) GraphQL points (one PR + checks query for the focused session), be cached/debounced, and degrade gracefully — not fan out to every tracked PR binding in parallel and consume a day's practical quota in minutes, and not double-spend past the limit.

Impact

Any user running multiple PR-attached sessions loses their entire GitHub GraphQL quota to the app shell, which then breaks gh-based workflows inside their sessions (merge queues, CI watchers) in ways that look like GitHub flakiness. Workaround we've adopted: run GitHub-heavy sessions from a plain terminal, where the desktop shell's poller is not present.

View original on GitHub ↗

3 Comments

tradecraft1 · 1 month ago

Refined measurement — the cost is per session-switch, and it's a constant. A controlled re-run against a proven-zero baseline on a fresh quota window corrects two attributions in the original report:

22:23:04Z  gq=0     app running, idle           0 pts / 32s
22:23:46Z  gq=0     app QUIT, idle              0 pts / 4m41s
22:28:37Z  gq=12    app restarted              12 pts total (startup)
22:30:42Z  gq=22    idle                        0 pts / 30s
22:31:14Z  gq=1728  after THREE sidebar
                    session switches        +1,706 pts in <10s
22:31:24Z  gq=1728  idle again                  0 pts
  • 569 points per switch (1,706 / 3). The same quantum reproduces unattended-looking bursts observed earlier (570, 570, 575) — the magnitude is fixed, independent of session contents.
  • App startup: ~12 points. Idle (open or quit): 0. There is no background poller — the original report's "periodic refresh cycle" and the "~1,970 per turn start" were rapid UI actions conflated; every burst decomposes into N × ~569.
  • Practical impact: 5,000 / 569 ≈ 8–9 session switches per hour before the user's entire GitHub GraphQL budget is gone.

Measurement notes for anyone reproducing: once remaining hits 0 the client backs off and used freezes, so a saturated window "measures" zero burn — only measure on a fresh window. rate_limit itself is exempt and safe to poll.

This localizes the defect precisely: whatever GraphQL batch GhRestClient issues on session focus/switch costs ~569 points per invocation and is uncached — two switches to the same session pay twice.

tradecraft1 · 1 month ago

Independent reproduction later the same day, with a controlled zero baseline and an app-quit control. Numbers land in the same band as the report, and the method turned up a measurement artifact that will make this look unreproducible to anyone who tries it naively.

Per-switch cost, against a proven-zero baseline

All on one fresh quota window, sampling /rate_limit every 10s (that endpoint is exempt, so it does not perturb the measurement):

22:23:04   gq=0      app running, idle           0 pts / 32s
22:23:46   gq=0      app QUIT, idle              0 pts / 4m41s
22:28:37   gq=12     app restarted              12 pts total (startup)
22:30:42   gq=22     idle                        0 pts / 30s
22:31:14   gq=1728   after THREE switches   +1,706 pts in <10s
22:31:24   gq=1728   idle again                  0 pts

1706 / 3 = 569 points per session switch. Earlier the same evening, unattended bursts measured 570, 570, 575 and 433 at ~30s intervals while the machine was idle — so the batch size is a constant rather than a function of the action. Consistent with the ~640/focus figure above; the difference is presumably which of focus/warm-up/binding refresh each action triggers.

Budget implication, which is the part that bites: 5000 / 569 ≈ 8–9 session switches per hour before the pool is gone.

It is per-action, not a background poller

Worth stating explicitly because it changes where to look: with the app running and untouched, consumption is flat zero — 5 minutes idle before the switches, and flat again the instant switching stopped. A full quit costs nothing to verify: 4m41s with the process gone, zero points, then 12 points total on restart. So nothing is polling on a timer; the cost is attached to the action.

The artifact: once the pool is exhausted, the counter stops telling the truth

This wasted my first attempt entirely, and anyone reproducing this will hit it.

After the limit is reached, used keeps climbing well past it — I saw 6,986 then 10,274 against a 5,000 limit — and then freezes, because the client backs off after repeated 403s. A frozen counter is indistinguishable from zero consumption.

My first experiment quit the app for 33 minutes against an already-saturated pool and dutifully reported 0 pts/min in every phase — app running, app gone, app restarted. Taken at face value that exonerates the app completely. It was measuring nothing.

Two consequences for anyone testing this:

  • Only measure on a fresh window (remaining back at 5000). At ~569/switch it re-saturates in under 5 minutes, so the usable measurement budget is small.
  • The burn stopping is not evidence the trigger stopped. Mine went quiet ~2 minutes before the app was quit — that was backoff, not the quit taking effect. Timing coincidences near an exhausted pool mean nothing.

Reproduction harness

A detached sampler survives the app being quit, and logs app liveness alongside the counter:

while :; do
  s=$(gh api rate_limit --jq '"\(.resources.graphql.used)\t\(.resources.core.used)"')
  ps aux | grep -q '[/]Applications/Claude.app/Contents/MacOS/Claude' && a=RUNNING || a=GONE
  printf '%s\t%s\t%s\n' "$(date -u +%H:%M:%S)" "$s" "$a" >> ~/burn.tsv
  sleep 10
done

Two macOS gotchas that silently corrupt this: setsid does not exist (use nohup … &), and pgrep -f returns zero matches on the /Applications/Claude.app/Contents/MacOS/Claude path, so a liveness column built on it logs GONE while the app is plainly running. ps aux | grep works.

Environment

Claude Code CLI 2.1.234, macOS Darwin 25.5.0, 182 session transcripts across 16 project dirs, several PR-bound. REST core usage over the same period: 11 points, i.e. the burn is entirely GraphQL and entirely not gh.

github-actions[bot] · 1 day ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.