From 7ec0093518f562bc3b97a6c4741c0a072468d951 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 18 Feb 2026 20:52:51 +0000 Subject: [PATCH 1/7] Make "do not retry" explicit --- src/wrap-request.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/wrap-request.ts b/src/wrap-request.ts index e4127773..da1d9b0d 100644 --- a/src/wrap-request.ts +++ b/src/wrap-request.ts @@ -26,6 +26,9 @@ export async function wrapRequest( // the request after that number of milliseconds have passed return after * state.retryAfterBaseValue; } + + // Do not retry. + return undefined; }); return limiter.schedule( From e1fcc27f7506c4d448d3a4bd8ca0c36f467e5309 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 18 Feb 2026 20:55:13 +0000 Subject: [PATCH 2/7] Allow custom `shouldRetry` predicate in `RetryState` --- src/error-request.ts | 18 ++++++++++++------ src/index.ts | 3 ++- src/types.ts | 1 + test/retry.test.ts | 8 +++++++- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/error-request.ts b/src/error-request.ts index 25af8a65..55a1d66e 100644 --- a/src/error-request.ts +++ b/src/error-request.ts @@ -6,19 +6,25 @@ export function isRequestError(error: any): error is RequestError { return error.request !== undefined; } -export async function errorRequest( +export function defaultShouldRetry( state: RetryState, - octokit: RetryPlugin, error: RequestError | Error, - options: { request: RequestRequestOptions }, -): Promise { +): boolean { if (!isRequestError(error) || !error?.request.request) { // address https://github.com/octokit/plugin-retry.js/issues/8 throw error; } - // retry all >= 400 && not doNotRetry - if (error.status >= 400 && !state.doNotRetry.includes(error.status)) { + return error.status >= 400 && !state.doNotRetry.includes(error.status); +} + +export async function errorRequest( + state: RetryState, + octokit: RetryPlugin, + error: RequestError | Error, + options: { request: RequestRequestOptions }, +): Promise { + if (isRequestError(error) && state.shouldRetry(state, error)) { const retries = options.request.retries != null ? options.request.retries : state.retries; const retryAfter = Math.pow((options.request.retryCount || 0) + 1, 2); diff --git a/src/index.ts b/src/index.ts index cbba4182..64eb807a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import type { Octokit, OctokitOptions } from "@octokit/core"; import type { RequestError } from "@octokit/request-error"; import { VERSION } from "./version.js"; -import { errorRequest } from "./error-request.js"; +import { defaultShouldRetry, errorRequest } from "./error-request.js"; import { wrapRequest } from "./wrap-request.js"; import type { RetryOptions, RetryPlugin, RetryState } from "./types.js"; import type { RequestRequestOptions } from "@octokit/types"; @@ -18,6 +18,7 @@ export function retry( retryAfterBaseValue: 1000, doNotRetry: [400, 401, 403, 404, 410, 422, 451], retries: 3, + shouldRetry: defaultShouldRetry, } satisfies RetryState, octokitOptions.retry, ); diff --git a/src/types.ts b/src/types.ts index 65598812..a00022e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,7 @@ export interface RetryOptions { retryAfterBaseValue?: number; doNotRetry?: number[]; retries?: number; + shouldRetry?: (state: RetryState, error: RequestError | Error) => boolean; } export type RetryState = Required; diff --git a/test/retry.test.ts b/test/retry.test.ts index acc0c369..bdc4e974 100644 --- a/test/retry.test.ts +++ b/test/retry.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from "vitest"; import { TestOctokit } from "./octokit.ts"; -import { errorRequest, isRequestError } from "../src/error-request.ts"; +import { + defaultShouldRetry, + errorRequest, + isRequestError, +} from "../src/error-request.ts"; import { RequestError } from "@octokit/request-error"; import type { RequestMethod, @@ -414,6 +418,7 @@ describe("errorRequest", function () { retryAfterBaseValue: 1000, doNotRetry: [400, 401, 403, 404, 422], retries: 3, + shouldRetry: defaultShouldRetry, } satisfies RetryState; const requestOptions = { method: "GET" as RequestMethod, @@ -448,6 +453,7 @@ describe("errorRequest", function () { retryAfterBaseValue: 1000, doNotRetry: [400, 401, 403, 404, 422], retries: 3, + shouldRetry: defaultShouldRetry, } satisfies RetryState; const requestOptions = { method: "GET" as RequestMethod, From 0af7f574c6b4c990bbee96b2226b9b37eb22b959 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 18 Feb 2026 23:58:23 +0000 Subject: [PATCH 3/7] Extract default `RetryState` into constant --- src/index.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 64eb807a..a8afa341 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,18 +8,21 @@ import type { RetryOptions, RetryPlugin, RetryState } from "./types.js"; import type { RequestRequestOptions } from "@octokit/types"; export { VERSION } from "./version.js"; +export const defaultRetryState: RetryState = { + enabled: true, + retryAfterBaseValue: 1000, + doNotRetry: [400, 401, 403, 404, 410, 422, 451], + retries: 3, + shouldRetry: defaultShouldRetry, +}; + export function retry( octokit: Octokit, octokitOptions: OctokitOptions, ): RetryPlugin { const state: RetryState = Object.assign( - { - enabled: true, - retryAfterBaseValue: 1000, - doNotRetry: [400, 401, 403, 404, 410, 422, 451], - retries: 3, - shouldRetry: defaultShouldRetry, - } satisfies RetryState, + {}, + defaultRetryState, octokitOptions.retry, ); From 5ee37bbf35e358848feed8707029cd92cdeacea5 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Thu, 19 Feb 2026 00:06:52 +0000 Subject: [PATCH 4/7] Add tests for `defaultShouldRetry` --- test/error-request.test.ts | 61 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 test/error-request.test.ts diff --git a/test/error-request.test.ts b/test/error-request.test.ts new file mode 100644 index 00000000..13f268dd --- /dev/null +++ b/test/error-request.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { defaultShouldRetry } from "../src/error-request.ts"; +import { defaultRetryState } from "../src/index.ts"; +import { RequestError } from "@octokit/request-error"; + +describe("defaultShouldRetry", function () { + it("should re-throw non-RequestError errors", function () { + try { + defaultShouldRetry(defaultRetryState, new Error("Re-throw me")); + throw new Error("Should not reach this point"); + } catch (err: any) { + expect(err.message).toEqual("Re-throw me"); + } + }); + + it("should re-throw errors without RequestRequestOptions", function () { + try { + defaultShouldRetry( + defaultRetryState, + new RequestError("Re-throw me", 500, { + request: { method: "GET", url: "/something", headers: {} }, + }), + ); + throw new Error("Should not reach this point"); + } catch (err: any) { + expect(err.message).toEqual("Re-throw me"); + } + }); + + it("returns false for doNotRetry status codes", function () { + for (const statusCode of defaultRetryState.doNotRetry) { + const result = defaultShouldRetry( + defaultRetryState, + new RequestError("Re-throw me", statusCode, { + request: { + method: "GET", + url: "/something", + headers: {}, + request: {}, + }, + }), + ); + expect(result).toBe(false); + } + }); + + it("returns true for 500 errors", function () { + const result = defaultShouldRetry( + defaultRetryState, + new RequestError("Re-throw me", 500, { + request: { + method: "GET", + url: "/something", + headers: {}, + request: {}, + }, + }), + ); + expect(result).toBe(true); + }); +}); From 6eb34b64aa2f81c42e780826cce2834e5da1d315 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Thu, 19 Feb 2026 14:28:42 +0000 Subject: [PATCH 5/7] Extend `RequestRequestOptions` with this plugin's properties --- src/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/index.ts b/src/index.ts index a8afa341..199e109e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,4 +58,11 @@ declare module "@octokit/core/types" { } } +declare module "@octokit/types" { + interface RequestRequestOptions { + retries?: number; + retryAfter?: number; + } +} + export type { RetryPlugin, RetryOptions }; From dc1a8bd1287e455e70a43abd04749dc5ce073a6d Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Thu, 19 Feb 2026 15:45:35 +0000 Subject: [PATCH 6/7] Transform `RequestOptions` rather than error with `retryRequest` --- src/error-request.ts | 29 ++++++++++++++++++++--------- src/index.ts | 18 ++++++++++++------ src/types.ts | 16 ++++++++++++++-- src/wrap-request.ts | 4 ++-- test/error-request.test.ts | 33 ++++++++++++++++++++++++++++++++- test/retry.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 20 deletions(-) diff --git a/src/error-request.ts b/src/error-request.ts index 55a1d66e..a1de3e30 100644 --- a/src/error-request.ts +++ b/src/error-request.ts @@ -1,6 +1,9 @@ -import { type RetryPlugin, type RetryState } from "./types.js"; -import type { RequestRequestOptions } from "@octokit/types"; -import type { RequestError } from "@octokit/request-error"; +import { + type RequestOptionsWithRequest, + type RetryPlugin, + type RetryState, +} from "./types.js"; +import { RequestError } from "@octokit/request-error"; export function isRequestError(error: any): error is RequestError { return error.request !== undefined; @@ -22,13 +25,21 @@ export async function errorRequest( state: RetryState, octokit: RetryPlugin, error: RequestError | Error, - options: { request: RequestRequestOptions }, + options: RequestOptionsWithRequest, ): Promise { - if (isRequestError(error) && state.shouldRetry(state, error)) { - const retries = - options.request.retries != null ? options.request.retries : state.retries; - const retryAfter = Math.pow((options.request.retryCount || 0) + 1, 2); - throw octokit.retry.retryRequest(error, retries, retryAfter); + if (state.shouldRetry(state, error)) { + const retries: number = + options.request?.retries != null + ? options.request.retries + : state.retries; + const retryAfter = Math.pow((options.request?.retryCount || 0) + 1, 2); + throw new RequestError( + error.message, + isRequestError(error) ? error.status : 500, + { + request: octokit.retry.retryRequest(options, retries, retryAfter), + }, + ); } // Maybe eventually there will be more cases here diff --git a/src/index.ts b/src/index.ts index 199e109e..3227655c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,15 @@ import type { Octokit, OctokitOptions } from "@octokit/core"; -import type { RequestError } from "@octokit/request-error"; import { VERSION } from "./version.js"; import { defaultShouldRetry, errorRequest } from "./error-request.js"; import { wrapRequest } from "./wrap-request.js"; -import type { RetryOptions, RetryPlugin, RetryState } from "./types.js"; +import type { + RequestOptionsWithRequest, + RetryOptions, + RetryPlugin, + RetryRequestOptions, + RetryState, +} from "./types.js"; import type { RequestRequestOptions } from "@octokit/types"; export { VERSION } from "./version.js"; @@ -29,16 +34,17 @@ export function retry( const retryPlugin: RetryPlugin = { retry: { retryRequest: ( - error: RequestError, + request: RequestOptionsWithRequest, retries: number, retryAfter: number, ) => { - error.request.request = Object.assign({}, error.request.request, { + const newRequest: RequestRequestOptions & + Required = Object.assign({}, request.request, { retries: retries, retryAfter: retryAfter, - } satisfies RequestRequestOptions); + } as Required); - return error; + return { ...request, request: newRequest }; }, }, }; diff --git a/src/types.ts b/src/types.ts index a00022e6..0ad06110 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,24 @@ import type { RequestError } from "@octokit/request-error"; +import type { RequestOptions, RequestRequestOptions } from "@octokit/types"; + +export interface RetryRequestOptions { + retries?: number; + retryAfter?: number; +} + +export type RequestOptionsWithRequest = RequestOptions & { + request: RequestRequestOptions & RetryRequestOptions; +}; export interface RetryPlugin { retry: { retryRequest: ( - error: RequestError, + request: RequestOptionsWithRequest, retries: number, retryAfter: number, - ) => RequestError; + ) => RequestOptions & { + request: RequestRequestOptions & Required; + }; }; } diff --git a/src/wrap-request.ts b/src/wrap-request.ts index da1d9b0d..65ebd755 100644 --- a/src/wrap-request.ts +++ b/src/wrap-request.ts @@ -17,8 +17,8 @@ export async function wrapRequest( const limiter = new Bottleneck(); limiter.on("failed", function (error: RequestError, info: RetryableInfo) { - const maxRetries = ~~error.request.request?.retries; - const after = ~~error.request.request?.retryAfter; + const maxRetries = error.request.request?.retries || 0; + const after = error.request.request?.retryAfter || 0; options.request.retryCount = info.retryCount + 1; if (maxRetries > info.retryCount) { diff --git a/test/error-request.test.ts b/test/error-request.test.ts index 13f268dd..43daa2c7 100644 --- a/test/error-request.test.ts +++ b/test/error-request.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect } from "vitest"; -import { defaultShouldRetry } from "../src/error-request.ts"; +import { defaultShouldRetry, errorRequest } from "../src/error-request.ts"; import { defaultRetryState } from "../src/index.ts"; import { RequestError } from "@octokit/request-error"; +import { TestOctokit } from "./octokit.ts"; +import { RetryState } from "../src/types.ts"; +import type { RequestMethod, RequestOptions } from "@octokit/types"; describe("defaultShouldRetry", function () { it("should re-throw non-RequestError errors", function () { @@ -59,3 +62,31 @@ describe("defaultShouldRetry", function () { expect(result).toBe(true); }); }); + +describe("errorRequest", function () { + it("allows non-RequestErrors to be retried", async function () { + const state: RetryState = { + ...defaultRetryState, + shouldRetry: (_state, _error) => true, + }; + const requestOptions = { + method: "GET" as RequestMethod, + url: "/issues", + headers: {}, + request: {}, + } satisfies RequestOptions; + + try { + await errorRequest( + state, + new TestOctokit(), + new Error("Some non-RequestError"), + requestOptions, + ); + throw new Error("Should not reach this point"); + } catch (error: any) { + expect(error.message).toBe("Some non-RequestError"); + expect(error.request.request.retries).toBe(3); + } + }); +}); diff --git a/test/retry.test.ts b/test/retry.test.ts index bdc4e974..f82d9357 100644 --- a/test/retry.test.ts +++ b/test/retry.test.ts @@ -62,6 +62,43 @@ describe("Automatic Retries", function () { ).toBeLessThan(20); }); + it("Should retry and pass with custom shouldRetry function", async function () { + const octokit = new TestOctokit({ + shouldRetry: (_retryState: RetryState, error: any) => { + if (isRequestError(error)) { + return error.message.includes("Intermittent problem"); + } + return false; + }, + }); + + const response = await octokit.request("GET /route", { + request: { + responses: [ + { + status: 403, + headers: {}, + data: { message: "Intermittent problem" }, + }, + { status: 200, headers: {}, data: { message: "Success!" } }, + ], + retries: 1, + }, + }); + + expect(response.status).toEqual(200); + expect(response.data).toStrictEqual({ message: "Success!" }); + expect(octokit.__requestLog).toStrictEqual([ + "START GET /route", + "START GET /route", + "END GET /route", + ]); + + expect( + octokit.__requestTimings[1] - octokit.__requestTimings[0], + ).toBeLessThan(20); + }); + it("Should retry twice and fail", async function () { const octokit = new TestOctokit(); From c2ba3db63f7dbaa3d1a6fac578dc219f6b2a6f01 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Thu, 19 Feb 2026 16:47:52 +0000 Subject: [PATCH 7/7] Update README --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 34c5c76b..794d90e8 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,22 @@ const octokit = new MyOctokit({ }); ``` +You can override the default predicate that determines whether to retry a request based on the outcome of the previous attempt. For example, to retry if the response message includes a particular string. Note that the `doNotRetry` option from the constructor is ignored in this case. + +```typescript +const octokit = new MyOctokit({ + auth: "secret123", + retry: { + shouldRetry: (retryState: RetryState, error: any) => { + if (isRequestError(error)) { + return error.message.includes("Intermittent problem"); + } + return false; + }, + }, +}); +``` + To override the number of retries: ```js