From 8def5ff0e305754437ace4c5f430c39ab915fb90 Mon Sep 17 00:00:00 2001 From: Michael Wittig Date: Wed, 7 Aug 2024 20:48:20 +0200 Subject: [PATCH 1/2] feat: support request, read, data, and write timeouts --- README.md | 6 +- index.js | 237 +++++++++++++++++++++++++++++++++++++------------- test/index.js | 227 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 401 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 0a6567f..4817577 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,11 @@ d.meta((err, metadata) => { * `options` `` * `partSizeInMegabytes` `` (optional, defaults to uploaded part size) * `concurrency` `` - * `connectionTimeoutInMilliseconds` `` (optional, defaults to 3000) + * `requestTimeoutInMilliseconds` `` Maxium time for a request to complete from start to finish (optional, defaults to 300,000, 0 := no timeout) + * `connectionTimeoutInMilliseconds` `` Maximum time for a socket to connect (optional, defaults to 3,000, 0 := no timeout) + * `readTimeoutInMilliseconds` `` Maxium time to read the response body (optional, defaults to 300,000, 0 := no timeout) + * `dataTimeoutInMilliseconds` `` Maxium time between two data events while reading the response body (optional, defaults to 3,000, 0 := no timeout) + * `writeTimeoutInMilliseconds` `` Maxium time to write the request body (optional, defaults to 300,000, 0 := no timeout) * `v2AwsSdkCredentials` `` (optional) * `endpointHostname` `` (optional, defaults to s3.${region}.amazonaws.com) * `agent` `` (optional) diff --git a/index.js b/index.js index 365f6c0..f24b29c 100644 --- a/index.js +++ b/index.js @@ -22,7 +22,48 @@ const EVENT_NAMES = [ ]; exports.EVENT_NAMES = EVENT_NAMES; +class RequestTimeoutError extends Error { + constructor(message) { + super(message); + this.name = 'RequestTimeoutError'; + } +} +exports.RequestTimeoutError = RequestTimeoutError; + +class ConnectionTimeoutError extends Error { + constructor(message) { + super(message); + this.name = 'ConnectionTimeoutError'; + } +} +exports.ConnectionTimeoutError = ConnectionTimeoutError; + +class ReadTimeoutError extends Error { + constructor(message) { + super(message); + this.name = 'ReadTimeoutError'; + } +} +exports.ReadTimeoutError = ReadTimeoutError; + +class DataTimeoutError extends Error { + constructor(message) { + super(message); + this.name = 'DataTimeoutError'; + } +} +exports.DataTimeoutError = DataTimeoutError; + +class WriteTimeoutError extends Error { + constructor(message) { + super(message); + this.name = 'WriteTimeoutError'; + } +} +exports.WriteTimeoutError = WriteTimeoutError; + const RETRIABLE_NETWORK_ERROR_CODES = ['ECONNRESET', 'ENOTFOUND', 'ESOCKETTIMEDOUT', 'ETIMEDOUT', 'ECONNREFUSED', 'EHOSTUNREACH', 'EPIPE', 'EAI_AGAIN', 'EBUSY']; +const RETRIABLE_ERROR_NAMES = ['ConnectionTimeoutError', 'ReadTimeoutError', 'DataTimeoutError', 'WriteTimeoutError']; const AWS_CREDENTIALS_MAX_AGE_IN_MILLISECONDS = 4*60*1000; // From AWS docs: We make new credentials available at least five minutes before the expiration of the old credentials. const DNS_RECORD_MAX_AGE_IN_MILLISECONDS = 10*1000; @@ -57,14 +98,45 @@ function parseContentRange(contentRange) { } } -function request(nodemodule, options, body, cb) { - options.lookup = getDnsCache; - const req = nodemodule.request(options, (res) => { +function request(nodemodule, requestOptions, body, timeoutOptions, cb) { + requestOptions.lookup = getDnsCache; + if (timeoutOptions.connectionTimeoutInMilliseconds > 0) { + requestOptions.timeout = timeoutOptions.connectionTimeoutInMilliseconds; + } + let requestTimeoutId; + let dataTimeoutId; + let readTimeoutId; + let writeTimeoutId; + let cbcalled = false; + const clearTimeouts = () => { + clearTimeout(requestTimeoutId); + clearTimeout(dataTimeoutId); + clearTimeout(readTimeoutId); + clearTimeout(writeTimeoutId); + }; + const req = nodemodule.request(requestOptions, (res) => { + if (timeoutOptions.readTimeoutInMilliseconds > 0) { + readTimeoutId = setTimeout(() => { + clearTimeouts(); + res.destroy(new ReadTimeoutError()); + }, timeoutOptions.readTimeoutInMilliseconds); + } let size = ('content-length' in res.headers) ? parseInt(res.headers['content-length'], 10) : 0; const bodyChunks = ('content-length' in res.headers) ? null : []; const bodyBuffer = ('content-length' in res.headers) ? Buffer.allocUnsafe(size) : null; let bodyBufferOffset = 0; + const resetDataTimeout = () => { + if (timeoutOptions.dataTimeoutInMilliseconds > 0) { + clearTimeout(dataTimeoutId); + dataTimeoutId = setTimeout(() => { + clearTimeouts(); + res.destroy(new DataTimeoutError()); + }, timeoutOptions.dataTimeoutInMilliseconds); + } + }; + resetDataTimeout(); res.on('data', chunk => { + resetDataTimeout(); if (bodyChunks !== null) { bodyChunks.push(chunk); size += chunk.length; @@ -74,27 +146,49 @@ function request(nodemodule, options, body, cb) { } }); res.on('end', () => { - if (bodyChunks !== null) { - cb(null, res, Buffer.concat(bodyChunks)); - } else { - cb(null, res, bodyBuffer); + clearTimeouts(); + if (cbcalled === false) { + cbcalled = true; + if (bodyChunks !== null) { + cb(null, res, Buffer.concat(bodyChunks)); + } else { + cb(null, res, bodyBuffer); + } } }); }); req.once('error', (err) => { - cb(err); + clearTimeouts(); + if (cbcalled === false) { + cbcalled = true; + cb(err); + } }); req.once('timeout', () => { - req.abort(); + clearTimeouts(); + req.destroy(new ConnectionTimeoutError()); }); - if (Buffer.isBuffer(body)) { - req.write(body); + if (timeoutOptions.requestTimeoutInMilliseconds > 0) { + requestTimeoutId = setTimeout(() => { + req.destroy(new RequestTimeoutError()); + }, timeoutOptions.requestTimeoutInMilliseconds); + } + if (Buffer.isBuffer(body) && body.length > 0) { + if (timeoutOptions.writeTimeoutInMilliseconds > 0) { + writeTimeoutId = setTimeout(() => { + clearTimeouts(); + req.destroy(new WriteTimeoutError()); + }, timeoutOptions.writeTimeoutInMilliseconds); + } + req.write(body, () => { + clearTimeout(writeTimeoutId); + }); } req.end(); } exports.request = request; -function retryrequest(nodemodule, requestOptions, body, retryOptions, cb) { +function retryrequest(nodemodule, requestOptions, body, retryOptions, timeoutOptions, cb) { let attempt = 1; const retry = (err) => { attempt++; @@ -117,9 +211,9 @@ function retryrequest(nodemodule, requestOptions, body, retryOptions, cb) { } }; const req = () => { - request(nodemodule, requestOptions, body, (err, res, body) => { + request(nodemodule, requestOptions, body, timeoutOptions, (err, res, body) => { if (err) { - if (RETRIABLE_NETWORK_ERROR_CODES.includes(err.code)) { + if (RETRIABLE_NETWORK_ERROR_CODES.includes(err.code) || RETRIABLE_ERROR_NAMES.includes(err.name)) { retry(err); } else { cb(err); @@ -147,15 +241,14 @@ function retryrequest(nodemodule, requestOptions, body, retryOptions, cb) { } exports.retryrequest = retryrequest; -function imdsRequest(method, path, headers, timeout, cb) { +function imdsRequest(method, path, headers, timeoutOptions, cb) { const options = { hostname: '169.254.169.254', method, path, - headers, - timeout + headers }; - retryrequest(http, options, undefined, {maxAttempts: 3}, (err, res, body) => { + retryrequest(http, options, undefined, {maxAttempts: 3}, timeoutOptions, (err, res, body) => { if (err) { cb(err); } else { @@ -171,9 +264,9 @@ function imdsRequest(method, path, headers, timeout, cb) { }); } -function refreshImdsToken(timeout) { +function refreshImdsToken(timeoutOptions) { imdsTokenCache = new Promise((resolve, reject) => { - imdsRequest('PUT', '/latest/api/token', {'X-aws-ec2-metadata-token-ttl-seconds': `${IMDS_TOKEN_TTL_IN_SECONDS}`}, timeout, (err, token) => { + imdsRequest('PUT', '/latest/api/token', {'X-aws-ec2-metadata-token-ttl-seconds': `${IMDS_TOKEN_TTL_IN_SECONDS}`}, timeoutOptions, (err, token) => { if (err) { reject(err); } else { @@ -187,14 +280,14 @@ function refreshImdsToken(timeout) { return imdsTokenCache; } -function getImdsToken(timeout, cb) { +function getImdsToken(timeoutOptions, cb) { if (imdsTokenCache === undefined) { - refreshImdsToken(timeout).then(({token}) => cb(null, token)).catch(cb); + refreshImdsToken(timeoutOptions).then(({token}) => cb(null, token)).catch(cb); } else { imdsTokenCache.then(({token, cachedAt}) => { if ((Date.now()-cachedAt) > IMDS_TOKEN_MAX_AGE_IN_MILLISECONDS) { imdsTokenCache = undefined; - getImdsToken(timeout, cb); + getImdsToken(timeoutOptions, cb); } else { cb(null, token); } @@ -202,23 +295,31 @@ function getImdsToken(timeout, cb) { } } -function imds(path, timeout, cb) { - getImdsToken(timeout, (err, token) => { +function imds(path, timeoutOptions, cb) { + getImdsToken(timeoutOptions, (err, token) => { if (err) { cb(err); } else { - imdsRequest('GET', path, {'X-aws-ec2-metadata-token': token}, timeout, cb); + imdsRequest('GET', path, {'X-aws-ec2-metadata-token': token}, timeoutOptions, cb); } }); } exports.imds = imds; -function refreshAwsRegion(timeout) { +const DEFAULT_IMDS_TIMEOUT_OPTIONS = { + requestTimeoutInMilliseconds: 3000, + connectionTimeoutInMilliseconds: 1000, + readTimeoutInMilliseconds: 1000, + dataTimeoutInMilliseconds: 1000, + writeTimeoutInMilliseconds: 1000 +}; + +function refreshAwsRegion() { imdsRegionCache = new Promise((resolve, reject) => { if ('AWS_REGION' in process.env) { resolve(process.env.AWS_REGION); } else { - imds('/latest/dynamic/instance-identity/document', timeout, (err, body) => { + imds('/latest/dynamic/instance-identity/document', DEFAULT_IMDS_TIMEOUT_OPTIONS, (err, body) => { if (err) { reject(err); } else { @@ -231,15 +332,15 @@ function refreshAwsRegion(timeout) { return imdsRegionCache; } -function getAwsRegion(timeout, cb) { +function getAwsRegion(cb) { if (imdsRegionCache === undefined) { - refreshAwsRegion(timeout).then(region => cb(null, region)).catch(cb); + refreshAwsRegion().then(region => cb(null, region)).catch(cb); } else { imdsRegionCache.then(region => cb(null, region)).catch(cb); } } -function refreshAwsCredentials(timeout) { +function refreshAwsCredentials() { imdsCredentialsCache = new Promise((resolve, reject) => { if ('AWS_ACCESS_KEY_ID' in process.env && 'AWS_SECRET_ACCESS_KEY' in process.env) { const credentials = { @@ -251,12 +352,12 @@ function refreshAwsCredentials(timeout) { } resolve(credentials); } else { - imds('/latest/meta-data/iam/security-credentials/', timeout, (err, body) => { + imds('/latest/meta-data/iam/security-credentials/', DEFAULT_IMDS_TIMEOUT_OPTIONS, (err, body) => { if (err) { reject(err); } else { const roleName = body.trim(); - imds(`/latest/meta-data/iam/security-credentials/${roleName}`, timeout, (err, body) => { + imds(`/latest/meta-data/iam/security-credentials/${roleName}`, DEFAULT_IMDS_TIMEOUT_OPTIONS, (err, body) => { if (err) { reject(err); } else { @@ -276,7 +377,7 @@ function refreshAwsCredentials(timeout) { return imdsCredentialsCache; } -function getAwsCredentials({timeout, v2AwsSdkCredentials}, cb) { +function getAwsCredentials(v2AwsSdkCredentials, cb) { if (!(v2AwsSdkCredentials === undefined || v2AwsSdkCredentials === null)) { v2AwsSdkCredentials.get((err) => { if (err) { @@ -293,12 +394,12 @@ function getAwsCredentials({timeout, v2AwsSdkCredentials}, cb) { } }); } else if (imdsCredentialsCache === undefined) { - refreshAwsCredentials(timeout).then(credentials => cb(null, credentials)).catch(cb); + refreshAwsCredentials().then(credentials => cb(null, credentials)).catch(cb); } else { imdsCredentialsCache.then(credentials => { if ((Date.now()-credentials.cachedAt) > AWS_CREDENTIALS_MAX_AGE_IN_MILLISECONDS) { imdsCredentialsCache = undefined; - getAwsCredentials({timeout, v2AwsSdkCredentials}, cb); + getAwsCredentials(v2AwsSdkCredentials, cb); } else { cb(null, credentials); } @@ -359,10 +460,10 @@ function getDnsCache(hostname, options, cb) { } } -function getHostname(timeout, endpointHostname, cb) { +function getHostname(endpointHostname, cb) { if (endpointHostname === undefined || endpointHostname === null) { // TODO Switch to virtual-hosted–style requests as soon as bucket names with a dot are supported (https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html) - getAwsRegion(timeout, (err, region) => { + getAwsRegion((err, region) => { if (err) { cb(err); } else { @@ -374,19 +475,6 @@ function getHostname(timeout, endpointHostname, cb) { } } -function mapTimeout(connectionTimeoutInMilliseconds) { - if (connectionTimeoutInMilliseconds === undefined || connectionTimeoutInMilliseconds === null) { - return 3000; - } - if (connectionTimeoutInMilliseconds < 0) { - throw new Error('connectionTimeoutInMilliseconds >= 0'); - } - if (connectionTimeoutInMilliseconds === 0) { - return undefined; - } - return connectionTimeoutInMilliseconds; -} - function mapPartSizeInBytes(partSizeInMegabytes) { if (partSizeInMegabytes === undefined || partSizeInMegabytes === null) { return null; @@ -405,7 +493,7 @@ function escapeKey(string) { // source https://github.com/aws/aws-sdk-js/blob/64 }); } -function getObject({Bucket, Key, VersionId, PartNumber, Range}, {timeout, v2AwsSdkCredentials, endpointHostname, agent}, cb) { +function getObject({Bucket, Key, VersionId, PartNumber, Range}, {requestTimeoutInMilliseconds, connectionTimeoutInMilliseconds, readTimeoutInMilliseconds, dataTimeoutInMilliseconds, writeTimeoutInMilliseconds, v2AwsSdkCredentials, endpointHostname, agent}, cb) { const ac = new AbortController(); const qs = {}; const headers = {}; @@ -418,11 +506,11 @@ function getObject({Bucket, Key, VersionId, PartNumber, Range}, {timeout, v2AwsS if (Range !== undefined && Range !== null) { headers.Range = Range; } - getHostname(timeout, endpointHostname, (err, hostname) => { + getHostname(endpointHostname, (err, hostname) => { if (err) { cb(err); } else { - getAwsCredentials({timeout, v2AwsSdkCredentials}, (err, credentials) => { + getAwsCredentials(v2AwsSdkCredentials, (err, credentials) => { if (err) { cb(err); } else { @@ -433,10 +521,9 @@ function getObject({Bucket, Key, VersionId, PartNumber, Range}, {timeout, v2AwsS headers, service: 's3', signal: ac.signal, - timeout, agent }, credentials); - retryrequest(https, options, undefined, {maxAttempts: 5}, (err, res, body) => { + retryrequest(https, options, undefined, {maxAttempts: 5}, {requestTimeoutInMilliseconds, connectionTimeoutInMilliseconds, readTimeoutInMilliseconds, dataTimeoutInMilliseconds, writeTimeoutInMilliseconds}, (err, res, body) => { if (err) { cb(err); } else { @@ -494,16 +581,46 @@ function getObject({Bucket, Key, VersionId, PartNumber, Range}, {timeout, v2AwsS return ac; } -exports.download = ({bucket, key, version}, {partSizeInMegabytes, concurrency, connectionTimeoutInMilliseconds, v2AwsSdkCredentials, endpointHostname, agent}) => { +exports.download = ({bucket, key, version}, {partSizeInMegabytes, concurrency, requestTimeoutInMilliseconds, connectionTimeoutInMilliseconds, readTimeoutInMilliseconds, dataTimeoutInMilliseconds, writeTimeoutInMilliseconds, v2AwsSdkCredentials, endpointHostname, agent}) => { if (concurrency < 1) { throw new Error('concurrency > 0'); } + + if (requestTimeoutInMilliseconds === undefined || requestTimeoutInMilliseconds === null) { + requestTimeoutInMilliseconds = 300000; + } else if (requestTimeoutInMilliseconds < 0) { + throw new Error('requestTimeoutInMilliseconds >= 0'); + } + + if (connectionTimeoutInMilliseconds === undefined || connectionTimeoutInMilliseconds === null) { + connectionTimeoutInMilliseconds = 3000; + } else if (connectionTimeoutInMilliseconds < 0) { + throw new Error('connectionTimeoutInMilliseconds >= 0'); + } + + if (readTimeoutInMilliseconds === undefined || readTimeoutInMilliseconds === null) { + readTimeoutInMilliseconds = 300000; + } else if (readTimeoutInMilliseconds < 0) { + throw new Error('readTimeoutInMilliseconds >= 0'); + } + + if (dataTimeoutInMilliseconds === undefined || dataTimeoutInMilliseconds === null) { + dataTimeoutInMilliseconds = 3000; + } else if (dataTimeoutInMilliseconds < 0) { + throw new Error('dataTimeoutInMilliseconds >= 0'); + } + + if (writeTimeoutInMilliseconds === undefined || writeTimeoutInMilliseconds === null) { + writeTimeoutInMilliseconds = 300000; + } else if (writeTimeoutInMilliseconds < 0) { + throw new Error('writeTimeoutInMilliseconds >= 0'); + } + if (!(v2AwsSdkCredentials === undefined || v2AwsSdkCredentials === null)) { if (typeof v2AwsSdkCredentials.get !== 'function') { throw new Error('invalid v2AwsSdkCredentials'); } } - const timeout = mapTimeout(connectionTimeoutInMilliseconds); const emitter = new EventEmitter(); const partSizeInBytes = mapPartSizeInBytes(partSizeInMegabytes); @@ -573,7 +690,7 @@ exports.download = ({bucket, key, version}, {partSizeInMegabytes, concurrency, c const endByte = Math.min(startByte+partSizeInBytes-1, bytesToDownload-1); // inclusive params.Range = `bytes=${startByte}-${endByte}`; } - const req = getObject(params, {timeout, v2AwsSdkCredentials, endpointHostname, agent}, (err, data) => { + const req = getObject(params, {requestTimeoutInMilliseconds, connectionTimeoutInMilliseconds, readTimeoutInMilliseconds, dataTimeoutInMilliseconds, writeTimeoutInMilliseconds, v2AwsSdkCredentials, endpointHostname, agent}, (err, data) => { delete partsDownloading[partNo]; if (err) { cb(err); @@ -624,7 +741,7 @@ exports.download = ({bucket, key, version}, {partSizeInMegabytes, concurrency, c const endByte = partSizeInBytes-1; // inclusive params.Range = `bytes=0-${endByte}`; } - partsDownloading[1] = getObject(params, {timeout, v2AwsSdkCredentials, endpointHostname, agent}, (err, data) => { + partsDownloading[1] = getObject(params, {requestTimeoutInMilliseconds, connectionTimeoutInMilliseconds, readTimeoutInMilliseconds, dataTimeoutInMilliseconds, writeTimeoutInMilliseconds, v2AwsSdkCredentials, endpointHostname, agent}, (err, data) => { delete partsDownloading[1]; if (err) { if (err.code === 'InvalidRange') { diff --git a/test/index.js b/test/index.js index cd0026f..cf97d68 100644 --- a/test/index.js +++ b/test/index.js @@ -5,7 +5,7 @@ const fs = require('node:fs'); const mockfs = require('mock-fs'); const nock = require('nock'); const AWS = require('aws-sdk'); -const {clearCache, request, imds, download} = require('../index.js'); +const {clearCache, request, retryrequest, imds, download} = require('../index.js'); function nockPart(partSize, partNumber, parts, bytes, hostname, optionalTimeout) { console.log(`nockPart(${partSize}, ${partNumber}, ${parts}, ${bytes}, ${hostname}, ${optionalTimeout})`); @@ -94,7 +94,7 @@ describe('index', () => { hostname: 'google.com', method: 'GET', path: '/' - }, null, (err, res) => { + }, null, {}, (err, res) => { if (err) { done(err); } else { @@ -115,12 +115,12 @@ describe('index', () => { it('with content-length = 0', (done) => { nock('http://localhost') .get('/test') - .reply(204, '', {'Content-Type': 'application/text', 'Content-Length': '0'}); + .reply(200, '', {'Content-Type': 'application/text', 'Content-Length': '0'}); request(http, { hostname: 'localhost', method: 'GET', path: '/test' - }, null, (err, res, body) => { + }, null, {}, (err, res, body) => { if (err) { done(err); } else { @@ -137,7 +137,7 @@ describe('index', () => { hostname: 'localhost', method: 'GET', path: '/test' - }, null, (err, res, body) => { + }, null, {}, (err, res, body) => { if (err) { done(err); } else { @@ -146,6 +146,23 @@ describe('index', () => { } }); }); + it('with content-length < than real response body', (done) => { + nock('http://localhost') + .get('/test') + .reply(200, 'Hello world!', {'Content-Type': 'application/text', 'Content-Length': '11'}); + request(http, { + hostname: 'localhost', + method: 'GET', + path: '/test' + }, null, {}, (err, res, body) => { + if (err) { + done(err); + } else { + assert.deepStrictEqual(body.toString('utf8'), 'Hello world'); + done(); + } + }); + }); it('without content-length', (done) => { nock('http://localhost') .get('/test') @@ -154,7 +171,7 @@ describe('index', () => { hostname: 'localhost', method: 'GET', path: '/test' - }, null, (err, res, body) => { + }, null, {}, (err, res, body) => { if (err) { done(err); } else { @@ -163,8 +180,202 @@ describe('index', () => { } }); }); + it('without response body', (done) => { + nock('http://localhost') + .get('/test') + .reply(204); + request(http, { + hostname: 'localhost', + method: 'GET', + path: '/test' + }, null, {}, (err, res, body) => { + if (err) { + done(err); + } else { + assert.deepStrictEqual(body.toString('utf8'), ''); + done(); + } + }); + }); + describe('timeout', () => { + it('request', (done) => { + nock('http://localhost') + .post('/api') + .delayBody(200) + .reply(204); + + request(http, { + hostname: 'localhost', + method: 'POST', + path: '/api' + }, Buffer.alloc(10), {requestTimeoutInMilliseconds: 100, connectionTimeoutInMilliseconds: 0, readTimeoutInMilliseconds: 0, dataTimeoutInMilliseconds: 0, writeTimeoutInMilliseconds: 0}, (err) => { + if (err) { + assert.ok(nock.isDone()); + assert.deepStrictEqual(err.name, 'RequestTimeoutError'); + done(); + } else { + assert.fail(); + } + }); + }); + it('connection', (done) => { + nock('http://localhost') + .post('/api') + .delayConnection(200) + .reply(204); + + request(http, { + hostname: 'localhost', + method: 'POST', + path: '/api' + }, Buffer.alloc(10), {requestTimeoutInMilliseconds: 0, connectionTimeoutInMilliseconds: 100, readTimeoutInMilliseconds: 0, dataTimeoutInMilliseconds: 0, writeTimeoutInMilliseconds: 0}, (err) => { + if (err) { + assert.ok(nock.isDone()); + assert.deepStrictEqual(err.name, 'ConnectionTimeoutError'); + done(); + } else { + assert.fail(); + } + }); + }); + it('read', (done) => { + nock('http://localhost') + .get('/test') + .delayBody(200) + .reply(200, 'Hello world!', {'Content-Type': 'application/text', 'Content-Length': '12'}); + request(http, { + hostname: 'localhost', + method: 'GET', + path: '/test' + }, null, {requestTimeoutInMilliseconds: 0, connectionTimeoutInMilliseconds: 0, readTimeoutInMilliseconds: 100, dataTimeoutInMilliseconds: 0, writeTimeoutInMilliseconds: 0}, (err) => { + if (err) { + assert.ok(nock.isDone()); + assert.deepStrictEqual(err.name, 'ReadTimeoutError'); + done(); + } else { + assert.fail(); + } + }); + }); + it('data', (done) => { + nock('http://localhost') + .get('/test') + .delayBody(200) + .reply(200, 'Hello world!', {'Content-Type': 'application/text', 'Content-Length': '12'}); + request(http, { + hostname: 'localhost', + method: 'GET', + path: '/test' + }, null, {requestTimeoutInMilliseconds: 0, connectionTimeoutInMilliseconds: 100, readTimeoutInMilliseconds: 0, dataTimeoutInMilliseconds: 100, writeTimeoutInMilliseconds: 0}, (err) => { + if (err) { + assert.ok(nock.isDone()); + assert.deepStrictEqual(err.name, 'DataTimeoutError'); + done(); + } else { + assert.fail(); + } + }); + }); + // it('write', () => {}); // not testable with nock + }); + }); + describe('retryrequest', () => { + before(() => { + nock.disableNetConnect(); + }); + after(() => { + nock.enableNetConnect(); + }); + afterEach(() => { + nock.cleanAll(); + clearCache(); + }); + it('body', (done) => { + const responseBody = 'test'; + nock('http://localhost') + .post('/api') + .reply(200, responseBody, {'content-length': Buffer.byteLength(responseBody, 'utf8')}); + + retryrequest(http, { + hostname: 'localhost', + method: 'POST', + path: '/api' + }, Buffer.alloc(10), {maxAttempts: 3}, {}, (err, res, body) => { + if (err) { + done(err); + } else { + assert.ok(nock.isDone()); + assert.deepStrictEqual(res.statusCode, 200); + assert.deepStrictEqual(body.length, 4); + done(); + } + }); + }); + it('no body', (done) => { + nock('http://localhost') + .post('/api') + .reply(204); + + retryrequest(http, { + hostname: 'localhost', + method: 'POST', + path: '/api' + }, Buffer.alloc(10), {maxAttempts: 3}, {}, (err, res, body) => { + if (err) { + done(err); + } else { + assert.ok(nock.isDone()); + assert.deepStrictEqual(res.statusCode, 204); + assert.deepStrictEqual(body.length, 0); + done(); + } + }); + }); + describe('timeout', () => { + it('connection', (done) => { + nock('http://localhost') + .post('/api') + .times(3) + .delayConnection(200) + .reply(204); + + retryrequest(http, { + hostname: 'localhost', + method: 'POST', + path: '/api' + }, Buffer.alloc(10), {maxAttempts: 3}, {connectionTimeoutInMilliseconds: 100}, (err) => { + if (err) { + assert.ok(nock.isDone()); + assert.deepStrictEqual(err.name, 'ConnectionTimeoutError'); + done(); + } else { + assert.fail(); + } + }); + }); + it('data', (done) => { + nock('http://localhost') + .post('/api') + .times(3) + .delayBody(200) + .reply(204); + + retryrequest(http, { + hostname: 'localhost', + method: 'POST', + path: '/api' + }, Buffer.alloc(10), {maxAttempts: 3}, {dataTimeoutInMilliseconds: 100}, (err) => { + if (err) { + assert.ok(nock.isDone()); + assert.deepStrictEqual(err.name, 'DataTimeoutError'); + done(); + } else { + assert.fail(); + } + }); + }); + }); }); - // TODO test retryrequest describe('imds', () => { before(() => { nock.disableNetConnect(); @@ -1144,7 +1355,7 @@ describe('index', () => { (err) => { if (err) { assert.ok(nock.isDone()); - assert.deepStrictEqual(err.code, 'ECONNRESET'); + assert.deepStrictEqual(err.name, 'ConnectionTimeoutError'); done(); } else { assert.fail(); From c359c298252750c52f02631475f5edc2fd910718 Mon Sep 17 00:00:00 2001 From: Michael Wittig Date: Wed, 7 Aug 2024 21:16:37 +0200 Subject: [PATCH 2/2] clean up --- index.js | 6 +++++- test/index.js | 45 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index f24b29c..704de30 100644 --- a/index.js +++ b/index.js @@ -226,7 +226,11 @@ function retryrequest(nodemodule, requestOptions, body, retryOptions, timeoutOpt err.body = body; retry(err); } else { - const err = new Error(`status code: ${res.statusCode}, content-type: ${res.headers['content-type']}`); + let message = `status code: ${res.statusCode}`; + if (res.headers['content-type']) { + message += `, content-type: ${res.headers['content-type']}`; + } + const err = new Error(message); err.statusCode = res.statusCode; err.body = body; retry(err); diff --git a/test/index.js b/test/index.js index cf97d68..4c47599 100644 --- a/test/index.js +++ b/test/index.js @@ -331,6 +331,49 @@ describe('index', () => { } }); }); + it('retry recover', (done) => { + nock('http://localhost') + .post('/api') + .times(2) + .reply(429) + .post('/api') + .reply(204); + + retryrequest(http, { + hostname: 'localhost', + method: 'POST', + path: '/api' + }, Buffer.alloc(10), {maxAttempts: 3}, {}, (err, res, body) => { + if (err) { + done(err); + } else { + assert.ok(nock.isDone()); + assert.deepStrictEqual(res.statusCode, 204); + assert.deepStrictEqual(body.length, 0); + done(); + } + }); + }); + it('retry fail', (done) => { + nock('http://localhost') + .post('/api') + .times(3) + .reply(429); + + retryrequest(http, { + hostname: 'localhost', + method: 'POST', + path: '/api' + }, Buffer.alloc(10), {maxAttempts: 3}, {}, (err) => { + if (err) { + assert.ok(nock.isDone()); + assert.deepStrictEqual(err.message, 'status code: 429'); + done(); + } else { + assert.fail(); + } + }); + }); describe('timeout', () => { it('connection', (done) => { nock('http://localhost') @@ -1520,7 +1563,7 @@ describe('index', () => { if (err) { assert.ok(nock.isDone()); assert.deepStrictEqual(err.statusCode, 500); - assert.deepStrictEqual(err.message, 'status code: 500, content-type: undefined'); + assert.deepStrictEqual(err.message, 'status code: 500'); done(); } else { assert.fail();