import { Octokit } from "@octokit/rest"; import { throttling } from "@octokit/plugin-throttling"; import { createClient, RedisClientType } from "redis"; import AnonymousError from "./AnonymousError"; import Repository from "./Repository"; import UserModel from "./model/users/users.model"; import config from "../config"; import { createLogger } from "./logger"; const logger = createLogger("github"); // Octokit RequestError shape (subset we care about for rate-limit detection). interface OctokitRequestErrorLike { status?: number; message?: string; response?: { headers?: Record; }; } /** * Detect GitHub rate-limit / abuse responses (primary 5k/h or undocumented * "secondary" limits) and rewrap them as a translatable AnonymousError so * the UI can show a friendly message instead of a raw HttpError stack. The * GitHub `x-github-request-id` header is preserved in `detail` so users can * cite it if they reach out to GitHub Support. */ export function isGitHubRateLimitError(err: unknown): boolean { if (!err || typeof err !== "object") return false; const e = err as OctokitRequestErrorLike; const msg = (e.message ?? "").toLowerCase(); // Primary limits return 403 with "x-ratelimit-remaining: 0"; secondary // limits return 403 (sometimes 429) with "secondary rate limit" in the // body. Match on either signal so we catch both. const status = e.status ?? 0; if (status !== 403 && status !== 429) return false; if (msg.includes("rate limit") || msg.includes("abuse")) return true; const remaining = e.response?.headers?.["x-ratelimit-remaining"]; return remaining === "0"; } function rateLimitDetail(err: OctokitRequestErrorLike): string { const headers = err.response?.headers ?? {}; const requestId = headers["x-github-request-id"]; const retryAfter = headers["retry-after"]; const reset = headers["x-ratelimit-reset"]; const parts: string[] = []; if (requestId) parts.push(`requestId=${requestId}`); if (retryAfter) parts.push(`retryAfter=${retryAfter}s`); if (reset) parts.push(`reset=${reset}`); return parts.join(" "); } const ThrottledOctokit = Octokit.plugin(throttling); /** * Per-token gate that blocks all callers when a rate limit is active. * When any request for a given token hits a rate limit, the gate records * the reset time and makes every subsequent caller wait until then — * preventing a stampede of doomed requests. */ const tokenGates = new Map(); function setTokenGate(token: string, retryAfterSec: number) { const key = token.slice(-8); const resetAt = Date.now() + retryAfterSec * 1000; const existing = tokenGates.get(key); if (!existing || resetAt > existing.resetAt) { tokenGates.set(key, { resetAt }); logger.warn("rate limit gate set", { code: "rate_limit_gate", tokenKey: key, retryAfterSec, resetAt: new Date(resetAt).toISOString(), }); setRedisGate(key, retryAfterSec).catch(() => {}); } } export class RateLimitDelayError extends Error { resetAt: number; tokenKey: string; constructor(resetAt: number, tokenKey: string) { const delaySec = Math.ceil((resetAt - Date.now()) / 1000); super(`github_rate_limit_delay:${delaySec}s`); this.name = "RateLimitDelayError"; this.resetAt = resetAt; this.tokenKey = tokenKey; } } /** * Check if a rate limit gate is active for a token. * Returns the reset timestamp, or 0 if no gate is active. */ export function getTokenGateResetAt(token: string): number { const key = token.slice(-8); const gate = tokenGates.get(key); if (!gate) return 0; if (gate.resetAt <= Date.now()) { tokenGates.delete(key); return 0; } return gate.resetAt; } async function waitForTokenGate(token: string): Promise { const key = token.slice(-8); const localGate = tokenGates.get(key); let waitMs = 0; let resetAt = 0; if (localGate && localGate.resetAt > Date.now()) { resetAt = localGate.resetAt; waitMs = resetAt - Date.now(); } const redisResetAt = await getRedisGateResetAt(key); if (redisResetAt > resetAt) { resetAt = redisResetAt; waitMs = resetAt - Date.now(); } if (waitMs <= 0) { if (localGate) tokenGates.delete(key); return; } logger.info("waiting for rate limit gate", { code: "rate_limit_gate_wait", waitMs, resetAt: new Date(resetAt).toISOString(), }); await new Promise((resolve) => setTimeout(resolve, waitMs)); if (localGate) tokenGates.delete(key); } const REDIS_GATE_PREFIX = "gh_rate_gate:"; let redisGateDisabled = false; let redisGateReady: Promise | null = null; function ensureRedisGateClient(): Promise { if (redisGateDisabled) return Promise.resolve(null); if (redisGateReady) return redisGateReady; redisGateReady = (async () => { try { const c = createClient({ socket: { host: config.REDIS_HOSTNAME, port: config.REDIS_PORT, reconnectStrategy: () => false as any, }, }) as RedisClientType; c.on("error", () => { redisGateDisabled = true; c.disconnect().catch(() => {}); redisGateReady = null; }); await c.connect(); return c; } catch { redisGateDisabled = true; redisGateReady = null; return null; } })(); return redisGateReady; } async function setRedisGate(tokenKey: string, retryAfterSec: number): Promise { const c = await ensureRedisGateClient(); if (!c || !c.isOpen) return; const resetAt = Date.now() + retryAfterSec * 1000; const ttl = Math.ceil(retryAfterSec) + 10; try { await c.set(REDIS_GATE_PREFIX + tokenKey, String(resetAt), { EX: ttl }); logger.info("redis rate limit gate written", { code: "redis_gate_set", tokenKey, resetAt: new Date(resetAt).toISOString(), ttl, }); } catch { // non-critical } } export async function setRedisGateFromWorker(tokenKey: string, resetAt: number): Promise { const retryAfterSec = Math.max(0, (resetAt - Date.now()) / 1000); if (retryAfterSec <= 0) return; await setRedisGate(tokenKey, retryAfterSec); } export async function getRedisGateResetAt(tokenKey: string): Promise { const c = await ensureRedisGateClient(); if (!c || !c.isOpen) return 0; try { const val = await c.get(REDIS_GATE_PREFIX + tokenKey); if (!val) return 0; const resetAt = parseInt(val, 10); if (isNaN(resetAt) || resetAt <= Date.now()) return 0; return resetAt; } catch { return 0; } } export function octokit(token: string) { const oct = new ThrottledOctokit({ auth: token, request: { fetch: fetch, }, throttle: { onRateLimit: (retryAfter, options, _o, retryCount) => { logger.warn("github primary rate limit hit", { code: "github_rate_limit", httpStatus: 429, method: options.method, url: options.url, retryAfter, retryCount, }); setTokenGate(token, retryAfter); return retryCount < 1; }, onSecondaryRateLimit: (retryAfter, options, _o, retryCount) => { logger.warn("github secondary rate limit hit", { code: "github_secondary_rate_limit", httpStatus: 429, method: options.method, url: options.url, retryAfter, retryCount, }); setTokenGate(token, retryAfter); return retryCount < 1; }, }, }); oct.hook.error("request", (err) => { if (isGitHubRateLimitError(err)) { throw new AnonymousError("github_rate_limit_exceeded", { httpStatus: 429, cause: err as Error, object: rateLimitDetail(err as OctokitRequestErrorLike), }); } throw err; }); return oct; } export { waitForTokenGate }; export async function checkToken(token: string) { const oct = octokit(token); try { await oct.users.getAuthenticated(); return true; } catch (err) { if ( err instanceof AnonymousError && err.message === "github_rate_limit_exceeded" ) { throw err; } return false; } } export async function getToken(repository: Repository) { logger.debug("getToken", { repoId: repository.repoId }); // if (repository.model.source.accessToken) { // // only check the token if the repo has been visited less than 10 minutes ago // if ( // repository.status == RepositoryStatus.READY && // repository.model.lastView > new Date(Date.now() - 1000 * 60 * 10) // ) { // return repository.model.source.accessToken; // } else if (await checkToken(repository.model.source.accessToken)) { // return repository.model.source.accessToken; // } // } if (!repository.owner.model.accessTokens?.github) { const query = await UserModel.findById(repository.owner.id, { accessTokens: 1, accessTokenDates: 1, }); if (query?.accessTokens) { repository.owner.model.accessTokens = query.accessTokens; repository.owner.model.accessTokenDates = query.accessTokenDates; } } const ownerAccessToken = repository.owner.model.accessTokens?.github; if (ownerAccessToken) { const tokenAge = repository.owner.model.accessTokenDates?.github; // if the token is older than 7 days, refresh it if ( !tokenAge || tokenAge < new Date(Date.now() - 1000 * 60 * 60 * 24 * 7) ) { const url = `https://api.github.com/applications/${config.CLIENT_ID}/token`; const headers = { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }; const res = await fetch(url, { method: "PATCH", body: JSON.stringify({ access_token: ownerAccessToken, }), credentials: "include", headers: { ...headers, Authorization: "Basic " + Buffer.from( config.CLIENT_ID + ":" + config.CLIENT_SECRET ).toString("base64"), }, }); // Only persist a refreshed token if GitHub actually returned a // valid one. Without this guard, a 4xx/5xx error body (revoked // OAuth, rate limit, transient outage) silently overwrites the // user's stored token with `undefined`, which then propagates as // `Authorization: token undefined` to every subsequent API call — // 401 even on public repos, and the config.GITHUB_TOKEN fallback // below is unreachable because the token field is no longer falsy. if (res.ok) { const resBody = (await res.json().catch(() => null)) as | { token?: unknown } | null; const refreshed = resBody && typeof resBody.token === "string" && resBody.token.length > 0 ? resBody.token : null; if (refreshed) { repository.owner.model.accessTokens.github = refreshed; if (!repository.owner.model.accessTokenDates) { repository.owner.model.accessTokenDates = { github: new Date() }; } else { repository.owner.model.accessTokenDates.github = new Date(); } await UserModel.updateOne( { _id: repository.owner.model._id }, { $set: { "accessTokens.github": refreshed, "accessTokenDates.github": repository.owner.model.accessTokenDates.github, }, } ).exec(); return refreshed; } } logger.warn("token refresh failed; falling back", { code: "token_refresh_failed", httpStatus: res.status, username: repository.owner.model.username, }); // fall through to the checkToken path / config.GITHUB_TOKEN } const check = await checkToken(ownerAccessToken); if (check) { repository.model.source.accessToken = ownerAccessToken; return ownerAccessToken; } } return config.GITHUB_TOKEN; }