From 95ccb82335ed242847dbbd19b43da12eb7d57d1a Mon Sep 17 00:00:00 2001 From: Andres Aguiar Date: Mon, 16 Feb 2026 15:55:25 -0300 Subject: [PATCH 1/4] test: reproduce concurrent token refresh thundering herd --- tests/credentials.test.ts | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index a465c794..6ae76631 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -537,5 +537,47 @@ describe("Credentials", () => { expect(scope.isDone()).toBe(true); }); + + test("should send a single token request for concurrent access token reads", async () => { + const apiTokenIssuer = "issuer.fga.example"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; + let tokenRequestCount = 0; + + nock(expectedBaseUrl) + .post(expectedPath) + .times(5) + .delay(20) + .reply(() => { + tokenRequestCount += 1; + return [200, { + access_token: "shared-token", + expires_in: 300, + }]; + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + const headers = await Promise.all( + Array.from({ length: 5 }, () => credentials.getAccessTokenHeader()) + ); + + headers.forEach(header => { + expect(header?.value).toBe("Bearer shared-token"); + }); + expect(tokenRequestCount).toBe(1); + }); }); }); From a1ae47f7dc916ea85150f2ad4b946bacf4404779 Mon Sep 17 00:00:00 2001 From: Andres Aguiar Date: Mon, 16 Feb 2026 15:55:39 -0300 Subject: [PATCH 2/4] fix: deduplicate concurrent token refresh requests --- credentials/credentials.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/credentials/credentials.ts b/credentials/credentials.ts index b350178f..cb425f65 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -45,6 +45,7 @@ export const DEFAULT_TOKEN_ENDPOINT_PATH = "oauth/token"; export class Credentials { private accessToken?: string; private accessTokenExpiryDate?: Date; + private refreshAccessTokenPromise?: Promise; public static init(configuration: { credentials: AuthCredentialsConfig, telemetry: TelemetryConfiguration, baseOptions?: any }, axios: AxiosInstance = globalAxios): Credentials { return new Credentials(configuration.credentials, axios, configuration.telemetry, configuration.baseOptions); @@ -141,7 +142,13 @@ export class Credentials { return this.accessToken; } - return this.refreshAccessToken(); + if (!this.refreshAccessTokenPromise) { + this.refreshAccessTokenPromise = this.refreshAccessToken().finally(() => { + this.refreshAccessTokenPromise = undefined; + }); + } + + return this.refreshAccessTokenPromise; } } From d31b42147456de7c4913249249ff1c58c7ac953f Mon Sep 17 00:00:00 2001 From: Andres Aguiar Date: Mon, 16 Feb 2026 18:15:30 -0300 Subject: [PATCH 3/4] test: enforce single token exchange call with nock --- tests/credentials.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 6ae76631..044719f9 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -544,9 +544,9 @@ describe("Credentials", () => { const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; let tokenRequestCount = 0; - nock(expectedBaseUrl) + const scope = nock(expectedBaseUrl) .post(expectedPath) - .times(5) + .once() .delay(20) .reply(() => { tokenRequestCount += 1; @@ -578,6 +578,7 @@ describe("Credentials", () => { expect(header?.value).toBe("Bearer shared-token"); }); expect(tokenRequestCount).toBe(1); + expect(scope.isDone()).toBe(true); }); }); }); From 5ab0260f359356d7f363464638c60c7be7863e66 Mon Sep 17 00:00:00 2001 From: Andres Aguiar Date: Tue, 17 Feb 2026 09:52:14 -0300 Subject: [PATCH 4/4] test: cover failed shared token refresh retry path --- tests/credentials.test.ts | 61 ++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 044719f9..c5396927 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -542,18 +542,14 @@ describe("Credentials", () => { const apiTokenIssuer = "issuer.fga.example"; const expectedBaseUrl = "https://issuer.fga.example"; const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; - let tokenRequestCount = 0; const scope = nock(expectedBaseUrl) .post(expectedPath) .once() .delay(20) - .reply(() => { - tokenRequestCount += 1; - return [200, { - access_token: "shared-token", - expires_in: 300, - }]; + .reply(200, { + access_token: "shared-token", + expires_in: 300, }); const credentials = new Credentials( @@ -577,7 +573,56 @@ describe("Credentials", () => { headers.forEach(header => { expect(header?.value).toBe("Bearer shared-token"); }); - expect(tokenRequestCount).toBe(1); + expect(scope.isDone()).toBe(true); + }); + + test("should clear shared refresh promise after failure and retry on the next call", async () => { + const apiTokenIssuer = "issuer.fga.example"; + const expectedBaseUrl = "https://issuer.fga.example"; + const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`; + + const scope = nock(expectedBaseUrl) + .post(expectedPath) + .once() + .reply(404, { + code: "not_found", + message: "token exchange failed", + }) + .post(expectedPath) + .once() + .reply(200, { + access_token: "recovered-token", + expires_in: 300, + }); + + const credentials = new Credentials( + { + method: CredentialsMethod.ClientCredentials, + config: { + apiTokenIssuer, + apiAudience: OPENFGA_API_AUDIENCE, + clientId: OPENFGA_CLIENT_ID, + clientSecret: OPENFGA_CLIENT_SECRET, + }, + } as AuthCredentialsConfig, + undefined, + mockTelemetryConfig, + ); + + const results = await Promise.allSettled( + Array.from({ length: 5 }, () => credentials.getAccessTokenHeader()) + ); + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === "rejected"); + + expect(rejected).toHaveLength(5); + expect(rejected[0].reason).toBe(rejected[1].reason); + expect(rejected[1].reason).toBe(rejected[2].reason); + expect(rejected[2].reason).toBe(rejected[3].reason); + expect(rejected[3].reason).toBe(rejected[4].reason); + + const header = await credentials.getAccessTokenHeader(); + + expect(header?.value).toBe("Bearer recovered-token"); expect(scope.isDone()).toBe(true); }); });