Skip to content

Commit 76398eb

Browse files
authored
feat: coalesce concurrent authentication calls (#689)
* feat: coalesce concurrent authentication calls * lint fix * move types
1 parent a73af3e commit 76398eb

File tree

4 files changed

+161
-19
lines changed

4 files changed

+161
-19
lines changed

src/cache.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Lru } from "toad-cache";
22

33
/* v8 ignore next */
44
import type {
5-
InstallationAuthOptions,
5+
CacheableInstallationAuthOptions,
66
Cache,
77
CacheData,
88
Permissions,
@@ -21,7 +21,7 @@ export function getCache() {
2121

2222
export async function get(
2323
cache: Cache,
24-
options: InstallationAuthOptions,
24+
options: CacheableInstallationAuthOptions,
2525
): Promise<InstallationAccessTokenData | void> {
2626
const cacheKey = optionsToCacheKey(options);
2727
const result = await cache.get(cacheKey);
@@ -66,7 +66,7 @@ export async function get(
6666
}
6767
export async function set(
6868
cache: Cache,
69-
options: InstallationAuthOptions,
69+
options: CacheableInstallationAuthOptions,
7070
data: CacheData,
7171
): Promise<void> {
7272
const key = optionsToCacheKey(options);
@@ -91,12 +91,13 @@ export async function set(
9191
await cache.set(key, value);
9292
}
9393

94-
function optionsToCacheKey({
94+
// TODO: consider baseUrl and appId too, so that we don't incorrectly cache tokens across them
95+
export function optionsToCacheKey({
9596
installationId,
9697
permissions = {},
9798
repositoryIds = [],
9899
repositoryNames = [],
99-
}: InstallationAuthOptions) {
100+
}: CacheableInstallationAuthOptions) {
100101
const permissionsString = Object.keys(permissions)
101102
.sort()
102103
.map((name) => (permissions[name] === "read" ? name : `${name}!`))

src/get-installation-authentication.ts

+42-13
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { get, set } from "./cache.js";
1+
import { get, optionsToCacheKey, set } from "./cache.js";
22
import { getAppAuthentication } from "./get-app-authentication.js";
33
import { toTokenAuthentication } from "./to-token-authentication.js";
44
import type {
55
InstallationAuthOptions,
66
InstallationAccessTokenAuthentication,
77
RequestInterface,
88
State,
9+
CacheableInstallationAuthOptions,
910
} from "./types.js";
1011

1112
export async function getInstallationAuthentication(
@@ -30,16 +31,45 @@ export async function getInstallationAuthentication(
3031
return factory(factoryAuthOptions);
3132
}
3233

33-
const optionsWithInstallationTokenFromState = Object.assign(
34-
{ installationId },
35-
options,
34+
const request = customRequest || state.request;
35+
36+
return getInstallationAuthenticationConcurrently(
37+
state,
38+
{ ...options, installationId },
39+
request,
3640
);
41+
}
3742

43+
const pendingPromises = new Map<
44+
string,
45+
Promise<InstallationAccessTokenAuthentication>
46+
>();
47+
48+
function getInstallationAuthenticationConcurrently(
49+
state: State,
50+
options: CacheableInstallationAuthOptions,
51+
request: RequestInterface,
52+
) {
53+
const cacheKey = optionsToCacheKey(options);
54+
if (pendingPromises.has(cacheKey)) {
55+
return pendingPromises.get(cacheKey)!;
56+
}
57+
const promise = getInstallationAuthenticationImpl(
58+
state,
59+
options,
60+
request,
61+
).finally(() => pendingPromises.delete(cacheKey));
62+
pendingPromises.set(cacheKey, promise);
63+
return promise;
64+
}
65+
66+
async function getInstallationAuthenticationImpl(
67+
state: State,
68+
options: CacheableInstallationAuthOptions,
69+
request: RequestInterface,
70+
) {
3871
if (!options.refresh) {
39-
const result = await get(
40-
state.cache,
41-
optionsWithInstallationTokenFromState,
42-
);
72+
const result = await get(state.cache, options);
4373

4474
if (result) {
4575
const {
@@ -54,7 +84,7 @@ export async function getInstallationAuthentication(
5484
} = result;
5585

5686
return toTokenAuthentication({
57-
installationId,
87+
installationId: options.installationId,
5888
token,
5989
createdAt,
6090
expiresAt,
@@ -68,10 +98,9 @@ export async function getInstallationAuthentication(
6898
}
6999

70100
const appAuthentication = await getAppAuthentication(state);
71-
const request = customRequest || state.request;
72101

73102
const payload = {
74-
installation_id: installationId,
103+
installation_id: options.installationId,
75104
mediaType: {
76105
previews: ["machine-man"],
77106
},
@@ -136,10 +165,10 @@ export async function getInstallationAuthentication(
136165
Object.assign(payload, { singleFileName });
137166
}
138167

139-
await set(state.cache, optionsWithInstallationTokenFromState, cacheOptions);
168+
await set(state.cache, options, cacheOptions);
140169

141170
const cacheData = {
142-
installationId,
171+
installationId: options.installationId,
143172
token,
144173
createdAt,
145174
expiresAt,

src/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ export type InstallationAuthOptionsWithFactory<T> = {
5858
[key: string]: unknown;
5959
};
6060

61+
// normalized installation auth options that can be used for caching
62+
export type CacheableInstallationAuthOptions = InstallationAuthOptions & {
63+
installationId: number;
64+
};
65+
6166
export type OAuthAppAuthOptions = OAuthAppAuth.AppAuthOptions;
6267
export type OAuthWebFlowAuthOptions = OAuthAppAuth.WebFlowAuthOptions;
6368
export type OAuthDeviceFlowAuthOptions =

test/index.test.ts

+108-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import fetchMock, { type MockMatcherFunction } from "fetch-mock";
1+
import fetchMock from "fetch-mock";
22

33
import { request } from "@octokit/request";
44
import { vi, beforeEach, test, expect } from "vitest";
55

66
import { createAppAuth, createOAuthUserAuth } from "../src/index.ts";
77
import type { FactoryInstallation } from "../src/types.ts";
88

9+
type MockMatcherFunction = fetchMock.MockMatcherFunction;
10+
911
const APP_ID = 1;
1012
const PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
1113
MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
@@ -397,6 +399,111 @@ test("repositoryIds auth option", async () => {
397399
});
398400
});
399401

402+
test("installationId strategy option coalesces concurrent calls with the same options", async () => {
403+
const sandbox = fetchMock
404+
.sandbox()
405+
.post("https://api.github.com/app/installations/123/access_tokens", {
406+
token: "secret123",
407+
expires_at: "1970-01-01T01:00:00.000Z",
408+
permissions: {
409+
metadata: "read",
410+
},
411+
repository_selection: "all",
412+
})
413+
.postOnce("https://api.github.com/app/installations/234/access_tokens", {
414+
token: "secret234",
415+
expires_at: "1970-01-01T01:00:00.000Z",
416+
permissions: {
417+
metadata: "read",
418+
},
419+
repository_selection: "all",
420+
});
421+
const requestMock = request.defaults({
422+
headers: {
423+
"user-agent": "test",
424+
},
425+
request: {
426+
fetch: sandbox,
427+
},
428+
});
429+
430+
const auth = createAppAuth({
431+
appId: APP_ID,
432+
privateKey: PRIVATE_KEY,
433+
installationId: 123,
434+
request: requestMock,
435+
});
436+
const unrelatedAuth = createAppAuth({
437+
appId: APP_ID,
438+
privateKey: PRIVATE_KEY,
439+
installationId: 234,
440+
request: requestMock,
441+
});
442+
443+
const authentications = await Promise.all([
444+
auth({ type: "installation", refresh: true }),
445+
auth({ type: "installation", refresh: true }),
446+
unrelatedAuth({ type: "installation", refresh: true }),
447+
]);
448+
expect(authentications[0]).toEqual({
449+
type: "token",
450+
token: "secret123",
451+
tokenType: "installation",
452+
installationId: 123,
453+
permissions: {
454+
metadata: "read",
455+
},
456+
createdAt: "1970-01-01T00:00:00.000Z",
457+
expiresAt: "1970-01-01T01:00:00.000Z",
458+
repositorySelection: "all",
459+
});
460+
// same result as the first one
461+
expect(authentications[1]).toBe(authentications[0]);
462+
// and only request was made
463+
expect(
464+
sandbox.calls("https://api.github.com/app/installations/123/access_tokens")
465+
.length,
466+
).toBe(1);
467+
468+
// whereas the auth for the unrelated installation got a different token
469+
expect(authentications[2]).toEqual({
470+
type: "token",
471+
token: "secret234",
472+
tokenType: "installation",
473+
installationId: 234,
474+
permissions: {
475+
metadata: "read",
476+
},
477+
createdAt: "1970-01-01T00:00:00.000Z",
478+
expiresAt: "1970-01-01T01:00:00.000Z",
479+
repositorySelection: "all",
480+
});
481+
482+
// now that the original auth call has resolved, if we do it again it will actually run
483+
const laterAuthentication = await auth({
484+
type: "installation",
485+
refresh: true,
486+
});
487+
expect(laterAuthentication).toEqual({
488+
type: "token",
489+
token: "secret123",
490+
tokenType: "installation",
491+
installationId: 123,
492+
permissions: {
493+
metadata: "read",
494+
},
495+
createdAt: "1970-01-01T00:00:00.000Z",
496+
expiresAt: "1970-01-01T01:00:00.000Z",
497+
repositorySelection: "all",
498+
});
499+
500+
// a fresh request was made
501+
expect(
502+
sandbox.calls("https://api.github.com/app/installations/123/access_tokens")
503+
.length,
504+
).toBe(2);
505+
});
506+
400507
test("repositoryNames auth option", async () => {
401508
const matchCreateInstallationAccessToken: MockMatcherFunction = (
402509
_url,

0 commit comments

Comments
 (0)