Skip to content

Commit d25fe89

Browse files
authored
Merge pull request #1902 from maxmind/greg/stf-803
Throw WebServiceError instances and preserve error causes
2 parents 77349d2 + 717ed80 commit d25fe89

8 files changed

Lines changed: 395 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ CHANGELOG
77
* **Breaking** Dropped support for Node.js 18 and 20. The minimum supported version is
88
now 22.
99
* **Breaking** Dropped commonjs support. The package is now only available as an ES module.
10+
* **Breaking** Errors from the web service client are now thrown as
11+
`WebServiceError` instances, which extend `Error`, rather than as plain
12+
objects. The `code`, `error`, `status`, and `url` properties are preserved,
13+
so existing field access continues to work, but the thrown value is now an
14+
`Error`. The original error is now preserved as the standard `cause`
15+
property (for example, the network error behind a `FETCH_ERROR`). The
16+
`WebServiceError` and `ArgumentError` classes and the `WebServiceClientError`
17+
type are now exported from the package.
1018
* Added the input `/device/tracking_token`. This is the token generated by
1119
the [Device Tracking Add-on](https://dev.maxmind.com/minfraud/track-devices)
1220
for explicit device linking. You may provide this by providing

README.md

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,23 @@ client.insights(transaction).then(insightsResponse => ...);
7474
client.factors(transaction).then(factorsResponse => ...);
7575
```
7676

77-
If the request fails, an error object will be returned in the catch in the form
78-
of:
77+
If the request fails, the returned promise rejects with a `WebServiceError`.
78+
This extends the built-in `Error` and has the following shape:
7979

8080
```js
8181
{
8282
code: string
8383
error: string
84+
status?: number
8485
url: string
86+
cause?: unknown // the underlying error, when one exists
8587
}
8688
```
8789

90+
`error` is also available as the standard `Error` `message`, and when the
91+
failure was caused by another error (for example, a network failure), the
92+
original error is available as `cause`.
93+
8894
### Reporting a transaction using the Report Transactions API
8995

9096
MaxMind encourages the use of this API, as data received through this channel
@@ -115,25 +121,23 @@ See the API documentation for more details.
115121

116122
If the request succeeds, no data is returned in the Promise.
117123

118-
If the request fails, an error object will be returned in the catch in the
119-
form of:
120-
121-
```js
122-
{
123-
code: string
124-
error: string
125-
url: string
126-
}
127-
```
124+
If the request fails, the returned promise rejects with a `WebServiceError`
125+
(see above for its shape).
128126

129127
## Errors and Exceptions
130128

131129
Thrown by the request and transaction models:
132130
* `ArgumentError` - Thrown when invalid data is passed to the Transaction
133131
and Transaction property constructors.
134132

133+
Web service failures reject with a `WebServiceError`, which extends `Error`.
134+
It exposes `code`, `error`, an optional `status`, `url`, and, when the failure
135+
was caused by another error, the standard `cause` property. Both
136+
`ArgumentError` and `WebServiceError` (along with the `WebServiceClientError`
137+
type) are exported from the package.
138+
135139
In addition to the [response errors](https://dev.maxmind.com/minfraud/api-documentation/responses/?lang=en#errors)
136-
returned by the web API, we also return:
140+
returned by the web API, we also return these `code` values:
137141

138142
```js
139143
{
@@ -162,6 +166,10 @@ returned by the web API, we also return:
162166
}
163167
```
164168

169+
For `FETCH_ERROR`, the `error` message includes the underlying failure reason
170+
(for example, a DNS or connection error) when one is available, and the
171+
original error is also attached as `cause`.
172+
165173
## Example
166174

167175
```js

src/errors.spec.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { ArgumentError, WebServiceError } from './errors.js';
2+
3+
describe('WebServiceError', () => {
4+
it('is an Error instance', () => {
5+
const err = new WebServiceError({
6+
code: 'FETCH_ERROR',
7+
error: 'something went wrong',
8+
url: 'https://example.com',
9+
});
10+
11+
expect(err).toBeInstanceOf(Error);
12+
expect(err).toBeInstanceOf(WebServiceError);
13+
expect(err.name).toBe('WebServiceError');
14+
});
15+
16+
it('exposes code, error, status, and url', () => {
17+
const err = new WebServiceError({
18+
code: 'SERVER_ERROR',
19+
error: 'boom',
20+
status: 500,
21+
url: 'https://example.com',
22+
});
23+
24+
expect(err.code).toBe('SERVER_ERROR');
25+
expect(err.error).toBe('boom');
26+
expect(err.status).toBe(500);
27+
expect(err.url).toBe('https://example.com');
28+
});
29+
30+
it('uses the error string as the message', () => {
31+
const err = new WebServiceError({
32+
code: 'FETCH_ERROR',
33+
error: 'the message',
34+
url: 'https://example.com',
35+
});
36+
37+
expect(err.message).toBe('the message');
38+
expect(err.error).toBe(err.message);
39+
});
40+
41+
it('preserves the underlying cause', () => {
42+
const cause = new TypeError('fetch failed');
43+
const err = new WebServiceError(
44+
{
45+
code: 'FETCH_ERROR',
46+
error: 'TypeError - fetch failed',
47+
url: 'https://example.com',
48+
},
49+
{ cause }
50+
);
51+
52+
expect(err.cause).toBe(cause);
53+
});
54+
55+
it('leaves cause undefined when not provided', () => {
56+
const err = new WebServiceError({
57+
code: 'FETCH_ERROR',
58+
error: 'something went wrong',
59+
url: 'https://example.com',
60+
});
61+
62+
expect(err.cause).toBeUndefined();
63+
expect(JSON.parse(JSON.stringify(err))).not.toHaveProperty('cause');
64+
});
65+
66+
it('omits status when not provided', () => {
67+
const err = new WebServiceError({
68+
code: 'FETCH_ERROR',
69+
error: 'something went wrong',
70+
url: 'https://example.com',
71+
});
72+
73+
expect(err.status).toBeUndefined();
74+
expect(Object.prototype.hasOwnProperty.call(err, 'status')).toBe(false);
75+
});
76+
77+
it('retains code, error, status, and url as enumerable properties', () => {
78+
const err = new WebServiceError({
79+
code: 'SERVER_ERROR',
80+
error: 'boom',
81+
status: 500,
82+
url: 'https://example.com',
83+
});
84+
85+
const serialized = JSON.parse(JSON.stringify(err));
86+
expect(serialized).toMatchObject({
87+
code: 'SERVER_ERROR',
88+
error: 'boom',
89+
status: 500,
90+
url: 'https://example.com',
91+
});
92+
// `name` lives on the prototype, so it is not serialized.
93+
expect(serialized).not.toHaveProperty('name');
94+
});
95+
});
96+
97+
describe('ArgumentError', () => {
98+
it('is an Error instance', () => {
99+
const err = new ArgumentError('bad input');
100+
101+
expect(err).toBeInstanceOf(Error);
102+
expect(err.name).toBe('ArgumentError');
103+
expect(err.message).toBe('bad input');
104+
});
105+
});

src/errors.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,68 @@
1+
import { WebServiceClientError } from './types.js';
2+
13
/* tslint:disable:max-classes-per-file */
24
export class ArgumentError extends Error {
35
constructor(message: string) {
46
super(message);
57
this.name = this.constructor.name;
68
}
79
}
10+
11+
/**
12+
* An error returned by the minFraud web service or encountered while
13+
* communicating with it.
14+
*
15+
* In addition to the standard `Error` properties (including `cause`, which
16+
* holds the underlying error when one exists, such as the network error
17+
* behind a `FETCH_ERROR`), it exposes the `code`, `status`, and `url`
18+
* associated with the failure.
19+
*/
20+
export class WebServiceError extends Error implements WebServiceClientError {
21+
/**
22+
* The error code returned by the web service or generated by this client.
23+
*/
24+
public readonly code: string;
25+
/**
26+
* A human-readable description of the error. This is an alias of `message`,
27+
* retained for backward compatibility.
28+
*/
29+
public readonly error: string;
30+
/**
31+
* The HTTP status code, when the error originated from an HTTP response.
32+
*
33+
* Declared with `declare` so that no class field is emitted: the property is
34+
* absent (rather than set to `undefined`) when no status applies, e.g. on
35+
* network-level errors such as `FETCH_ERROR` and `NETWORK_TIMEOUT`.
36+
*/
37+
declare public readonly status?: number;
38+
/**
39+
* The URL that was being requested when the error occurred.
40+
*/
41+
public readonly url: string;
42+
43+
constructor(
44+
properties: {
45+
code: string;
46+
error: string;
47+
status?: number;
48+
url: string;
49+
},
50+
options?: { cause?: unknown }
51+
) {
52+
super(properties.error, options);
53+
this.code = properties.code;
54+
this.error = properties.error;
55+
// Only assign `status` when present so it stays genuinely optional: on
56+
// network-level errors (e.g. FETCH_ERROR, NETWORK_TIMEOUT) the instance
57+
// carries no `status` property at all, rather than `status: undefined`.
58+
if (properties.status !== undefined) {
59+
this.status = properties.status;
60+
}
61+
this.url = properties.url;
62+
}
63+
}
64+
65+
// Set `name` on the prototype rather than in the constructor. Using a string
66+
// literal keeps the name correct under minification, and keeping it off the
67+
// instance means it stays out of `JSON.stringify` output.
68+
WebServiceError.prototype.name = 'WebServiceError';

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as Constants from './constants.js';
2+
import { ArgumentError, WebServiceError } from './errors.js';
23
import Account from './request/account.js';
34
import Billing from './request/billing.js';
45
import CreditCard from './request/credit-card.js';
@@ -12,10 +13,12 @@ import Shipping from './request/shipping.js';
1213
import ShoppingCartItem from './request/shopping-cart-item.js';
1314
import Transaction from './request/transaction.js';
1415
import TransactionReport from './request/transaction-report.js';
16+
import { WebServiceClientError } from './types.js';
1517
import Client from './webServiceClient.js';
1618

1719
export {
1820
Account,
21+
ArgumentError,
1922
Billing,
2023
Client,
2124
Constants,
@@ -30,4 +33,7 @@ export {
3033
ShoppingCartItem,
3134
Transaction,
3235
TransactionReport,
36+
WebServiceError,
3337
};
38+
39+
export type { WebServiceClientError };

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,9 @@ export interface WebServiceClientError {
33
error: string;
44
status?: number;
55
url: string;
6+
/**
7+
* The underlying error that caused this one, when available (for example,
8+
* the network error behind a `FETCH_ERROR`).
9+
*/
10+
cause?: unknown;
611
}

0 commit comments

Comments
 (0)