diff --git a/CHANGELOG.md b/CHANGELOG.md index f6451131..c6e13198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ CHANGELOG * **Breaking** Dropped support for Node.js 18 and 20. The minimum supported version is now 22. * **Breaking** Dropped commonjs support. The package is now only available as an ES module. +* **Breaking** Errors from the web service client are now thrown as + `WebServiceError` instances, which extend `Error`, rather than as plain + objects. The `code`, `error`, `status`, and `url` properties are preserved, + so existing field access continues to work, but the thrown value is now an + `Error`. The original error is now preserved as the standard `cause` + property (for example, the network error behind a `FETCH_ERROR`). The + `WebServiceError` and `ArgumentError` classes and the `WebServiceClientError` + type are now exported from the package. * Added the input `/device/tracking_token`. This is the token generated by the [Device Tracking Add-on](https://dev.maxmind.com/minfraud/track-devices) for explicit device linking. You may provide this by providing diff --git a/README.md b/README.md index 23ccd101..585dd640 100644 --- a/README.md +++ b/README.md @@ -74,17 +74,23 @@ client.insights(transaction).then(insightsResponse => ...); client.factors(transaction).then(factorsResponse => ...); ``` -If the request fails, an error object will be returned in the catch in the form -of: +If the request fails, the returned promise rejects with a `WebServiceError`. +This extends the built-in `Error` and has the following shape: ```js { code: string error: string + status?: number url: string + cause?: unknown // the underlying error, when one exists } ``` +`error` is also available as the standard `Error` `message`, and when the +failure was caused by another error (for example, a network failure), the +original error is available as `cause`. + ### Reporting a transaction using the Report Transactions API MaxMind encourages the use of this API, as data received through this channel @@ -115,16 +121,8 @@ See the API documentation for more details. If the request succeeds, no data is returned in the Promise. -If the request fails, an error object will be returned in the catch in the -form of: - -```js -{ - code: string - error: string - url: string -} -``` +If the request fails, the returned promise rejects with a `WebServiceError` +(see above for its shape). ## Errors and Exceptions @@ -132,8 +130,14 @@ Thrown by the request and transaction models: * `ArgumentError` - Thrown when invalid data is passed to the Transaction and Transaction property constructors. +Web service failures reject with a `WebServiceError`, which extends `Error`. +It exposes `code`, `error`, an optional `status`, `url`, and, when the failure +was caused by another error, the standard `cause` property. Both +`ArgumentError` and `WebServiceError` (along with the `WebServiceClientError` +type) are exported from the package. + In addition to the [response errors](https://dev.maxmind.com/minfraud/api-documentation/responses/?lang=en#errors) -returned by the web API, we also return: +returned by the web API, we also return these `code` values: ```js { @@ -162,6 +166,10 @@ returned by the web API, we also return: } ``` +For `FETCH_ERROR`, the `error` message includes the underlying failure reason +(for example, a DNS or connection error) when one is available, and the +original error is also attached as `cause`. + ## Example ```js diff --git a/src/errors.spec.ts b/src/errors.spec.ts new file mode 100644 index 00000000..5fdefcc1 --- /dev/null +++ b/src/errors.spec.ts @@ -0,0 +1,105 @@ +import { ArgumentError, WebServiceError } from './errors.js'; + +describe('WebServiceError', () => { + it('is an Error instance', () => { + const err = new WebServiceError({ + code: 'FETCH_ERROR', + error: 'something went wrong', + url: 'https://example.com', + }); + + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(WebServiceError); + expect(err.name).toBe('WebServiceError'); + }); + + it('exposes code, error, status, and url', () => { + const err = new WebServiceError({ + code: 'SERVER_ERROR', + error: 'boom', + status: 500, + url: 'https://example.com', + }); + + expect(err.code).toBe('SERVER_ERROR'); + expect(err.error).toBe('boom'); + expect(err.status).toBe(500); + expect(err.url).toBe('https://example.com'); + }); + + it('uses the error string as the message', () => { + const err = new WebServiceError({ + code: 'FETCH_ERROR', + error: 'the message', + url: 'https://example.com', + }); + + expect(err.message).toBe('the message'); + expect(err.error).toBe(err.message); + }); + + it('preserves the underlying cause', () => { + const cause = new TypeError('fetch failed'); + const err = new WebServiceError( + { + code: 'FETCH_ERROR', + error: 'TypeError - fetch failed', + url: 'https://example.com', + }, + { cause } + ); + + expect(err.cause).toBe(cause); + }); + + it('leaves cause undefined when not provided', () => { + const err = new WebServiceError({ + code: 'FETCH_ERROR', + error: 'something went wrong', + url: 'https://example.com', + }); + + expect(err.cause).toBeUndefined(); + expect(JSON.parse(JSON.stringify(err))).not.toHaveProperty('cause'); + }); + + it('omits status when not provided', () => { + const err = new WebServiceError({ + code: 'FETCH_ERROR', + error: 'something went wrong', + url: 'https://example.com', + }); + + expect(err.status).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(err, 'status')).toBe(false); + }); + + it('retains code, error, status, and url as enumerable properties', () => { + const err = new WebServiceError({ + code: 'SERVER_ERROR', + error: 'boom', + status: 500, + url: 'https://example.com', + }); + + const serialized = JSON.parse(JSON.stringify(err)); + expect(serialized).toMatchObject({ + code: 'SERVER_ERROR', + error: 'boom', + status: 500, + url: 'https://example.com', + }); + // `name` lives on the prototype, so it is not serialized. + expect(serialized).not.toHaveProperty('name'); + }); +}); + +describe('ArgumentError', () => { + it('is an Error instance', () => { + const err = new ArgumentError('bad input'); + + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('ArgumentError'); + expect(err.message).toBe('bad input'); + }); +}); diff --git a/src/errors.ts b/src/errors.ts index 50267fab..9ca2473c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,5 @@ +import { WebServiceClientError } from './types.js'; + /* tslint:disable:max-classes-per-file */ export class ArgumentError extends Error { constructor(message: string) { @@ -5,3 +7,62 @@ export class ArgumentError extends Error { this.name = this.constructor.name; } } + +/** + * An error returned by the minFraud web service or encountered while + * communicating with it. + * + * In addition to the standard `Error` properties (including `cause`, which + * holds the underlying error when one exists, such as the network error + * behind a `FETCH_ERROR`), it exposes the `code`, `status`, and `url` + * associated with the failure. + */ +export class WebServiceError extends Error implements WebServiceClientError { + /** + * The error code returned by the web service or generated by this client. + */ + public readonly code: string; + /** + * A human-readable description of the error. This is an alias of `message`, + * retained for backward compatibility. + */ + public readonly error: string; + /** + * The HTTP status code, when the error originated from an HTTP response. + * + * Declared with `declare` so that no class field is emitted: the property is + * absent (rather than set to `undefined`) when no status applies, e.g. on + * network-level errors such as `FETCH_ERROR` and `NETWORK_TIMEOUT`. + */ + declare public readonly status?: number; + /** + * The URL that was being requested when the error occurred. + */ + public readonly url: string; + + constructor( + properties: { + code: string; + error: string; + status?: number; + url: string; + }, + options?: { cause?: unknown } + ) { + super(properties.error, options); + this.code = properties.code; + this.error = properties.error; + // Only assign `status` when present so it stays genuinely optional: on + // network-level errors (e.g. FETCH_ERROR, NETWORK_TIMEOUT) the instance + // carries no `status` property at all, rather than `status: undefined`. + if (properties.status !== undefined) { + this.status = properties.status; + } + this.url = properties.url; + } +} + +// Set `name` on the prototype rather than in the constructor. Using a string +// literal keeps the name correct under minification, and keeping it off the +// instance means it stays out of `JSON.stringify` output. +WebServiceError.prototype.name = 'WebServiceError'; diff --git a/src/index.ts b/src/index.ts index 519bd6da..316e9859 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import * as Constants from './constants.js'; +import { ArgumentError, WebServiceError } from './errors.js'; import Account from './request/account.js'; import Billing from './request/billing.js'; import CreditCard from './request/credit-card.js'; @@ -12,10 +13,12 @@ import Shipping from './request/shipping.js'; import ShoppingCartItem from './request/shopping-cart-item.js'; import Transaction from './request/transaction.js'; import TransactionReport from './request/transaction-report.js'; +import { WebServiceClientError } from './types.js'; import Client from './webServiceClient.js'; export { Account, + ArgumentError, Billing, Client, Constants, @@ -30,4 +33,7 @@ export { ShoppingCartItem, Transaction, TransactionReport, + WebServiceError, }; + +export type { WebServiceClientError }; diff --git a/src/types.ts b/src/types.ts index b172f068..4d21304b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,4 +3,9 @@ export interface WebServiceClientError { error: string; status?: number; url: string; + /** + * The underlying error that caused this one, when available (for example, + * the network error behind a `FETCH_ERROR`). + */ + cause?: unknown; } diff --git a/src/webServiceClient.spec.ts b/src/webServiceClient.spec.ts index bf0b5e52..4b8b231b 100644 --- a/src/webServiceClient.spec.ts +++ b/src/webServiceClient.spec.ts @@ -10,6 +10,7 @@ import { Device, Transaction, TransactionReport, + WebServiceError, } from './index.js'; import * as webRecords from './response/web-records.js'; @@ -912,9 +913,44 @@ describe('WebServiceClient', () => { }), }); + const expectError = async ( + promise: Promise, + expected: { + code: string; + error: string; + status?: number; + url: string; + // Whether the error's `cause` should be present or absent. + cause?: 'defined' | 'undefined'; + } + ): Promise => { + const { cause, ...fields } = expected; + const err = await promise.then( + () => { + throw new Error('expected the request to reject'); + }, + (e: unknown) => e + ); + expect(err).toBeInstanceOf(WebServiceError); + const webServiceError = err as WebServiceError; + expect(webServiceError).toMatchObject(fields); + // The `error` property is retained as an alias of `message`. + expect(webServiceError.message).toBe(expected.error); + // Assert `status` explicitly (toMatchObject ignores extra own + // properties), so a regression leaking a `status` onto a network-error + // path is caught. + expect(webServiceError.status).toBe(expected.status); + if (cause === 'defined') { + expect(webServiceError.cause).toBeDefined(); + } + if (cause === 'undefined') { + expect(webServiceError.cause).toBeUndefined(); + } + return webServiceError; + }; + it('handles timeouts', async () => { const timeoutClient = new Client(auth.user, auth.pass, 10); - expect.assertions(1); nockInstance .post(fullPath('score'), score.request.basic) @@ -922,78 +958,155 @@ describe('WebServiceClient', () => { .delay(100) .reply(200, score.response.full); - await expect(timeoutClient.score(transaction)).rejects.toEqual({ + // The underlying abort/timeout error is preserved as the cause. + await expectError(timeoutClient.score(transaction), { code: 'NETWORK_TIMEOUT', error: 'The request timed out', url: baseUrl + fullPath('score'), + cause: 'defined', }); }); it('handles 5xx level errors', async () => { - expect.assertions(1); - nockInstance .post(fullPath('score'), score.request.basic) .basicAuth(auth) .reply(500); - await expect(client.score(transaction)).rejects.toEqual({ + await expectError(client.score(transaction), { code: 'SERVER_ERROR', error: 'Received a server error with HTTP status code: 500', status: 500, url: baseUrl + fullPath('score'), + cause: 'undefined', }); }); it('handles 3xx level errors', async () => { - expect.assertions(1); - nockInstance .post(fullPath('score'), score.request.basic) .basicAuth(auth) .reply(300); - await expect(client.score(transaction)).rejects.toEqual({ + await expectError(client.score(transaction), { code: 'HTTP_STATUS_CODE_ERROR', error: 'Received an unexpected HTTP status code: 300', status: 300, url: baseUrl + fullPath('score'), + cause: 'undefined', }); }); it('handles errors with unknown payload', async () => { - expect.assertions(1); - nockInstance .post(fullPath('score'), score.request.basic) .basicAuth(auth) .reply(401, { foo: 'bar' }); - await expect(client.score(transaction)).rejects.toEqual({ + await expectError(client.score(transaction), { + code: 'INVALID_RESPONSE_BODY', + error: 'Received an invalid or unparseable response body', + status: 401, + url: baseUrl + fullPath('score'), + cause: 'undefined', + }); + }); + + test.each` + description | payload + ${'a non-string code'} | ${{ code: 123, error: 'an error' }} + ${'a non-string error'} | ${{ code: 'A_CODE', error: {} }} + ${'empty string fields'} | ${{ code: '', error: '' }} + `( + 'treats $description as an invalid response body', + async ({ payload }) => { + nockInstance + .post(fullPath('score'), score.request.basic) + .basicAuth(auth) + .reply(400, payload); + + await expectError(client.score(transaction), { + code: 'INVALID_RESPONSE_BODY', + error: 'Received an invalid or unparseable response body', + status: 400, + url: baseUrl + fullPath('score'), + cause: 'undefined', + }); + } + ); + + it('preserves the cause for invalid response bodies', async () => { + nockInstance + .post(fullPath('score'), score.request.basic) + .basicAuth(auth) + .reply(200, 'this is not json'); + + const err = await expectError(client.score(transaction), { + code: 'INVALID_RESPONSE_BODY', + error: 'Received an invalid or unparseable response body', + url: baseUrl + fullPath('score'), + cause: 'defined', + }); + // The JSON parse error is preserved as the cause. (instanceof Error is + // unreliable here: under --experimental-vm-modules the parse error is + // created in a different realm than this test file.) + expect((err.cause as Error).message).toEqual(expect.any(String)); + }); + + it('preserves the cause when an error response body is not JSON', async () => { + nockInstance + .post(fullPath('score'), score.request.basic) + .basicAuth(auth) + .reply(401, 'this is not json'); + + const err = await expectError(client.score(transaction), { code: 'INVALID_RESPONSE_BODY', error: 'Received an invalid or unparseable response body', status: 401, url: baseUrl + fullPath('score'), + cause: 'defined', }); + // The parse failure on a non-2xx response is preserved as the cause. + expect((err.cause as Error).message).toEqual(expect.any(String)); }); it('handles general fetch errors', async () => { const error = 'general error'; - const expected = { + nockInstance + .post(fullPath('score'), score.request.basic) + .basicAuth(auth) + .replyWithError(error); + + const err = await expectError(client.score(transaction), { code: 'FETCH_ERROR', error: `Error - ${error}`, url: baseUrl + fullPath('score'), - }; + cause: 'defined', + }); + // The original fetch error is preserved as the cause. + expect(err.cause).toBeInstanceOf(Error); + expect((err.cause as Error).message).toBe(error); + }); - expect.assertions(1); + it('includes the underlying cause in the FETCH_ERROR message', async () => { + const fetchError = Object.assign(new TypeError('fetch failed'), { + cause: new Error('connect ECONNREFUSED 1.2.3.4:443'), + }); nockInstance .post(fullPath('score'), score.request.basic) .basicAuth(auth) - .replyWithError(error); + .replyWithError(fetchError); - await expect(client.score(transaction)).rejects.toEqual(expected); + const err = await expectError(client.score(transaction), { + code: 'FETCH_ERROR', + error: 'TypeError - fetch failed: connect ECONNREFUSED 1.2.3.4:443', + url: baseUrl + fullPath('score'), + cause: 'defined', + }); + // The original error (with its own cause) is still attached. + expect((err.cause as Error).message).toBe('fetch failed'); }); test.each` @@ -1012,13 +1125,13 @@ describe('WebServiceClient', () => { .post(fullPath('score'), score.request.basic) .basicAuth(auth) .reply(status, { code, error }); - expect.assertions(1); - await expect(client.score(transaction)).rejects.toEqual({ + await expectError(client.score(transaction), { code, error, status, url: baseUrl + fullPath('score'), + cause: 'undefined', }); }); }); diff --git a/src/webServiceClient.ts b/src/webServiceClient.ts index edca9ecb..df507ee3 100644 --- a/src/webServiceClient.ts +++ b/src/webServiceClient.ts @@ -1,13 +1,8 @@ import packageInfo from '../package.json' with { type: 'json' }; +import { WebServiceError } from './errors.js'; import Transaction from './request/transaction.js'; import TransactionReport from './request/transaction-report.js'; import * as models from './response/models/index.js'; -import { WebServiceClientError } from './types.js'; - -interface ResponseError { - code?: string; - error?: string; -} type servicePath = 'factors' | 'insights' | 'score' | 'transactions/report'; @@ -16,6 +11,14 @@ const invalidResponseBody = { error: 'Received an invalid or unparseable response body', }; +const isErrorBody = (data: unknown): data is { code: string; error: string } => + typeof data === 'object' && + data !== null && + typeof (data as Record).code === 'string' && + (data as Record).code !== '' && + typeof (data as Record).error === 'string' && + (data as Record).error !== ''; + export default class WebServiceClient { private accountID: string; private host: string; @@ -93,10 +96,10 @@ export default class WebServiceClient { /* We handle two kinds of errors here: 1. Network errors, such as timeouts or CORS errors. These will be caught - by the catch block and rethrown as a WebServiceClientError. + by the catch block and rethrown as a WebServiceError. 2. Errors returned by the MaxMind web service, namely non-200 status codes. These will be caught by the handleBadServerResponse method and rethrown/rejected as a - WebServiceClientError. + WebServiceError. */ let response: Response; try { @@ -107,17 +110,28 @@ export default class WebServiceClient { ? err : new Error(String(err)); if (error.name === 'TimeoutError') { - throw { - code: 'NETWORK_TIMEOUT', - error: 'The request timed out', - url, - }; + throw new WebServiceError( + { + code: 'NETWORK_TIMEOUT', + error: 'The request timed out', + url, + }, + { cause: error } + ); } - throw { - code: 'FETCH_ERROR', - error: `${error.name} - ${error.message}`, - url, - }; + // Include the underlying cause's message in the error string so the + // reason (e.g. a DNS or connection failure) is visible to consumers that + // only log `code`/`error`, not just available via `cause`. + const causeDetail = + error.cause instanceof Error ? `: ${error.cause.message}` : ''; + throw new WebServiceError( + { + code: 'FETCH_ERROR', + error: `${error.name} - ${error.message}${causeDetail}`, + url, + }, + { cause: error } + ); } if (!response.ok) { @@ -131,8 +145,11 @@ export default class WebServiceClient { let data; try { data = await response.json(); - } catch { - throw { ...invalidResponseBody, url }; + } catch (err) { + throw new WebServiceError( + { ...invalidResponseBody, url }, + { cause: err } + ); } return new modelClass(data); @@ -141,38 +158,46 @@ export default class WebServiceClient { private async handleBadServerResponse( response: Response, url: string - ): Promise { + ): Promise { const status = response.status; if (status && status >= 500 && status < 600) { - return { + return new WebServiceError({ code: 'SERVER_ERROR', error: `Received a server error with HTTP status code: ${status}`, status, url, - }; + }); } if (status && (status < 400 || status >= 600)) { - return { + return new WebServiceError({ code: 'HTTP_STATUS_CODE_ERROR', error: `Received an unexpected HTTP status code: ${status}`, status, url, - }; + }); } - let data; + let data: unknown; try { - data = (await response.json()) as ResponseError; + data = await response.json(); + } catch (err) { + return new WebServiceError( + { ...invalidResponseBody, status, url }, + { cause: err } + ); + } - if (!data.code || !data.error) { - return { ...invalidResponseBody, status, url }; - } - } catch { - return { ...invalidResponseBody, status, url }; + if (!isErrorBody(data)) { + return new WebServiceError({ ...invalidResponseBody, status, url }); } - return { ...data, status, url } as WebServiceClientError; + return new WebServiceError({ + code: data.code, + error: data.error, + status, + url, + }); } }