Skip to content

Commit

Permalink
Use fetch
Browse files Browse the repository at this point in the history
  • Loading branch information
kevcenteno committed Nov 19, 2024
1 parent cd26658 commit e0f5400
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 81 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,16 @@ returned by the web API, we also return:
code: 'INVALID_RESPONSE_BODY',
error: <string>
}

{
code: 'NETWORK_TIMEOUT',
error: <string>
}

{
code: 'FETCH_ERROR',
error: <string>
}
```

## Example
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 25 additions & 7 deletions src/webServiceClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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'),
};

Expand Down
142 changes: 71 additions & 71 deletions src/webServiceClient.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -65,7 +68,7 @@ export default class WebServiceClient {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
modelClass?: any
): Promise<T>;
private responseFor(
private async responseFor(
path: servicePath,
postData: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -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<WebServiceClientError> {
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;
Expand Down

0 comments on commit e0f5400

Please sign in to comment.