Files
dualfroz 017b847750 fix: retry refused connections on Node.js 24.20+
Since Node.js 24.20 (nodejs/node#64847) the ClientRequest end() callback
fires with the connection error after the request has already emitted
error and been destroyed. That late ERR_SOCKET_CLOSED_BEFORE_CONNECTION was
forwarded to _final, destroying the stream and cancelling the retry that the
error handler had scheduled, so ECONNREFUSED was no longer retried and the
generic error surfaced instead of the real one.

Ignore the end() callback error once the request is destroyed; the real
error already drives the retry state machine. Write errors like EPIPE that
arrive while the request is still alive keep reaching the retry classifier.

Fixes #2469
2026-08-28 03:35:54 +02:00

1416 lines
33 KiB
TypeScript

import {EventEmitter} from 'node:events';
import {PassThrough as PassThroughStream} from 'node:stream';
import type {AddressInfo, Socket} from 'node:net';
import http from 'node:http';
import https from 'node:https';
import test from 'ava';
import is from '@sindresorhus/is';
import type {Handler} from 'express';
import getStream from 'get-stream';
import {pEvent} from 'p-event';
import got, {HTTPError, RequestError, TimeoutError} from '../source/index.js';
import type Request from '../source/core/index.js';
import withServer from './helpers/with-server.js';
const retryAfterOn413 = 2;
const socketTimeout = 300;
const handler413: Handler = (_request, response) => {
response.writeHead(413, {
'Retry-After': retryAfterOn413,
});
response.end();
};
const createSocketTimeoutStream = (url: string): http.ClientRequest => {
if (url.includes('https:')) {
return https.request(url, {
timeout: 1,
});
}
return http.request(url, {
timeout: socketTimeout,
});
};
test('works on timeout', withServer, async (t, server, got) => {
let knocks = 0;
server.get('/', (_request, response) => {
response.end('who`s there?');
});
t.is((await got({
timeout: {
socket: socketTimeout,
},
request(...arguments_: [
string | URL | http.RequestOptions,
(http.RequestOptions | ((response: http.IncomingMessage) => void))?,
((response: http.IncomingMessage) => void)?,
]) {
if (knocks === 1) {
// @ts-expect-error Overload error
return http.request(...arguments_);
}
knocks++;
return createSocketTimeoutStream(server.url);
},
})).body, 'who`s there?');
});
test('retry function gets iteration count', withServer, async (t, server, got) => {
let knocks = 0;
server.get('/', (_request, response) => {
if (knocks++ === 1) {
response.end('who`s there?');
return;
}
response.statusCode = 500;
response.end();
});
await got({
retry: {
calculateDelay({attemptCount}) {
t.true(is.number(attemptCount));
return attemptCount < 2 ? 1 : 0;
},
},
});
});
test('setting to `0` disables retrying', async t => {
let capturedAttemptCount: number | undefined;
await t.throwsAsync(got('https://example.com', {
timeout: {socket: socketTimeout},
retry: {
calculateDelay({attemptCount}) {
capturedAttemptCount = attemptCount;
return 0;
},
},
request: () => createSocketTimeoutStream('https://example.com'),
}), {
instanceOf: TimeoutError,
message: `Timeout awaiting 'socket' for ${socketTimeout}ms`,
});
t.is(capturedAttemptCount, 1);
});
test('custom retries', withServer, async (t, server, got) => {
server.get('/', (_request, response) => {
response.statusCode = 500;
response.end();
});
let hasTried = false;
const error = await t.throwsAsync<HTTPError>(got({
throwHttpErrors: true,
retry: {
calculateDelay({attemptCount}) {
if (attemptCount === 1) {
hasTried = true;
return 1;
}
return 0;
},
methods: [
'GET',
],
statusCodes: [
500,
],
},
}));
t.is(error?.response.statusCode, 500);
t.true(hasTried);
});
test('custom retries async', withServer, async (t, server, got) => {
server.get('/', (_request, response) => {
response.statusCode = 500;
response.end();
});
let hasTried = false;
const error = await t.throwsAsync<HTTPError>(got({
throwHttpErrors: true,
retry: {
async calculateDelay({attemptCount}) {
await new Promise(resolve => {
setTimeout(resolve, 1000);
});
if (attemptCount === 1) {
hasTried = true;
return 1;
}
return 0;
},
methods: [
'GET',
],
statusCodes: [
500,
],
},
}));
t.is(error?.response.statusCode, 500);
t.true(hasTried);
});
test('custom error codes', async t => {
const errorCode = 'OH_SNAP';
let capturedErrorCode: string | undefined;
const error = await t.throwsAsync<Error & {code: typeof errorCode}>(got('https://example.com', {
request() {
const emitter = new EventEmitter() as http.ClientRequest;
(emitter as any).end = () => {};
(emitter as any).destroy = () => {};
(emitter as any).writable = true;
(emitter as any).writableEnded = false;
const error = new Error('Snap!');
(error as Error & {code: typeof errorCode}).code = errorCode;
setTimeout(() => {
emitter.emit('error', error);
});
return emitter;
},
retry: {
calculateDelay({error}) {
capturedErrorCode = error.code;
return 0;
},
methods: [
'GET',
],
errorCodes: [
errorCode,
],
},
}));
t.is(capturedErrorCode, errorCode);
t.is(error?.code, errorCode);
});
test('respects 413 Retry-After', withServer, async (t, server, got) => {
let lastTried413access = Date.now();
server.get('/', (_request, response) => {
response.writeHead(413, {
'Retry-After': retryAfterOn413,
});
response.end((Date.now() - lastTried413access).toString());
lastTried413access = Date.now();
});
const {statusCode, body} = await got({
throwHttpErrors: false,
retry: {
limit: 1,
},
});
t.is(statusCode, 413);
t.true(Number(body) >= retryAfterOn413 * 1000);
});
test('respects 413 Retry-After with RFC-1123 timestamp', withServer, async (t, server, got) => {
let lastTried413TimestampAccess: string;
server.get('/', (_request, response) => {
const date = (new Date(Date.now() + (retryAfterOn413 * 1000))).toUTCString();
response.writeHead(413, {
'Retry-After': date,
});
response.end(lastTried413TimestampAccess);
lastTried413TimestampAccess = date;
});
const {statusCode, body} = await got({
throwHttpErrors: false,
retry: {
limit: 1,
},
});
t.is(statusCode, 413);
t.true(Date.now() >= Date.parse(body));
});
test('doesn\'t retry on 413 with empty statusCodes and methods', withServer, async (t, server, got) => {
server.get('/', handler413);
const {statusCode, retryCount} = await got({
throwHttpErrors: false,
retry: {
limit: 1,
statusCodes: [],
methods: [],
},
});
t.is(statusCode, 413);
t.is(retryCount, 0);
});
test('doesn\'t retry on 413 with empty methods', withServer, async (t, server, got) => {
server.get('/', handler413);
const {statusCode, retryCount} = await got({
throwHttpErrors: false,
retry: {
limit: 1,
statusCodes: [413],
methods: [],
},
});
t.is(statusCode, 413);
t.is(retryCount, 0);
});
test('doesn\'t retry on 413 without Retry-After header', withServer, async (t, server, got) => {
server.get('/', (_request, response) => {
response.statusCode = 413;
response.end();
});
const {retryCount} = await got({
throwHttpErrors: false,
});
t.is(retryCount, 0);
});
test('retries on 503 without Retry-After header', withServer, async (t, server, got) => {
server.get('/', (_request, response) => {
response.statusCode = 503;
response.end();
});
const {retryCount} = await got({
throwHttpErrors: false,
retry: {
limit: 1,
},
});
t.is(retryCount, 1);
});
test('doesn\'t retry on streams', withServer, async (t, server, got) => {
server.get('/', () => {});
// @ts-expect-error Error tests
const stream = got.stream({
timeout: {
request: 1,
},
retry: {
calculateDelay() {
t.fail('Retries on streams');
},
},
});
await t.throwsAsync(pEvent(stream, 'response'));
});
test('doesn\'t retry if Retry-After header is greater than maxRetryAfter', withServer, async (t, server, got) => {
server.get('/', handler413);
const {retryCount} = await got({
retry: {maxRetryAfter: 1000},
throwHttpErrors: false,
});
t.is(retryCount, 0);
});
test('doesn\'t retry when set to 0', withServer, async (t, server, got) => {
server.get('/', handler413);
const {statusCode, retryCount} = await got({
throwHttpErrors: false,
retry: {
limit: 0,
},
});
t.is(statusCode, 413);
t.is(retryCount, 0);
});
test('works when defaults.options.retry is a number', withServer, async (t, server, got) => {
server.get('/', handler413);
const instance = got.extend({
retry: {
limit: 2,
},
});
const {retryCount} = await instance({
throwHttpErrors: false,
});
t.is(retryCount, 2);
});
test('retry function can throw', withServer, async (t, server, got) => {
server.get('/', handler413);
const error = 'Simple error';
await t.throwsAsync(got({
retry: {
calculateDelay() {
throw new Error(error);
},
},
}), {message: error});
});
test('does not retry on POST', withServer, async (t, server, got) => {
server.post('/', () => {});
let retried = false;
await t.throwsAsync(got.post({
timeout: {
request: 200,
},
hooks: {
beforeRetry: [
() => {
retried = true;
},
],
},
}), {instanceOf: TimeoutError});
t.false(retried, 'Retries on POST requests');
});
test('retries QUERY with JSON body by default', withServer, async (t, server, got) => {
const attempts: Array<{method: string; body: string; contentType: string | undefined}> = [];
const payload = {
query: true,
};
server.all('/', async (request, response) => {
const body = await getStream(request);
attempts.push({
method: request.method,
body,
contentType: request.headers['content-type'],
});
if (attempts.length === 1) {
response.statusCode = 500;
response.end();
return;
}
response.end(body);
});
const {body, retryCount} = await got.query({
json: payload,
retry: {
limit: 1,
},
});
t.is(retryCount, 1);
t.deepEqual(JSON.parse(body), payload);
t.deepEqual(attempts, [
{
method: 'QUERY',
body: '{"query":true}',
contentType: 'application/json',
},
{
method: 'QUERY',
body: '{"query":true}',
contentType: 'application/json',
},
]);
});
test('does not break on redirect', withServer, async (t, server, got) => {
server.get('/', (_request, response) => {
response.statusCode = 500;
response.end();
});
let tries = 0;
server.get('/redirect', (_request, response) => {
tries++;
response.writeHead(302, {
location: '/',
});
response.end();
});
await t.throwsAsync(got('redirect'), {message: /^Request failed with status code 500 \(Internal Server Error\): GET http:\/\/localhost:\d+\/$/v});
t.is(tries, 1);
});
test('does not destroy the socket on HTTP error', withServer, async (t, server, got) => {
let returnServerError = true;
server.get('/', (_request, response) => {
if (returnServerError) {
response.statusCode = 500;
returnServerError = false;
}
response.end();
});
const sockets: Socket[] = [];
const agent = new http.Agent({
keepAlive: true,
});
await got('', {
agent: {
http: agent,
},
}).on('request', request => {
sockets.push(request.socket!);
});
t.is(sockets.length, 2);
t.is(sockets[0], sockets[1]);
agent.destroy();
});
test('can retry a Got stream', withServer, async (t, server, got) => {
let returnServerError = true;
server.get('/', (_request, response) => {
if (returnServerError) {
response.statusCode = 500;
response.end('not ok');
returnServerError = false;
return;
}
response.end('ok');
});
let globalRetryCount = 0;
const responseStreamPromise = new Promise<PassThroughStream>((resolve, reject) => {
let writeStream: PassThroughStream;
const function_ = (retryStream?: Request) => {
const stream = retryStream ?? got.stream('');
globalRetryCount = stream.retryCount;
if (writeStream) {
writeStream.destroy();
}
writeStream = new PassThroughStream();
stream.pipe(writeStream);
stream.once('retry', (_retryCount, _error, createRetryStream) => {
function_(createRetryStream());
});
stream.once('error', reject);
stream.once('end', () => {
resolve(writeStream);
});
};
function_();
});
const responseStream = await responseStreamPromise;
const data = await getStream(responseStream);
t.is(data, 'ok');
t.is(globalRetryCount, 1);
});
test('can retry a Got stream with allowAbsoluteUrls false', withServer, async (t, server, got) => {
let returnServerError = true;
server.get('/', (_request, response) => {
if (returnServerError) {
response.statusCode = 500;
response.end('not ok');
returnServerError = false;
return;
}
response.end('ok');
});
const responseStreamPromise = new Promise<PassThroughStream>((resolve, reject) => {
let writeStream: PassThroughStream;
const function_ = (retryStream?: Request) => {
const stream = retryStream ?? got.stream('', {allowAbsoluteUrls: false});
if (writeStream) {
writeStream.destroy();
}
writeStream = new PassThroughStream();
stream.pipe(writeStream);
stream.once('retry', (_retryCount, _error, createRetryStream) => {
function_(createRetryStream());
});
stream.once('error', reject);
stream.once('end', () => {
resolve(writeStream);
});
};
function_();
});
const responseStream = await responseStreamPromise;
const data = await getStream(responseStream);
t.is(data, 'ok');
});
test('`allowAbsoluteUrls: false` rejects an absolute URL passed to a Got stream retry', withServer, async (t, server, got) => {
server.get('/', (_request, response) => {
response.statusCode = 500;
response.end('not ok');
});
const error = await new Promise<Error>(resolve => {
const stream = got.stream('', {allowAbsoluteUrls: false});
stream.resume();
stream.once('error', () => {});
stream.once('retry', (_retryCount, _error, createRetryStream) => {
const retryStream = createRetryStream({url: `${server.url}/other`});
retryStream.once('error', resolve);
retryStream.resume();
});
});
t.is(error.message, 'The `url` option must be relative when `allowAbsoluteUrls` is false and `prefixUrl` is set');
});
test('throws when cannot retry a Got stream', withServer, async (t, server, got) => {
server.get('/', (_request, response) => {
response.statusCode = 500;
response.end('not ok');
});
let globalRetryCount = 0;
const streamPromise = new Promise<PassThroughStream>((resolve, reject) => {
const function_ = (retryStream?: Request) => {
const stream = retryStream ?? got.stream('');
globalRetryCount = stream.retryCount;
stream.resume();
stream.once('retry', (_retryCount, _error, createRetryStream) => {
function_(createRetryStream());
});
stream.once('data', () => {
stream.destroy(new Error('data event has been emitted'));
});
stream.once('error', reject);
stream.once('end', resolve);
};
function_();
});
const error = await t.throwsAsync<HTTPError>(streamPromise, {
instanceOf: HTTPError,
});
t.is(error?.response.statusCode, 500);
t.is(error?.response.body, 'not ok');
t.is(globalRetryCount, 2);
});
test('can attach only one retry listener to a stream', withServer, async (t, _server, got) => {
const stream = got.stream('');
t.notThrows(() => {
stream.on('retry', () => {});
});
t.throws(() => {
stream.on('retry', () => {});
}, {
message: 'A retry listener has been attached already.',
});
stream.destroy();
});
test('createRetryStream accepts options', withServer, async (t, server, got) => {
let returnServerError = true;
let receivedCustomHeader = false;
server.get('/', (request, response) => {
if (request.headers['x-custom-header'] === 'custom-value') {
receivedCustomHeader = true;
}
if (returnServerError) {
response.statusCode = 500;
response.end('not ok');
returnServerError = false;
return;
}
response.end('ok');
});
const responseStreamPromise = new Promise<PassThroughStream>((resolve, reject) => {
let writeStream: PassThroughStream;
const function_ = (retryStream?: Request) => {
const stream = retryStream ?? got.stream('');
if (writeStream) {
writeStream.destroy();
}
writeStream = new PassThroughStream();
stream.pipe(writeStream);
stream.once('retry', (_retryCount, _error, createRetryStream) => {
// Pass custom options on retry
function_(createRetryStream({
headers: {
'x-custom-header': 'custom-value',
},
}));
});
stream.once('error', reject);
stream.once('end', () => {
resolve(writeStream);
});
};
function_();
});
const responseStream = await responseStreamPromise;
const data = await getStream(responseStream);
t.is(data, 'ok');
t.true(receivedCustomHeader);
});
test('createRetryStream re-copies piped headers on retry', withServer, async (t, server, got) => {
let returnServerError = true;
server.put('/', (request, response) => {
if (returnServerError) {
returnServerError = false;
response.statusCode = 500;
response.end('not ok');
return;
}
response.end(JSON.stringify(request.headers));
});
const responseStreamPromise = new Promise<PassThroughStream>((resolve, reject) => {
let writeStream: PassThroughStream;
let attempt = 0;
const function_ = (retryStream?: Request) => {
attempt++;
const stream = retryStream ?? got.stream.put('', {copyPipedHeaders: true});
if (writeStream) {
writeStream.destroy();
}
writeStream = new PassThroughStream();
const sourceStream = new PassThroughStream() as PassThroughStream & {headers: Record<string, string>};
sourceStream.headers = {
'x-custom-header': attempt === 1 ? 'first-value' : 'second-value',
};
sourceStream.pipe(stream);
sourceStream.end('request body');
stream.pipe(writeStream);
stream.once('retry', (_retryCount, _error, createRetryStream) => {
function_(createRetryStream());
});
stream.once('error', reject);
stream.once('end', () => {
if (stream.retryCount === 1) {
resolve(writeStream);
}
});
};
function_();
});
const responseStream = await responseStreamPromise;
const headers = JSON.parse(await getStream(responseStream)) as Record<string, string>;
t.is(headers['x-custom-header'], 'second-value');
});
test('createRetryStream preserves explicit header omissions after undefined header pruning', withServer, async (t, server, got) => {
let returnServerError = true;
server.put('/', (request, response) => {
if (returnServerError) {
returnServerError = false;
response.statusCode = 500;
response.end('not ok');
return;
}
response.end(JSON.stringify(request.headers));
});
const responseStreamPromise = new Promise<PassThroughStream>((resolve, reject) => {
let writeStream: PassThroughStream;
const function_ = (retryStream?: Request) => {
const stream = retryStream ?? got.stream.put('', {
copyPipedHeaders: true,
headers: {
authorization: undefined,
},
});
if (writeStream) {
writeStream.destroy();
}
writeStream = new PassThroughStream();
const sourceStream = new PassThroughStream() as PassThroughStream & {headers: Record<string, string>};
sourceStream.headers = {
authorization: 'Bearer piped-token',
};
sourceStream.pipe(stream);
sourceStream.end('request body');
stream.pipe(writeStream);
stream.once('retry', (_retryCount, _error, createRetryStream) => {
function_(createRetryStream());
});
stream.once('error', reject);
stream.once('end', () => {
if (stream.retryCount === 1) {
resolve(writeStream);
}
});
};
function_();
});
const responseStream = await responseStreamPromise;
const headers = JSON.parse(await getStream(responseStream)) as Record<string, string | undefined>;
t.is(headers.authorization, undefined);
});
test('createRetryStream re-copies piped headers after internal header additions on previous attempt', withServer, async (t, server, got) => {
let returnServerError = true;
let attempt = 0;
server.put('/', (request, response) => {
if (returnServerError) {
returnServerError = false;
response.statusCode = 500;
response.end('not ok');
return;
}
response.end(JSON.stringify(request.headers));
});
const responseStreamPromise = new Promise<PassThroughStream>((resolve, reject) => {
let writeStream: PassThroughStream;
const function_ = (retryStream?: Request) => {
attempt++;
const stream = retryStream ?? got.stream.put('', {copyPipedHeaders: true});
if (writeStream) {
writeStream.destroy();
}
writeStream = new PassThroughStream();
const sourceStream = new PassThroughStream() as PassThroughStream & {headers: Record<string, string>};
sourceStream.headers = attempt === 1
? {}
: {'accept-encoding': 'identity'};
sourceStream.pipe(stream);
sourceStream.end('request body');
stream.pipe(writeStream);
stream.once('retry', (_retryCount, _error, createRetryStream) => {
function_(createRetryStream());
});
stream.once('error', reject);
stream.once('end', () => {
if (stream.retryCount === 1) {
resolve(writeStream);
}
});
};
function_();
});
const responseStream = await responseStreamPromise;
const headers = JSON.parse(await getStream(responseStream)) as Record<string, string>;
t.is(headers['accept-encoding'], 'identity');
});
test('createRetryStream preserves header omission from direct header mutation', withServer, async (t, server, got) => {
let returnServerError = true;
server.put('/', (request, response) => {
if (returnServerError) {
returnServerError = false;
response.statusCode = 500;
response.end('not ok');
return;
}
response.end(JSON.stringify(request.headers));
});
const responseStreamPromise = new Promise<PassThroughStream>((resolve, reject) => {
let writeStream: PassThroughStream;
const function_ = (retryStream?: Request) => {
const stream = retryStream ?? got.stream.put('', {copyPipedHeaders: true});
stream.options.headers.authorization = undefined;
if (writeStream) {
writeStream.destroy();
}
writeStream = new PassThroughStream();
const sourceStream = new PassThroughStream() as PassThroughStream & {headers: Record<string, string>};
sourceStream.headers = {
authorization: 'Bearer piped-token',
};
sourceStream.pipe(stream);
sourceStream.end('request body');
stream.pipe(writeStream);
stream.once('retry', (_retryCount, _error, createRetryStream) => {
function_(createRetryStream());
});
stream.once('error', reject);
stream.once('end', () => {
if (stream.retryCount === 1) {
resolve(writeStream);
}
});
};
function_();
});
const responseStream = await responseStreamPromise;
const headers = JSON.parse(await getStream(responseStream)) as Record<string, string | undefined>;
t.is(headers.authorization, undefined);
});
test('createRetryStream preserves mixed-case header omission from direct header mutation', withServer, async (t, server, got) => {
let returnServerError = true;
server.put('/', (request, response) => {
if (returnServerError) {
returnServerError = false;
response.statusCode = 500;
response.end('not ok');
return;
}
response.end(JSON.stringify(request.headers));
});
const responseStreamPromise = new Promise<PassThroughStream>((resolve, reject) => {
let writeStream: PassThroughStream;
const function_ = (retryStream?: Request) => {
const stream = retryStream ?? got.stream.put('', {copyPipedHeaders: true});
stream.options.headers.Authorization = undefined;
if (writeStream) {
writeStream.destroy();
}
writeStream = new PassThroughStream();
const sourceStream = new PassThroughStream() as PassThroughStream & {headers: Record<string, string>};
sourceStream.headers = {
authorization: 'Bearer piped-token',
};
sourceStream.pipe(stream);
sourceStream.end('request body');
stream.pipe(writeStream);
stream.once('retry', (_retryCount, _error, createRetryStream) => {
function_(createRetryStream());
});
stream.once('error', reject);
stream.once('end', () => {
if (stream.retryCount === 1) {
resolve(writeStream);
}
});
};
function_();
});
const responseStream = await responseStreamPromise;
const headers = JSON.parse(await getStream(responseStream)) as Record<string, string | undefined>;
t.is(headers.authorization, undefined);
});
test('createRetryStream keeps username/password authorization precedence over piped authorization', withServer, async (t, server, got) => {
let returnServerError = true;
let attempt = 0;
server.put('/', (request, response) => {
if (returnServerError) {
returnServerError = false;
response.statusCode = 500;
response.end('not ok');
return;
}
response.end(JSON.stringify(request.headers));
});
const responseStreamPromise = new Promise<PassThroughStream>((resolve, reject) => {
let writeStream: PassThroughStream;
const function_ = (retryStream?: Request) => {
attempt++;
const stream = retryStream ?? got.stream.put('', {
copyPipedHeaders: true,
username: 'foo',
password: 'bar',
});
if (writeStream) {
writeStream.destroy();
}
writeStream = new PassThroughStream();
const sourceStream = new PassThroughStream() as PassThroughStream & {headers: Record<string, string>};
sourceStream.headers = attempt === 1
? {}
: {authorization: 'Bearer retry-token'};
sourceStream.pipe(stream);
sourceStream.end('request body');
stream.pipe(writeStream);
stream.once('retry', (_retryCount, _error, createRetryStream) => {
function_(createRetryStream());
});
stream.once('error', reject);
stream.once('end', () => {
if (stream.retryCount === 1) {
resolve(writeStream);
}
});
};
function_();
});
const responseStream = await responseStreamPromise;
const headers = JSON.parse(await getStream(responseStream)) as Record<string, string>;
t.is(headers.authorization, 'Basic Zm9vOmJhcg==');
});
test('promise does not retry when body is a stream', withServer, async (t, server, got) => {
server.post('/', (_request, response) => {
response.statusCode = 500;
response.end('not ok');
});
const body = new PassThroughStream();
body.end('hello');
const response = await got.post({
retry: {
methods: ['POST'],
},
body,
throwHttpErrors: false,
});
t.is(response.retryCount, 0);
});
test('reuses request options on retry', withServer, async (t, server, got) => {
let first = true;
server.get('/', (request, response) => {
if (first) {
first = false;
return;
}
response.end(JSON.stringify(request.headers));
});
const {body, retryCount} = await got('', {timeout: {request: 1000}, responseType: 'json'});
t.is(retryCount, 1);
t.is((body as any).accept, 'application/json');
});
test('respects backoffLimit', withServer, async (t, server, got) => {
let count = 0;
let timestamp = Date.now();
const data: number[] = [];
server.get('/', (_request, response) => {
count++;
const now = Date.now();
data.push(now - timestamp);
timestamp = now;
if (count === 3) {
response.end(JSON.stringify(data));
} else {
response.statusCode = 408;
response.end();
}
});
const body = await got('', {
retry: {
backoffLimit: 0,
},
}).json<number[]>();
t.is(body.length, 3);
t.true(body[0]! < 400);
t.true(body[1]! < 400);
t.true(body[2]! < 400);
});
test('enforceRetryRules respects statusCodes with custom calculateDelay', withServer, async (t, server, got) => {
let requestCount = 0;
server.get('/', (_request, response) => {
requestCount++;
// Return 500 on first request, 429 on second, 200 on third
if (requestCount === 1) {
response.statusCode = 500;
} else if (requestCount === 2) {
response.statusCode = 429;
}
response.end();
});
const {statusCode, retryCount} = await got({
throwHttpErrors: false,
retry: {
limit: 5,
statusCodes: [429], // Should only retry on 429
enforceRetryRules: true,
calculateDelay({attemptCount}) {
// Custom delay but should still respect statusCodes
return attemptCount * 100;
},
},
});
// Should not retry on 500 (not in statusCodes list)
t.is(statusCode, 500);
t.is(retryCount, 0);
});
test('enforces retry rules by default with custom calculateDelay', withServer, async (t, server, got) => {
let requestCount = 0;
server.get('/', (_request, response) => {
requestCount++;
if (requestCount === 1) {
response.statusCode = 500;
} else if (requestCount === 2) {
response.statusCode = 429;
}
response.end();
});
const {statusCode, retryCount} = await got({
throwHttpErrors: false,
retry: {
limit: 5,
statusCodes: [429],
calculateDelay({attemptCount}) {
return attemptCount * 100;
},
},
});
t.is(statusCode, 500);
t.is(retryCount, 0);
});
test('enforceRetryRules respects limit with custom calculateDelay', withServer, async (t, server, got) => {
let requestCount = 0;
server.get('/', (_request, response) => {
requestCount++;
response.statusCode = 500;
response.end();
});
const {retryCount} = await got({
throwHttpErrors: false,
retry: {
limit: 2,
enforceRetryRules: true,
calculateDelay({attemptCount}) {
// With enforceRetryRules, limit is enforced automatically
return attemptCount * 100;
},
},
});
// Should stop at limit even with custom calculateDelay
t.is(retryCount, 2);
t.is(requestCount, 3); // Initial request + 2 retries
});
test('retries on stream errors like EPIPE when configured', async t => {
let attemptCount = 0;
let retryCount = 0;
const error = await t.throwsAsync<Error & {code: string}>(got.post('https://example.com', {
retry: {
limit: 2,
methods: ['POST'],
errorCodes: ['EPIPE'],
},
hooks: {
beforeRetry: [
() => {
retryCount++;
},
],
},
request() {
attemptCount++;
const emitter = new EventEmitter() as http.ClientRequest;
(emitter as any).end = (callback: any) => {
// Simulate EPIPE error from Node.js during write/end
// This mimics what happens when a socket is torn down (e.g., AWS Lambda pause)
const error = new Error('write EPIPE');
(error as NodeJS.ErrnoException).code = 'EPIPE';
if (callback) {
setTimeout(() => {
callback(error);
}, 10);
}
};
emitter.destroyed = false;
(emitter as any).destroy = () => {
emitter.destroyed = true;
};
(emitter as any).write = () => true;
(emitter as any).writable = true;
(emitter as any).writableEnded = false;
return emitter;
},
}), {code: 'EPIPE'});
// Should retry twice (limit: 2) for a total of 3 attempts
t.is(attemptCount, 3);
t.is(retryCount, 2);
t.is(error?.code, 'EPIPE');
});
test('does not retry on stream errors when not in errorCodes', async t => {
let attemptCount = 0;
const error = await t.throwsAsync<Error & {code: string}>(got('https://example.com', {
retry: {
limit: 2,
errorCodes: [], // Empty list means no errors should be retried
},
request() {
attemptCount++;
const emitter = new EventEmitter() as http.ClientRequest;
(emitter as any).end = (callback: any) => {
const error = new Error('write ETEST');
(error as NodeJS.ErrnoException).code = 'ETEST';
if (callback) {
setTimeout(() => {
callback(error);
}, 10);
}
};
emitter.destroyed = false;
(emitter as any).destroy = () => {
emitter.destroyed = true;
};
(emitter as any).write = () => true;
(emitter as any).writable = true;
(emitter as any).writableEnded = false;
return emitter;
},
}), {code: 'ETEST'});
// Should NOT retry since errorCodes is empty
t.is(attemptCount, 1);
t.is(error?.code, 'ETEST');
});
test('does not retry after promise settles (issue #1489)', async t => {
let retryTriggered = false;
const response = await got('https://example.com', {
retry: {limit: 2, errorCodes: ['ECONNRESET']},
hooks: {
beforeRetry: [() => {
retryTriggered = true;
}],
},
request() {
const emitter = new EventEmitter() as http.ClientRequest;
(emitter as any).end = () => {};
emitter.destroyed = false;
(emitter as any).destroy = () => {
emitter.destroyed = true;
};
(emitter as any).write = () => true;
(emitter as any).writable = true;
(emitter as any).writableEnded = false;
setTimeout(() => {
const incomingMessage = new PassThroughStream() as unknown as http.IncomingMessage;
incomingMessage.statusCode = 200;
incomingMessage.headers = {};
emitter.emit('response', incomingMessage);
setImmediate(() => {
// @ts-expect-error PassThrough method
incomingMessage.end('ok');
});
// Late error after response - should NOT trigger retry
setTimeout(() => {
const error = new Error('read ECONNRESET');
(error as NodeJS.ErrnoException).code = 'ECONNRESET';
emitter.emit('error', error);
}, 10);
});
return emitter;
},
throwHttpErrors: false,
});
t.is(response.statusCode, 200);
t.false(retryTriggered);
});
test('retries on a refused connection and rejects with the real ECONNREFUSED', async t => {
// Regression test for https://github.com/sindresorhus/got/issues/2469
// Since Node.js 24.20 (nodejs/node#64847) the `request.end()` callback is
// invoked with the connection-phase error. Before the fix that error
// (surfaced as ERR_SOCKET_CLOSED_BEFORE_CONNECTION) terminated the request
// after a single attempt, ignoring `retry` and masking the real error.
const server = http.createServer();
await new Promise<void>(resolve => {
server.listen(0, '127.0.0.1', resolve);
});
const {port} = server.address() as AddressInfo;
await new Promise<void>(resolve => {
server.close(() => {
resolve();
});
});
let retries = 0;
const error = await t.throwsAsync<RequestError>(got(`http://127.0.0.1:${port}`, {
retry: {
limit: 2,
calculateDelay: () => 1,
},
hooks: {
beforeRetry: [
() => {
retries++;
},
],
},
}), {
instanceOf: RequestError,
});
t.is(error?.code, 'ECONNREFUSED');
t.is(retries, 2);
});