Skip to content

Commit 54f107e

Browse files
authored
fix: don't deadlock on 10+ concurrent requests needing auth (#779)
* fix: don't deadlock on 10+ concurrent requests needing auth give auth requests a dedicated queue, so that they don't cause a deadlock when too many requests are queued * revert unnecessary change to testPlugin * update TODO to reference related issue * bump @octokit/auth-app and simplify test * add more auth urls
1 parent b879eb9 commit 54f107e

File tree

6 files changed

+226
-2
lines changed

6 files changed

+226
-2
lines changed

package-lock.json

Lines changed: 114 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@octokit/core": "^6.1.3"
3030
},
3131
"devDependencies": {
32+
"@octokit/auth-app": "^7.2.0",
3233
"@octokit/core": "^6.1.3",
3334
"@octokit/request-error": "^6.1.6",
3435
"@octokit/tsconfig": "^4.0.0",

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ const createGroups = function (
3030
maxConcurrent: 10,
3131
...common,
3232
});
33+
groups.auth = new Bottleneck.Group({
34+
id: "octokit-auth",
35+
maxConcurrent: 1,
36+
...common,
37+
});
3338
groups.search = new Bottleneck.Group({
3439
id: "octokit-search",
3540
maxConcurrent: 1,

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export type ThrottlingOptions =
3939

4040
export type Groups = {
4141
global?: Bottleneck.Group;
42+
auth?: Bottleneck.Group;
4243
write?: Bottleneck.Group;
4344
search?: Bottleneck.Group;
4445
notifications?: Bottleneck.Group;

src/wrap-request.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ async function doRequest(
2020
) => Promise<OctokitResponse<any>>) & { retryCount: number },
2121
options: Required<EndpointDefaults>,
2222
) {
23-
const isWrite = options.method !== "GET" && options.method !== "HEAD";
2423
const { pathname } = new URL(options.url, "http://github.test");
24+
const isAuth = isAuthRequest(options.method, pathname);
25+
const isWrite =
26+
!isAuth && options.method !== "GET" && options.method !== "HEAD";
2527
const isSearch = options.method === "GET" && pathname.startsWith("/search/");
2628
const isGraphQL = pathname.startsWith("/graphql");
2729

@@ -50,7 +52,7 @@ async function doRequest(
5052
await state.search.key(state.id).schedule(jobOptions, noop);
5153
}
5254

53-
const req = state.global
55+
const req = (isAuth ? state.auth : state.global)
5456
.key(state.id)
5557
.schedule<
5658
OctokitResponse<any>,
@@ -73,3 +75,18 @@ async function doRequest(
7375
}
7476
return req;
7577
}
78+
79+
function isAuthRequest(method: string, pathname: string) {
80+
return (
81+
(method === "PATCH" &&
82+
// https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-a-scoped-access-token
83+
/^\/applications\/[^/]+\/token\/scoped$/.test(pathname)) ||
84+
(method === "POST" &&
85+
// https://docs.github.com/en/rest/apps/oauth-applications?apiVersion=2022-11-28#reset-a-token
86+
(/^\/applications\/[^/]+\/token$/.test(pathname) ||
87+
// https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app
88+
/^\/app\/installations\/[^/]+\/access_tokens$/.test(pathname) ||
89+
// https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
90+
pathname === "/login/oauth/access_token"))
91+
);
92+
}

test/plugin.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { describe, it, expect } from "vitest";
22
import Bottleneck from "bottleneck";
3+
import { createAppAuth } from "@octokit/auth-app";
34
import { TestOctokit } from "./octokit.ts";
45
import { throttling } from "../src/index.ts";
6+
import { Octokit } from "@octokit/core";
7+
import * as crypto from "node:crypto";
8+
import { promisify } from "node:util";
9+
const generateKeyPair = promisify(crypto.generateKeyPair);
510

611
describe("General", function () {
712
it("Should be possible to disable the plugin", async function () {
@@ -377,4 +382,85 @@ describe("GitHub API best practices", function () {
377382
octokit.__requestTimings[12] - octokit.__requestTimings[10],
378383
).toBeLessThan(30);
379384
});
385+
386+
it("should not deadlock concurrent auth requests", async function () {
387+
// instrument a fake fetch rather than using TestOctokit; this way
388+
// @octokit/auth-app's request hook will actually run and we can
389+
// track all requests (auth ones too, not just the top-level ones
390+
// we make)
391+
const requestLog: string[] = [];
392+
const fakeFetch = async (url: string, init: any) => {
393+
requestLog.push(`${init.method.toUpperCase()} ${url}`);
394+
let data = {};
395+
if (init.method === "POST" && url.includes("/app/installations/")) {
396+
data = {
397+
token: "token",
398+
expires_at: new Date(Date.now() + 60_000).toISOString(),
399+
permissions: {},
400+
single_file: "",
401+
};
402+
}
403+
404+
return Promise.resolve(
405+
new Response(JSON.stringify(data), {
406+
status: 200,
407+
headers: { "content-type": "application/json" },
408+
}),
409+
);
410+
};
411+
// jsonwebtoken needs a valid private key to sign the JWT, though the
412+
// actual value doesn't matter since nothing will validate it
413+
const privateKey = (
414+
await generateKeyPair("rsa", {
415+
modulusLength: 4096,
416+
publicKeyEncoding: { type: "spki", format: "pem" },
417+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
418+
})
419+
).privateKey;
420+
421+
const octokit = new (Octokit.plugin(throttling))({
422+
authStrategy: createAppAuth,
423+
auth: {
424+
appId: 123,
425+
privateKey,
426+
installationId: 456,
427+
},
428+
throttle: {
429+
onSecondaryRateLimit: () => 0,
430+
onRateLimit: () => 0,
431+
},
432+
request: {
433+
fetch: fakeFetch,
434+
},
435+
});
436+
437+
const routes = [
438+
"/route01",
439+
"/route02",
440+
"/route03",
441+
"/route04",
442+
"/route05",
443+
"/route06",
444+
"/route07",
445+
"/route08",
446+
"/route09",
447+
"/route10",
448+
];
449+
450+
await Promise.all(routes.map((route) => octokit.request(`GET ${route}`)));
451+
452+
expect(requestLog).toStrictEqual([
453+
"POST https://api.github.com/app/installations/456/access_tokens",
454+
"GET https://api.github.com/route01",
455+
"GET https://api.github.com/route02",
456+
"GET https://api.github.com/route03",
457+
"GET https://api.github.com/route04",
458+
"GET https://api.github.com/route05",
459+
"GET https://api.github.com/route06",
460+
"GET https://api.github.com/route07",
461+
"GET https://api.github.com/route08",
462+
"GET https://api.github.com/route09",
463+
"GET https://api.github.com/route10",
464+
]);
465+
});
380466
});

0 commit comments

Comments
 (0)