Skip to content
Open
Show file tree
Hide file tree
Changes from all 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: 13 additions & 0 deletions src/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type ContainerDownload = {
export default class Client {
private token: string | null = null;
private lastCall = 0;
private tokenRequest: Promise<string> | null = null;

public constructor(
private readonly uri: string,
Expand Down Expand Up @@ -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<string> {
const headers = {
'Content-Type': 'application/json',
'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`,
Expand Down
61 changes: 61 additions & 0 deletions test/Client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading