diff --git a/src/common/exceptions/unprocessable-entity.exception.ts b/src/common/exceptions/unprocessable-entity.exception.ts index 5be338471..a2ded4ff1 100644 --- a/src/common/exceptions/unprocessable-entity.exception.ts +++ b/src/common/exceptions/unprocessable-entity.exception.ts @@ -13,11 +13,13 @@ export class UnprocessableEntityException constructor({ code, + error, errors, message, requestID, }: { code?: string; + error?: string; errors?: UnprocessableEntityError[]; message?: string; requestID: string; @@ -28,6 +30,8 @@ export class UnprocessableEntityException if (message) { this.message = message; + } else if (error) { + this.message = `Error: ${error}`; } if (code) { diff --git a/src/common/net/fetch-client.ts b/src/common/net/fetch-client.ts index 0ed0c12fc..9ebe45f98 100644 --- a/src/common/net/fetch-client.ts +++ b/src/common/net/fetch-client.ts @@ -307,7 +307,7 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { message: `Request timeout after ${timeout}ms`, response: { status: 408, - headers: {}, + headers: new Headers(), data: { error: 'Request timeout' }, }, }); diff --git a/src/workos.spec.ts b/src/workos.spec.ts index 6b75f9219..df726ea75 100644 --- a/src/workos.spec.ts +++ b/src/workos.spec.ts @@ -316,6 +316,81 @@ describe('WorkOS', () => { }); }); + describe('when the api responds with a 422 and an error but no message', () => { + it('surfaces the error in the exception message', async () => { + fetchOnce( + { error: 'Maximum unique keys reached' }, + { status: 422, headers: { 'X-Request-ID': 'a-request-id' } }, + ); + + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + + await expect(workos.post('/path', {})).rejects.toMatchObject({ + message: 'Error: Maximum unique keys reached', + requestID: 'a-request-id', + status: 422, + }); + }); + + it('prefers the message over the error when both are present', async () => { + fetchOnce( + { error: 'an-error', message: 'A message' }, + { status: 422, headers: { 'X-Request-ID': 'a-request-id' } }, + ); + + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + + await expect(workos.post('/path', {})).rejects.toMatchObject({ + message: 'A message', + status: 422, + }); + }); + + it('still formats field errors when present', async () => { + fetchOnce( + { + error: 'an-error', + errors: [{ field: 'a-field', code: 'a-requirement' }], + }, + { status: 422, headers: { 'X-Request-ID': 'a-request-id' } }, + ); + + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + + await expect(workos.post('/path', {})).rejects.toMatchObject({ + message: 'The following requirement must be met:\n\ta-requirement\n', + status: 422, + }); + }); + }); + + describe('when the request times out', () => { + it('surfaces the timeout rather than a headers TypeError', async () => { + const neverResolvingFetch = jest.fn( + (_url: any, init: any) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; + reject(abortError); + }); + }) as any, + ); + + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { + timeout: 1, + fetchFn: neverResolvingFetch as any, + }); + + await expect(workos.post('/path', {})).rejects.toMatchObject({ + name: 'OauthException', + status: 408, + requestID: '', + message: 'Error: Request timeout', + }); + }, 20000); + }); + describe('when the api responds with a 500 and no error/error_description', () => { it('throws an GenericServerException', async () => { fetchOnce( diff --git a/src/workos.ts b/src/workos.ts index 6bceec035..133541d1f 100644 --- a/src/workos.ts +++ b/src/workos.ts @@ -397,7 +397,7 @@ export class WorkOS { if (response) { const { status, data, headers } = response; - const requestID = headers['X-Request-ID'] ?? ''; + const requestID = headers.get('X-Request-ID') ?? ''; const { code, error_description: errorDescription, @@ -416,6 +416,7 @@ export class WorkOS { case 422: { throw new UnprocessableEntityException({ code, + error, errors, message, requestID,