diff --git a/CHANGELOG.md b/CHANGELOG.md index b51b9ec3..8ae3a06e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,13 @@ CHANGELOG ========= -7.1.0 +8.0.0 ----------------- +* **Breaking** Internal webservice calls now use Node's built-in `fetch` instead of `http`. This + will affect users who are on unsupported versions of Node, specifically Node 17 and below. +* Two new error codes have been added: `NETWORK_TIMEOUT` and `FETCH_ERROR`, second of which is returned + when there's a `fetch` related error that could not be handled by other errors. * The minFraud Factors subscores have been deprecated. They will be removed in March 2025. Please see [our release notes](https://dev.maxmind.com/minfraud/release-notes/2024/#deprecation-of-risk-factor-scoressubscores) for more information. diff --git a/README.md b/README.md index 41a4c6cc..5f931076 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,16 @@ returned by the web API, we also return: code: 'INVALID_RESPONSE_BODY', error: } + +{ + code: 'NETWORK_TIMEOUT', + error: +} + +{ + code: 'FETCH_ERROR', + error: +} ``` ## Example diff --git a/package-lock.json b/package-lock.json index b0741030..3f63464e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "gh-pages": "^6.0.0", "globals": "^15.9.0", "jest": "^29.5.0", - "nock": "^14.0.0-beta.15", + "nock": "^14.0.0-beta.16", "prettier": "^3.0.0", "ts-jest": "^29.1.0", "typedoc": "^0.26.3", diff --git a/package.json b/package.json index d3957441..6934ccd5 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "gh-pages": "^6.0.0", "globals": "^15.9.0", "jest": "^29.5.0", - "nock": "^14.0.0-beta.15", + "nock": "^14.0.0-beta.16", "prettier": "^3.0.0", "ts-jest": "^29.1.0", "typedoc": "^0.26.3", diff --git a/src/webServiceClient.spec.ts b/src/webServiceClient.spec.ts index 1a2ef0a2..87d5eed9 100644 --- a/src/webServiceClient.spec.ts +++ b/src/webServiceClient.spec.ts @@ -879,12 +879,33 @@ describe('WebServiceClient', () => { }); describe('error handling', () => { + afterEach(() => { + nock.cleanAll(); + }); + const transaction = new Transaction({ device: new Device({ ipAddress: '1.1.1.1', }), }); + it('handles timeouts', () => { + const timeoutClient = new Client(auth.user, auth.pass, 10); + expect.assertions(1); + + nockInstance + .post(fullPath('score'), score.request.basic) + .basicAuth(auth) + .delayConnection(100) + .reply(200, score.response.full); + + return expect(timeoutClient.score(transaction)).rejects.toEqual({ + code: 'NETWORK_TIMEOUT', + error: 'The request timed out', + url: baseUrl + fullPath('score'), + }); + }); + it('handles 5xx level errors', () => { expect.assertions(1); @@ -930,15 +951,12 @@ describe('WebServiceClient', () => { }); }); - it('handles general http.request errors', () => { - const error = { - code: 'FOO_ERR', - message: 'some foo error', - }; + it('handles general fetch errors', () => { + const error = 'general error'; const expected = { - code: error.code, - error: error.message, + code: 'FETCH_ERROR', + error: `Error - ${error}`, url: baseUrl + fullPath('score'), }; diff --git a/src/webServiceClient.ts b/src/webServiceClient.ts index ca546ed8..810717bd 100644 --- a/src/webServiceClient.ts +++ b/src/webServiceClient.ts @@ -1,5 +1,3 @@ -import * as http from 'http'; -import * as https from 'https'; import { version } from '../package.json'; import Transaction from './request/transaction'; import TransactionReport from './request/transaction-report'; @@ -13,6 +11,11 @@ interface ResponseError { type servicePath = 'factors' | 'insights' | 'score' | 'transactions/report'; +const invalidResponseBody = { + code: 'INVALID_RESPONSE_BODY', + error: 'Received an invalid or unparseable response body', +}; + export default class WebServiceClient { private accountID: string; private host: string; @@ -65,7 +68,7 @@ export default class WebServiceClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any modelClass?: any ): Promise; - private responseFor( + private async responseFor( path: servicePath, postData: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -74,97 +77,94 @@ export default class WebServiceClient { const parsedPath = `/minfraud/v2.0/${path}`; const url = `https://${this.host}${parsedPath}`; - const options = { - auth: `${this.accountID}:${this.licenseKey}`, + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + const options: RequestInit = { + body: postData, headers: { Accept: 'application/json', - 'Content-Length': Buffer.byteLength(postData), + Authorization: + 'Basic ' + + Buffer.from(`${this.accountID}:${this.licenseKey}`).toString( + 'base64' + ), + 'Content-Length': Buffer.byteLength(postData).toString(), 'Content-Type': 'application/json', 'User-Agent': `minfraud-api-node/${version}`, }, - host: this.host, method: 'POST', - path: parsedPath, - timeout: this.timeout, + signal: controller.signal, }; - return new Promise((resolve, reject) => { - const req = https.request(options, (response) => { - let data = ''; - - response.on('data', (chunk) => { - data += chunk; - }); - - response.on('end', () => { - if (response.statusCode && response.statusCode === 204) { - return resolve(); - } - - try { - data = JSON.parse(data); - } catch { - return reject(this.handleError({}, response, url)); - } - - if (response.statusCode && response.statusCode !== 200) { - return reject( - this.handleError(data as ResponseError, response, url) - ); - } - - return resolve(new modelClass(data)); - }); - }); - - req.on('error', (err: NodeJS.ErrnoException) => { - return reject({ - code: err.code, - error: err.message, - url, - } as WebServiceClientError); - }); - - req.write(postData); - - req.end(); - }); + let data; + try { + const response = await fetch(url, options); + + if (!response.ok) { + return Promise.reject(await this.handleError(response, url)); + } + + if (response.status === 204) { + return Promise.resolve(); + } + data = await response.json(); + } catch (err) { + const error = err as TypeError; + switch (error.name) { + case 'AbortError': + return Promise.reject({ + code: 'NETWORK_TIMEOUT', + error: 'The request timed out', + url, + }); + case 'SyntaxError': + return Promise.reject({ + ...invalidResponseBody, + url, + }); + default: + return Promise.reject({ + code: 'FETCH_ERROR', + error: `${error.name} - ${error.message}`, + url, + }); + } + } finally { + clearTimeout(timeoutId); + } + return new modelClass(data); } - private handleError( - data: ResponseError, - response: http.IncomingMessage, + private async handleError( + response: Response, url: string - ): WebServiceClientError { - if ( - response.statusCode && - response.statusCode >= 500 && - response.statusCode < 600 - ) { + ): Promise { + if (response.status && response.status >= 500 && response.status < 600) { return { code: 'SERVER_ERROR', - error: `Received a server error with HTTP status code: ${response.statusCode}`, + error: `Received a server error with HTTP status code: ${response.status}`, url, }; } - if ( - response.statusCode && - (response.statusCode < 400 || response.statusCode >= 600) - ) { + if (response.status && (response.status < 400 || response.status >= 600)) { return { code: 'HTTP_STATUS_CODE_ERROR', - error: `Received an unexpected HTTP status code: ${response.statusCode}`, + error: `Received an unexpected HTTP status code: ${response.status}`, url, }; } - if (!data.code || !data.error) { - return { - code: 'INVALID_RESPONSE_BODY', - error: 'Received an invalid or unparseable response body', - url, - }; + let data; + try { + data = (await response.json()) as ResponseError; + + if (!data.code || !data.error) { + return { ...invalidResponseBody, url }; + } + } catch { + return { ...invalidResponseBody, url }; } return { ...data, url } as WebServiceClientError;