feat(sync): add Octokit retry/throttle and rate-limited status
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getInstallationOctokit,
|
||||
getRepository,
|
||||
getSpoonInstallationId,
|
||||
isRateLimitError,
|
||||
listPullRequests,
|
||||
syncForkBranch,
|
||||
} from './githubClient';
|
||||
@@ -360,13 +361,16 @@ const refreshOwnedSpoon = async (
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const rateLimited = isRateLimitError(error);
|
||||
await Promise.all([
|
||||
ctx.runMutation(internal.spoons.patchSyncFields, {
|
||||
spoonId,
|
||||
syncStatus: 'error',
|
||||
syncStatus: rateLimited ? 'rate_limited' : 'error',
|
||||
lastGithubRefreshAt: Date.now(),
|
||||
lastCheckedAt: Date.now(),
|
||||
lastError: message,
|
||||
lastError: rateLimited
|
||||
? `Rate limited by GitHub; will retry. ${message}`
|
||||
: message,
|
||||
}),
|
||||
ctx.runMutation(internal.syncRuns.patchInternal, {
|
||||
syncRunId,
|
||||
|
||||
@@ -119,6 +119,7 @@ const applicationTables = {
|
||||
v.literal('checking'),
|
||||
v.literal('conflict'),
|
||||
v.literal('error'),
|
||||
v.literal('rate_limited'),
|
||||
),
|
||||
),
|
||||
upstreamAheadBy: v.optional(v.number()),
|
||||
|
||||
@@ -63,6 +63,7 @@ const spoonSyncStatus = v.union(
|
||||
v.literal('checking'),
|
||||
v.literal('conflict'),
|
||||
v.literal('error'),
|
||||
v.literal('rate_limited'),
|
||||
);
|
||||
|
||||
const hasForkMetadata = (args: {
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "^8.2.0",
|
||||
"@octokit/plugin-retry": "^8.1.0",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@react-email/components": "1.0.10",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { isRateLimitError } from '../../convex/githubClient';
|
||||
|
||||
describe('isRateLimitError', () => {
|
||||
test('detects a 403 primary rate limit via ratelimit-remaining header', () => {
|
||||
expect(
|
||||
isRateLimitError({
|
||||
status: 403,
|
||||
response: { headers: { 'x-ratelimit-remaining': '0' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('detects a 429 status', () => {
|
||||
expect(isRateLimitError({ status: 429 })).toBe(true);
|
||||
});
|
||||
|
||||
test('detects a secondary rate limit via message', () => {
|
||||
expect(
|
||||
isRateLimitError({ status: 403, message: 'secondary rate limit' }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for a 404', () => {
|
||||
expect(isRateLimitError({ status: 404 })).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for a 500', () => {
|
||||
expect(isRateLimitError({ status: 500 })).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for a generic Error', () => {
|
||||
expect(isRateLimitError(new Error('boom'))).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for non-object values', () => {
|
||||
expect(isRateLimitError(null)).toBe(false);
|
||||
expect(isRateLimitError(undefined)).toBe(false);
|
||||
expect(isRateLimitError('rate limit')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user