feat(sync): add Octokit retry/throttle and rate-limited status

This commit is contained in:
Gabriel Brown
2026-07-11 14:28:02 -04:00
parent f1dbf8cd50
commit 4769a76274
7 changed files with 104 additions and 4 deletions
+43 -1
View File
@@ -1,4 +1,6 @@
import { createAppAuth } from '@octokit/auth-app';
import { retry } from '@octokit/plugin-retry';
import { throttling } from '@octokit/plugin-throttling';
import { Octokit } from '@octokit/rest';
import { ConvexError } from 'convex/values';
@@ -51,8 +53,12 @@ const getEnv = (name: string) => {
const normalizePrivateKey = (value: string) => value.replaceAll('\\n', '\n');
const SpoonOctokit = Octokit.plugin(retry, throttling);
const MAX_THROTTLE_RETRIES = 2;
export const getInstallationOctokit = (installationId: string) =>
new Octokit({
new SpoonOctokit({
authStrategy: createAppAuth,
auth: {
appId: getEnv('GITHUB_APP_ID'),
@@ -65,8 +71,44 @@ export const getInstallationOctokit = (installationId: string) =>
'X-GitHub-Api-Version': '2022-11-28',
},
},
throttle: {
onRateLimit: (retryAfter, options, _octokit, retryCount) => {
console.warn(
`[github] rate limit hit for ${options.method} ${options.url}; retry ${retryCount} after ${retryAfter}s`,
);
return retryCount < MAX_THROTTLE_RETRIES;
},
onSecondaryRateLimit: (retryAfter, options, _octokit, retryCount) => {
console.warn(
`[github] secondary rate limit hit for ${options.method} ${options.url}; retry ${retryCount} after ${retryAfter}s`,
);
return retryCount < MAX_THROTTLE_RETRIES;
},
},
});
/**
* Pure predicate: does this thrown value look like a GitHub rate-limit /
* secondary-limit error? Detects the Octokit `RequestError` shape without
* importing node-only modules so it can be unit-tested directly.
*/
export const isRateLimitError = (error: unknown): boolean => {
if (typeof error !== 'object' || error === null) return false;
const err = error as {
status?: number;
message?: string;
response?: { headers?: Record<string, string | undefined> };
};
if (err.status !== 403 && err.status !== 429) return false;
if (err.status === 429) return true;
const message = typeof err.message === 'string' ? err.message.toLowerCase() : '';
if (message.includes('rate limit')) return true;
const headers = err.response?.headers ?? {};
if (headers['x-ratelimit-remaining'] === '0') return true;
if (headers['x-ratelimit-reset'] !== undefined) return true;
return false;
};
export const getSpoonInstallationId = (
spoon: Doc<'spoons'>,
connection?: Doc<'gitConnections'> | null,