Skip to content

feat: coalesce concurrent authentication calls #689

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 2, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import type {
REPOSITORY_SELECTION,
} from "./types.js";

export type CacheableInstallationAuthOptions = InstallationAuthOptions & {
installationId: number;
// TODO: consider baseUrl and appId too, so that we don't incorrectly cache tokens across them
};

export function getCache() {
return new Lru<string>(
// cache max. 15000 tokens, that will use less than 10mb memory
Expand All @@ -21,7 +26,7 @@ export function getCache() {

export async function get(
cache: Cache,
options: InstallationAuthOptions,
options: CacheableInstallationAuthOptions,
): Promise<InstallationAccessTokenData | void> {
const cacheKey = optionsToCacheKey(options);
const result = await cache.get(cacheKey);
Expand Down Expand Up @@ -66,7 +71,7 @@ export async function get(
}
export async function set(
cache: Cache,
options: InstallationAuthOptions,
options: CacheableInstallationAuthOptions,
data: CacheData,
): Promise<void> {
const key = optionsToCacheKey(options);
Expand All @@ -91,12 +96,12 @@ export async function set(
await cache.set(key, value);
}

function optionsToCacheKey({
export function optionsToCacheKey({
installationId,
permissions = {},
repositoryIds = [],
repositoryNames = [],
}: InstallationAuthOptions) {
}: CacheableInstallationAuthOptions) {
const permissionsString = Object.keys(permissions)
.sort()
.map((name) => (permissions[name] === "read" ? name : `${name}!`))
Expand Down
59 changes: 46 additions & 13 deletions src/get-installation-authentication.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { get, set } from "./cache.js";
import {
get,
optionsToCacheKey,
set,
type CacheableInstallationAuthOptions,
} from "./cache.js";
import { getAppAuthentication } from "./get-app-authentication.js";
import { toTokenAuthentication } from "./to-token-authentication.js";
import type {
Expand Down Expand Up @@ -30,16 +35,45 @@ export async function getInstallationAuthentication(
return factory(factoryAuthOptions);
}

const optionsWithInstallationTokenFromState = Object.assign(
{ installationId },
options,
const request = customRequest || state.request;

return getInstallationAuthenticationConcurrently(
state,
{ ...options, installationId },
request,
);
}

const pendingPromises = new Map<
string,
Promise<InstallationAccessTokenAuthentication>
>();

function getInstallationAuthenticationConcurrently(
state: State,
options: CacheableInstallationAuthOptions,
request: RequestInterface,
) {
const cacheKey = optionsToCacheKey(options);
if (pendingPromises.has(cacheKey)) {
return pendingPromises.get(cacheKey)!;
}
const promise = getInstallationAuthenticationImpl(
state,
options,
request,
).finally(() => pendingPromises.delete(cacheKey));
pendingPromises.set(cacheKey, promise);
return promise;
}

async function getInstallationAuthenticationImpl(
state: State,
options: CacheableInstallationAuthOptions,
request: RequestInterface,
) {
if (!options.refresh) {
const result = await get(
state.cache,
optionsWithInstallationTokenFromState,
);
const result = await get(state.cache, options);

if (result) {
const {
Expand All @@ -54,7 +88,7 @@ export async function getInstallationAuthentication(
} = result;

return toTokenAuthentication({
installationId,
installationId: options.installationId,
token,
createdAt,
expiresAt,
Expand All @@ -68,10 +102,9 @@ export async function getInstallationAuthentication(
}

const appAuthentication = await getAppAuthentication(state);
const request = customRequest || state.request;

const payload = {
installation_id: installationId,
installation_id: options.installationId,
mediaType: {
previews: ["machine-man"],
},
Expand Down Expand Up @@ -136,10 +169,10 @@ export async function getInstallationAuthentication(
Object.assign(payload, { singleFileName });
}

await set(state.cache, optionsWithInstallationTokenFromState, cacheOptions);
await set(state.cache, options, cacheOptions);

const cacheData = {
installationId,
installationId: options.installationId,
token,
createdAt,
expiresAt,
Expand Down
109 changes: 108 additions & 1 deletion test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import fetchMock, { type MockMatcherFunction } from "fetch-mock";
import fetchMock from "fetch-mock";

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

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

type MockMatcherFunction = fetchMock.MockMatcherFunction;

const APP_ID = 1;
const PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
Expand Down Expand Up @@ -397,6 +399,111 @@ test("repositoryIds auth option", async () => {
});
});

test("installationId strategy option coalesces concurrent calls with the same options", async () => {
const sandbox = fetchMock
.sandbox()
.post("https://api.github.com/app/installations/123/access_tokens", {
token: "secret123",
expires_at: "1970-01-01T01:00:00.000Z",
permissions: {
metadata: "read",
},
repository_selection: "all",
})
.postOnce("https://api.github.com/app/installations/234/access_tokens", {
token: "secret234",
expires_at: "1970-01-01T01:00:00.000Z",
permissions: {
metadata: "read",
},
repository_selection: "all",
});
const requestMock = request.defaults({
headers: {
"user-agent": "test",
},
request: {
fetch: sandbox,
},
});

const auth = createAppAuth({
appId: APP_ID,
privateKey: PRIVATE_KEY,
installationId: 123,
request: requestMock,
});
const unrelatedAuth = createAppAuth({
appId: APP_ID,
privateKey: PRIVATE_KEY,
installationId: 234,
request: requestMock,
});

const authentications = await Promise.all([
auth({ type: "installation", refresh: true }),
auth({ type: "installation", refresh: true }),
unrelatedAuth({ type: "installation", refresh: true }),
]);
expect(authentications[0]).toEqual({
type: "token",
token: "secret123",
tokenType: "installation",
installationId: 123,
permissions: {
metadata: "read",
},
createdAt: "1970-01-01T00:00:00.000Z",
expiresAt: "1970-01-01T01:00:00.000Z",
repositorySelection: "all",
});
// same result as the first one
expect(authentications[1]).toBe(authentications[0]);
// and only request was made
expect(
sandbox.calls("https://api.github.com/app/installations/123/access_tokens")
.length,
).toBe(1);

// whereas the auth for the unrelated installation got a different token
expect(authentications[2]).toEqual({
type: "token",
token: "secret234",
tokenType: "installation",
installationId: 234,
permissions: {
metadata: "read",
},
createdAt: "1970-01-01T00:00:00.000Z",
expiresAt: "1970-01-01T01:00:00.000Z",
repositorySelection: "all",
});

// now that the original auth call has resolved, if we do it again it will actually run
const laterAuthentication = await auth({
type: "installation",
refresh: true,
});
expect(laterAuthentication).toEqual({
type: "token",
token: "secret123",
tokenType: "installation",
installationId: 123,
permissions: {
metadata: "read",
},
createdAt: "1970-01-01T00:00:00.000Z",
expiresAt: "1970-01-01T01:00:00.000Z",
repositorySelection: "all",
});

// a fresh request was made
expect(
sandbox.calls("https://api.github.com/app/installations/123/access_tokens")
.length,
).toBe(2);
});

test("repositoryNames auth option", async () => {
const matchCreateInstallationAccessToken: MockMatcherFunction = (
_url,
Expand Down