From c94db28bb7a1db1caaeabde080a5884c8a58c810 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 24 Jun 2026 15:06:27 +0000 Subject: [PATCH 1/2] Throw WebServiceError instances and preserve error causes The web service client rejected with plain objects, which meant errors were not Error instances and the underlying cause (for example, the network error behind a FETCH_ERROR) was discarded. Introduce a WebServiceError class that extends Error and carries the existing code/error/status/url properties as own enumerable properties, plus the standard cause. Capture the cause at every throw site. Export WebServiceError, ArgumentError, and the WebServiceClientError type. STF-803 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 +++ README.md | 30 ++++---- src/errors.spec.ts | 105 +++++++++++++++++++++++++++ src/errors.ts | 61 ++++++++++++++++ src/index.ts | 6 ++ src/types.ts | 5 ++ src/webServiceClient.spec.ts | 135 +++++++++++++++++++++++++++++------ src/webServiceClient.ts | 86 +++++++++++++--------- 8 files changed, 369 insertions(+), 67 deletions(-) create mode 100644 src/errors.spec.ts 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..88889915 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 { 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..9887ead8 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,135 @@ 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', }); }); - it('handles general fetch errors', async () => { - const error = 'general error'; + 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', + }); + } + ); - const expected = { - code: 'FETCH_ERROR', - error: `Error - ${error}`, + 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)); + }); - expect.assertions(1); + 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'; nockInstance .post(fullPath('score'), score.request.basic) .basicAuth(auth) .replyWithError(error); - await expect(client.score(transaction)).rejects.toEqual(expected); + 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); }); test.each` @@ -1012,13 +1105,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..4417f3b9 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,23 @@ 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, - }; + throw new WebServiceError( + { + code: 'FETCH_ERROR', + error: `${error.name} - ${error.message}`, + url, + }, + { cause: error } + ); } if (!response.ok) { @@ -131,8 +140,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 +153,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, + }); } } From 717ed8040c470cdf3c6e16bfe9d0d13d280e5f3a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 24 Jun 2026 16:56:53 +0000 Subject: [PATCH 2/2] Include the underlying cause in the FETCH_ERROR message A FETCH_ERROR previously surfaced only "TypeError - fetch failed", with the real reason (DNS failure, refused connection, TLS error, etc.) hidden in the cause. Consumers that log only `code`/`error` never saw it. Append the underlying cause's message to the error string so the reason is visible without inspecting `cause`, which is still attached. STF-803 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++++ src/webServiceClient.spec.ts | 20 ++++++++++++++++++++ src/webServiceClient.ts | 7 ++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 88889915..585dd640 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,10 @@ returned by the web API, we also return these `code` values: } ``` +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/webServiceClient.spec.ts b/src/webServiceClient.spec.ts index 9887ead8..4b8b231b 100644 --- a/src/webServiceClient.spec.ts +++ b/src/webServiceClient.spec.ts @@ -1089,6 +1089,26 @@ describe('WebServiceClient', () => { expect((err.cause as Error).message).toBe(error); }); + 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(fetchError); + + 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` status | code | error ${400} | ${'IP_ADDRESS_INVALID'} | ${'ip address invalid'} diff --git a/src/webServiceClient.ts b/src/webServiceClient.ts index 4417f3b9..df507ee3 100644 --- a/src/webServiceClient.ts +++ b/src/webServiceClient.ts @@ -119,10 +119,15 @@ export default class WebServiceClient { { cause: error } ); } + // 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}`, + error: `${error.name} - ${error.message}${causeDetail}`, url, }, { cause: error }