Skip to content

Commit 025346f

Browse files
authored
fix: prevent token refresh thundering herd (#333)
* test: reproduce concurrent token refresh thundering herd * fix: deduplicate concurrent token refresh requests * test: enforce single token exchange call with nock * test: cover failed shared token refresh retry path
1 parent 7331c56 commit 025346f

2 files changed

Lines changed: 96 additions & 1 deletion

File tree

credentials/credentials.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export class Credentials {
4747
private accessToken?: string;
4848
private accessTokenExpiryDate?: Date;
4949
private accessTokenExpiryBufferInMs = 0;
50+
private refreshAccessTokenPromise?: Promise<string | undefined>;
5051

5152
public static init(configuration: { credentials: AuthCredentialsConfig, telemetry: TelemetryConfiguration, baseOptions?: any }, axios: AxiosInstance = globalAxios): Credentials {
5253
return new Credentials(configuration.credentials, axios, configuration.telemetry, configuration.baseOptions);
@@ -146,7 +147,13 @@ export class Credentials {
146147
return this.accessToken;
147148
}
148149

149-
return this.refreshAccessToken();
150+
if (!this.refreshAccessTokenPromise) {
151+
this.refreshAccessTokenPromise = this.refreshAccessToken().finally(() => {
152+
this.refreshAccessTokenPromise = undefined;
153+
});
154+
}
155+
156+
return this.refreshAccessTokenPromise;
150157
}
151158
}
152159
}

tests/credentials.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,94 @@ describe("Credentials", () => {
539539
expect(scope.isDone()).toBe(true);
540540
});
541541

542+
test("should send a single token request for concurrent access token reads", async () => {
543+
const apiTokenIssuer = "issuer.fga.example";
544+
const expectedBaseUrl = "https://issuer.fga.example";
545+
const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`;
546+
547+
const scope = nock(expectedBaseUrl)
548+
.post(expectedPath)
549+
.once()
550+
.delay(20)
551+
.reply(200, {
552+
access_token: "shared-token",
553+
expires_in: 300,
554+
});
555+
556+
const credentials = new Credentials(
557+
{
558+
method: CredentialsMethod.ClientCredentials,
559+
config: {
560+
apiTokenIssuer,
561+
apiAudience: OPENFGA_API_AUDIENCE,
562+
clientId: OPENFGA_CLIENT_ID,
563+
clientSecret: OPENFGA_CLIENT_SECRET,
564+
},
565+
} as AuthCredentialsConfig,
566+
undefined,
567+
mockTelemetryConfig,
568+
);
569+
570+
const headers = await Promise.all(
571+
Array.from({ length: 5 }, () => credentials.getAccessTokenHeader())
572+
);
573+
574+
headers.forEach(header => {
575+
expect(header?.value).toBe("Bearer shared-token");
576+
});
577+
expect(scope.isDone()).toBe(true);
578+
});
579+
580+
test("should clear shared refresh promise after failure and retry on the next call", async () => {
581+
const apiTokenIssuer = "issuer.fga.example";
582+
const expectedBaseUrl = "https://issuer.fga.example";
583+
const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`;
584+
585+
const scope = nock(expectedBaseUrl)
586+
.post(expectedPath)
587+
.once()
588+
.reply(404, {
589+
code: "not_found",
590+
message: "token exchange failed",
591+
})
592+
.post(expectedPath)
593+
.once()
594+
.reply(200, {
595+
access_token: "recovered-token",
596+
expires_in: 300,
597+
});
598+
599+
const credentials = new Credentials(
600+
{
601+
method: CredentialsMethod.ClientCredentials,
602+
config: {
603+
apiTokenIssuer,
604+
apiAudience: OPENFGA_API_AUDIENCE,
605+
clientId: OPENFGA_CLIENT_ID,
606+
clientSecret: OPENFGA_CLIENT_SECRET,
607+
},
608+
} as AuthCredentialsConfig,
609+
undefined,
610+
mockTelemetryConfig,
611+
);
612+
613+
const results = await Promise.allSettled(
614+
Array.from({ length: 5 }, () => credentials.getAccessTokenHeader())
615+
);
616+
const rejected = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
617+
618+
expect(rejected).toHaveLength(5);
619+
expect(rejected[0].reason).toBe(rejected[1].reason);
620+
expect(rejected[1].reason).toBe(rejected[2].reason);
621+
expect(rejected[2].reason).toBe(rejected[3].reason);
622+
expect(rejected[3].reason).toBe(rejected[4].reason);
623+
624+
const header = await credentials.getAccessTokenHeader();
625+
626+
expect(header?.value).toBe("Bearer recovered-token");
627+
expect(scope.isDone()).toBe(true);
628+
});
629+
542630
test("should refresh cached token when it is close to expiration", async () => {
543631
const apiTokenIssuer = "issuer.fga.example";
544632
const expectedBaseUrl = "https://issuer.fga.example";

0 commit comments

Comments
 (0)