Files
openchamber/packages/web/server/lib/github/rate-limit.js
T
Bohdan Triapitsyn 1e3139ab38 feat(github): detect rate limiting and pause PR status calls during cooldown
Octokit has no throttling plugin, so under a flood of PR-status calls a
primary/secondary rate limit just surfaced as repeated 403s that the cache
masked. Add a shared rate-limit gate: PR-status sub-calls note 403/429
responses, and the route short-circuits to cached/stale data during the
cooldown instead of issuing more doomed requests. Transient failures
(rate limit or the overall timeout) no longer log as hard errors.
2026-06-29 02:01:30 +03:00

67 lines
2.7 KiB
JavaScript

// Lightweight, process-global GitHub rate-limit gate.
//
// Octokit is configured without the throttling plugin, so a primary or
// secondary rate limit surfaces as a thrown 403/429. Resolving PR status for
// many worktrees fans out dozens of calls; once GitHub starts limiting, every
// further call wastes a round-trip and the cache masks the failure. When we
// detect a rate-limit response we record a cooldown and skip GitHub work until
// it passes, so the burst stops and the reason is visible in the logs.
const MAX_COOLDOWN_MS = 15 * 60 * 1000;
const DEFAULT_COOLDOWN_MS = 60 * 1000;
let rateLimitedUntil = 0;
const headerValue = (headers, name) => {
if (!headers) return undefined;
// Octokit/fetch headers can be a plain object or a Headers instance.
if (typeof headers.get === 'function') return headers.get(name);
return headers[name];
};
const parseRetryAfterMs = (error) => {
const headers = error?.response?.headers;
const retryAfter = headerValue(headers, 'retry-after');
if (retryAfter !== undefined && retryAfter !== null) {
const secs = Number(retryAfter);
if (Number.isFinite(secs) && secs > 0) return secs * 1000;
}
const reset = headerValue(headers, 'x-ratelimit-reset');
if (reset !== undefined && reset !== null) {
const delta = Number(reset) * 1000 - Date.now();
if (Number.isFinite(delta) && delta > 0) return delta;
}
return null;
};
/** True when an Octokit error represents a primary or secondary rate limit. */
export const isGitHubRateLimitError = (error) => {
const status = error?.status ?? error?.response?.status;
if (status === 429) return true;
if (status !== 403) return false;
const remaining = headerValue(error?.response?.headers, 'x-ratelimit-remaining');
if (remaining === '0' || remaining === 0) return true;
if (headerValue(error?.response?.headers, 'retry-after') != null) return true;
const message = String(error?.message ?? '').toLowerCase();
return message.includes('rate limit');
};
/** Record a cooldown after a detected rate-limit response. */
export const noteGitHubRateLimit = (error) => {
const retryMs = Math.min(parseRetryAfterMs(error) ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS);
const until = Date.now() + retryMs;
if (until > rateLimitedUntil) {
rateLimitedUntil = until;
console.warn(`[github] rate limited — pausing GitHub PR status calls for ~${Math.round(retryMs / 1000)}s`);
}
};
/** Convenience: note the error if it is a rate-limit error. Returns whether it was. */
export const noteIfGitHubRateLimit = (error) => {
if (!isGitHubRateLimitError(error)) return false;
noteGitHubRateLimit(error);
return true;
};
export const isGitHubRateLimited = () => Date.now() < rateLimitedUntil;