Skip to content

Commit a3946c7

Browse files
heiskrCopilotCopilot
authored
Fix always-false instanceof RequestError checks and dedupe retry logic (#62510)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Copilot-Session: 9fa62c70-6b0e-40df-8a9e-88ec6807a878
1 parent e9b563c commit a3946c7

4 files changed

Lines changed: 85 additions & 62 deletions

File tree

‎src/workflows/git-utils.ts‎

Lines changed: 10 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import crypto from 'crypto'
22
import fs from 'fs/promises'
33

4-
import { RequestError } from '@octokit/request-error'
5-
6-
import { retryingGithub } from './github'
4+
import { isRequestError, retryingGithub } from '@/workflows/github'
5+
import { octoSecondaryRatelimitRetry } from '@/workflows/secondary-ratelimit-retry'
76
const github = retryingGithub()
87

98
// https://docs.github.com/rest/reference/git#get-a-reference
@@ -31,7 +30,7 @@ export async function hasMatchingRef(owner: string, repo: string, ref: string) {
3130
})
3231
return true
3332
} catch (err) {
34-
if (err instanceof RequestError && err.status === 404) {
33+
if (isRequestError(err, 404)) {
3534
return false
3635
}
3736
console.log('error getting matching ref', owner, repo, ref)
@@ -220,11 +219,13 @@ async function searchCode(
220219
}
221220

222221
try {
223-
const { data } = await secondaryRateLimitRetry(github.rest.search.code, {
224-
q,
225-
per_page: perPage,
226-
page: currentPage,
227-
})
222+
const { data } = await octoSecondaryRatelimitRetry(() =>
223+
github.rest.search.code({
224+
q,
225+
per_page: perPage,
226+
page: currentPage,
227+
}),
228+
)
228229
if (cache) {
229230
await fs.writeFile(tempFilename, JSON.stringify(data))
230231
console.log(`Wrote search results to ${tempFilename}`)
@@ -237,52 +238,6 @@ async function searchCode(
237238
}
238239
}
239240

240-
async function secondaryRateLimitRetry<T, TArgs = Record<string, unknown>>(
241-
callable: (args: TArgs) => Promise<T>,
242-
args: TArgs,
243-
maxAttempts = 10,
244-
sleepTime = 1000,
245-
): Promise<T> {
246-
try {
247-
const response = await callable(args)
248-
return response
249-
} catch (err: unknown) {
250-
// If you get a secondary rate limit error (403) you'll get a data
251-
// response that includes:
252-
//
253-
// {
254-
// documentation_url: 'https://docs.github.com/en/free-pro-team@latest/rest/overview/resources-in-the-rest-api#secondary-rate-limits',
255-
// message: 'You have exceeded a secondary rate limit. Please wait a few minutes before you try again.'
256-
// }
257-
//
258-
// Let's look for that an manually self-recurse, under certain conditions
259-
const lookFor = 'You have exceeded a secondary rate limit.'
260-
if (
261-
err instanceof RequestError &&
262-
err.status === 403 &&
263-
typeof err.response?.data === 'object' &&
264-
err.response?.data !== null &&
265-
'message' in err.response.data &&
266-
typeof (err.response.data as Record<string, unknown>).message === 'string' &&
267-
(err.response.data as Record<string, string>).message.includes(lookFor) &&
268-
maxAttempts > 0
269-
) {
270-
console.warn(
271-
`Got secondary rate limit blocked. Sleeping for ${
272-
sleepTime / 1000
273-
} seconds. (attempts left: ${maxAttempts})`,
274-
)
275-
return new Promise((resolve) => {
276-
setTimeout(() => {
277-
resolve(secondaryRateLimitRetry(callable, args, maxAttempts - 1, sleepTime * 2))
278-
}, sleepTime)
279-
})
280-
}
281-
282-
throw err
283-
}
284-
}
285-
286241
// Recursively gets the contents of a directory within a repo. Returns an
287242
// array of file contents. This function could be modified to return an array
288243
// of objects that include the path and the content of the file if needed

‎src/workflows/github.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,18 @@ export function retryingGithub(token?: string) {
2929
auth: `token ${token || apiToken}`,
3030
})
3131
}
32+
33+
// Duck-typing instead of `instanceof RequestError` on purpose.
34+
// `@octokit/request` throws a RequestError built from its own nested copy of
35+
// `@octokit/request-error`, which is a different module instance than the
36+
// top-level one. The two classes are not identical, so `instanceof` always
37+
// returns false across that boundary.
38+
export function isRequestError(
39+
error: unknown,
40+
status?: number,
41+
): error is Error & { status: number } {
42+
if (!(error instanceof Error) || !('status' in error)) return false
43+
const errorStatus = (error as { status: unknown }).status
44+
if (typeof errorStatus !== 'number') return false
45+
return status === undefined || errorStatus === status
46+
}

‎src/workflows/secondary-ratelimit-retry.ts‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { isRequestError } from '@/workflows/github'
2+
13
const DEFAULT_SLEEPTIME = parseInt(process.env.SECONDARY_RATELIMIT_RETRY_SLEEPTIME || '30000', 10)
24
const DEFAULT_ATTEMPTS = parseInt(process.env.SECONDARY_RATELIMIT_RETRY_ATTEMPTS || '5', 10)
35

@@ -14,14 +16,8 @@ export async function octoSecondaryRatelimitRetry<T>(
1416
try {
1517
return await fn()
1618
} catch (error) {
17-
// Use duck-typing instead of `instanceof RequestError` because octokit
18-
// bundles its own copy of @octokit/request-error in dist-bundle/index.js,
19-
// so the class reference differs from the top-level package and instanceof
20-
// always returns false across the module boundary.
2119
if (
22-
error instanceof Error &&
23-
'status' in error &&
24-
(error as { status: number }).status === 403 &&
20+
isRequestError(error, 403) &&
2521
/You have exceeded a secondary rate limit/.test(error.message)
2622
) {
2723
if (tries < attempts) {

‎src/workflows/tests/github.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { createRequire } from 'node:module'
2+
3+
import { describe, expect, test } from 'vitest'
4+
import { RequestError } from '@octokit/request-error'
5+
6+
import { isRequestError } from '@/workflows/github'
7+
8+
// `@octokit/request` throws errors built from whichever copy of
9+
// `@octokit/request-error` resolves from its own location, which npm may or may
10+
// not hoist to the top level. Resolve it the same way Node would so this test
11+
// keeps working either way. When it is a separate copy, `instanceof` against the
12+
// top-level class fails, and that is the whole reason `isRequestError` exists.
13+
const requireFromRequest = createRequire(createRequire(import.meta.url).resolve('@octokit/request'))
14+
const nested = await import(requireFromRequest.resolve('@octokit/request-error'))
15+
16+
function makeError(RequestErrorClass: typeof RequestError, status: number, message: string): Error {
17+
return new RequestErrorClass(message, status, {
18+
request: { method: 'GET', url: 'https://api.github.com/x', headers: {} },
19+
response: { status, url: 'https://api.github.com/x', headers: {}, data: { message } },
20+
})
21+
}
22+
23+
describe('isRequestError', () => {
24+
test('matches an error thrown from the copy @octokit/request uses', () => {
25+
const err = makeError(nested.RequestError, 404, 'Not Found')
26+
// When npm does not hoist to a single copy, this is the case a plain
27+
// `instanceof RequestError` gets wrong.
28+
if (nested.RequestError !== RequestError) {
29+
expect(err instanceof RequestError).toBe(false)
30+
}
31+
expect(isRequestError(err)).toBe(true)
32+
expect(isRequestError(err, 404)).toBe(true)
33+
})
34+
35+
test('matches an error from the top-level request-error copy', () => {
36+
const err = makeError(RequestError, 403, 'Forbidden')
37+
expect(isRequestError(err, 403)).toBe(true)
38+
})
39+
40+
test('respects the status argument', () => {
41+
const err = makeError(nested.RequestError, 404, 'Not Found')
42+
expect(isRequestError(err, 403)).toBe(false)
43+
})
44+
45+
test('rejects non-request errors and non-errors', () => {
46+
expect(isRequestError(new Error('nope'))).toBe(false)
47+
expect(isRequestError({ status: 404 })).toBe(false)
48+
expect(isRequestError(undefined)).toBe(false)
49+
expect(isRequestError(null)).toBe(false)
50+
expect(isRequestError('404')).toBe(false)
51+
})
52+
53+
test('rejects an error whose status is not a number', () => {
54+
const err = Object.assign(new Error('weird'), { status: '404' })
55+
expect(isRequestError(err)).toBe(false)
56+
})
57+
})

0 commit comments

Comments
 (0)