From 1f3b518392d294adef4a6776cf7e4d7832fb936c Mon Sep 17 00:00:00 2001 From: Stephen Bennett Date: Wed, 26 Aug 2026 21:14:09 -0500 Subject: [PATCH] fix: share the in-flight sign in so a cold token opens one session getToken had no in-flight guard. Every caller arriving while the token was null or older than 14 minutes ran its own sign in, so N concurrent callers opened N Data API sessions. Only the last one was kept in this.token; the rest were never referenced again and never deleted, so they sat against the server's session limit until it timed them out. Consumers hit this whenever a page fans out. A tracker-api endpoint that answers three concurrent requests after every save opened three sessions and abandoned two on any cold start, which is every time someone comes back after a quarter of an hour and logs time. Sharing the pending promise means one sign in however many callers arrive together. The promise is cleared when it settles, so a failed sign in is not cached and the next caller retries rather than being handed a rejection for good. This also covers the invalid-token retry: request() nulls the token on code 952 and calls itself, and concurrent retries now coalesce the same way. That path is not reachable from a subclass, because the retry happens inside request(). Three tests: one sign in for three concurrent callers, recovery after a failed sign in, and every waiter rejecting when the shared sign in fails. Verified by mutation: dropping the shared promise fails the first and leaves the other sixteen in the file passing. --- src/Client.ts | 13 ++++++++++ test/Client.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/Client.ts b/src/Client.ts index e793ac9..c29402d 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -29,6 +29,7 @@ type ContainerDownload = { export default class Client { private token: string | null = null; private lastCall = 0; + private tokenRequest: Promise | null = null; public constructor( private readonly uri: string, @@ -127,6 +128,18 @@ export default class Client { return this.token; } + // Without this, every caller arriving on a cold or expired token opens its own session. + // Only the last one is kept in this.token; the rest are never referenced again and never + // deleted, so they sit against the server's session limit until it times them out. Sharing + // the in-flight request means one login however many callers arrive together. + this.tokenRequest ??= this.mintToken().finally(() => { + this.tokenRequest = null; + }); + + return this.tokenRequest; + } + + private async mintToken(): Promise { const headers = { 'Content-Type': 'application/json', 'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`, diff --git a/test/Client.test.ts b/test/Client.test.ts index 7b33753..e728a24 100644 --- a/test/Client.test.ts +++ b/test/Client.test.ts @@ -71,6 +71,67 @@ describe('Client', () => { await client.request('test'); }); + it('should sign in once when several requests arrive on a cold token', async () => { + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 200, + headers: {'X-FM-Data-Access-Token': 'foo'}, + body: {}, + }); + + fetchMock.get('https://localhost/fmi/data/v1/databases/db/test', { + status: 200, + headers: { + 'authorization': 'Bearer foo', + 'content-type': 'application/json', + }, + body: {response: 'test'}, + }); + + await Promise.all([client.request('test'), client.request('test'), client.request('test')]); + + // one session for the three of them: without sharing the in-flight sign in, each caller + // opens its own and every session but the last is orphaned on the server + expect(fetchMock).toHaveFetchedTimes(1, new URL('https://localhost/fmi/data/v1/databases/db/sessions')); + }); + + it('should sign in again after a failed sign in', async () => { + fetchMock.postOnce('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 400, + body: {messages: [{code: '0', message: 'error'}]}, + }); + + await expect(client.request('test')).rejects.toEqual(new FileMakerError('0', 'error')); + + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 200, + headers: {'X-FM-Data-Access-Token': 'foo'}, + body: {}, + }); + + fetchMock.get('https://localhost/fmi/data/v1/databases/db/test', { + status: 200, + headers: { + 'authorization': 'Bearer foo', + 'content-type': 'application/json', + }, + body: {response: 'test'}, + }); + + // a rejected sign in must not be left cached, or the client can never recover + await expect(client.request('test')).resolves.toBe('test'); + }); + + it('should reject every waiter when the shared sign in fails', async () => { + fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { + status: 400, + body: {messages: [{code: '0', message: 'error'}]}, + }); + + const results = await Promise.allSettled([client.request('test'), client.request('test')]); + + expect(results.map(result => result.status)).toEqual(['rejected', 'rejected']); + }); + it('should request a new token after 14 minutes', async () => { fetchMock.post('https://localhost/fmi/data/v1/databases/db/sessions', { status: 200,