Skip to content

gh-pulse: throttle the hour (0.36.0) - #72

Merged
ralyodio merged 1 commit into
masterfrom
worktree-gh-pulse-throttle
Sep 13, 2026
Merged

ralyodio merged 1 commit into
masterfrom
worktree-gh-pulse-throttle

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What was wrong

A gh-pulse run this morning died with GitHub 403: API rate limit exceeded ten repos into the events step, and the spinner kept ticking after the error because the request queue went on draining in the background. Four range scans and a daily run in one hour had spent the whole 5,000-call budget the token shares with gh and every other tool, and the client's answer to a 403 was to wait at most two minutes, four times, then throw.

What changes

The GitHub client is now the only place the hour is spent, and it behaves:

  • Reserve. GH_PULSE_RESERVE calls of the hour (default 500) are never spent. Down to the reserve, or rate limited anyway, the scan pauses until the hour resets and then carries on, on one shared pause for every in-flight request, for as long as the headers say. The pause is announced on the spinner line and in the log.
  • Pacing. Four requests in flight, at least 100ms between starts, so a scan cannot burst into GitHub's secondary limits. retry-after is honoured.
  • ETag cache. Every reply is cached under <data>/cache by ETag and offered back with If-None-Match. GitHub does not charge for a 304, so an unchanged page is free. Entries nobody has asked about for a month are pruned after each daily run.
  • since= from the top of the hour. The commits and issues URLs repeat within an hour, so a rescan hits the cache; the exact cutoff is applied to the items.
  • No draining after a crash. Once a task has failed, nothing queued behind it starts and further requests are refused, so the process ends instead of spending the rest of the hour on a report nobody will read. gh-pulse show exits the process when the screen closes for the same reason.
  • One daily run at a time. <data>/run.lock names the pid; a second run refuses while the first is alive and takes over a stale lock.
  • The budget in the log. Each scan reports what the hour has left from the first real reply's headers (/rate_limit reported a fresh hour for this token while real replies said 4,695 left, so it is not consulted), and closes with what was charged, what came from cache, and what is left.

Measured

Live, --range hour --repo profullstack/g1tz, from the worktree:

run charged from cache
cold 20 6
warm, same hour 1 25

With since= floored the warm run is 0 charged.

Tests

test/gh-pulse-client.test.ts (16 tests, fake clock, scripted fetch): spacing, the reserve pause, a 403 waited out on one shared pause, retry-after, a 403 that is not about the limit, the ETag round trip, cache keyed by accept header, no cache dir, pruning, the reserve from the environment, abort of queued work, the budget line, since flooring with the cutoff applied, and the run lock. Existing gh-pulse suites unchanged and green. The one failing test in the full suite (torrent.test.ts, needs a live client) is unrelated.

🤖 Generated with Claude Code

https://claude.ai/code/session_012nnVu9u7HeHEVRoCwKcQM3

A run died this morning with GitHub 403, rate limit exceeded, ten repos
into the events step, and the spinner kept ticking after the error
because the queue went on draining in the background. Four range scans
and a daily run in one hour had spent the whole 5,000-call budget the
token shares with gh and everything else, and the client's answer to a
403 was to wait at most two minutes, four times, then throw.

The GitHub client is now the only place the hour is spent, and it
behaves. GH_PULSE_RESERVE calls (default 500) are never spent: down to
the reserve, or rate limited anyway, a scan pauses until the hour
resets, on one shared pause for every in-flight request, for as long as
the headers say, and then carries on. Four requests in flight and 100ms
between starts keep it out of the secondary limits; retry-after is
honoured. Every reply is cached by ETag under <data>/cache and offered
back with If-None-Match, and GitHub does not charge for a 304, so an
unchanged page is free; since= is asked from the top of the hour so the
URL repeats and a rescan hits the cache, with the exact cutoff applied
to the items. Once a task has failed nothing queued behind it starts,
and show exits the process when the screen closes, so a crash or a quit
no longer spends the rest of the hour in the background. One daily run
at a time, through <data>/run.lock.

The budget is read off the first real reply's headers, not /rate_limit,
which reported a fresh hour for this token while real replies carried
4,695 left. Each scan logs what it has to spend and closes with what was
charged, what came from cache and what is left.

Measured live on one repo for the last hour: a cold scan charged 20
calls, the warm rescan 0, with 26 answered from cache.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012nnVu9u7HeHEVRoCwKcQM3
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

13 finding(s)

HIGH/CRITICAL: 4 | MEDIUM: 5 | LOW: 4

Severity Rule Location
HIGH sh-remote-script-execution root-ubuntu.sh:3227
HIGH sh-remote-script-execution root-ubuntu.sh:3228
HIGH sh-remote-script-execution root-ubuntu.sh:4840
HIGH sh-remote-script-execution root-ubuntu.sh:4844
MEDIUM sh-remote-script-execution root-ubuntu.sh:4993
MEDIUM sh-remote-script-execution root-ubuntu.sh:6055
MEDIUM redos-nested-quantifier src/domain-free.ts:56
MEDIUM redos-nested-quantifier src/mail.ts:1042
MEDIUM redos-nested-quantifier src/wcag.ts:556
LOW secret-generic-credential src/credentials.ts:36
LOW secret-generic-api-key test/credentials.test.ts:208
LOW secret-generic-credential test/mail.test.ts:135
LOW secret-generic-credential test/shorten.test.ts:36

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 9841e56 into master Sep 13, 2026
5 checks passed
@ralyodio
ralyodio deleted the worktree-gh-pulse-throttle branch September 13, 2026 06:57
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