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
This commit is contained in:
dualfroz committed 2026-08-28 03:35:54 +02:00
1 parent 6c46990d75
commit 017b847750
2 files changed
+53 -3

No files matched your search

+10 -1
View File
@@ -762,7 +762,16 @@ export default class Request extends Duplex implements RequestEvents<Request> {
request.end((error?: Error | null) => { // eslint-disable-line @typescript-eslint/no-restricted-types
// The request has been destroyed before `_final` finished.
// See https://github.com/nodejs/node/issues/39356
if ((request as any)?._writableState?.errored) {
//
// Since Node.js 24.20 (nodejs/node#64847) this callback is also invoked
// with the connection-phase error after the request has already emitted
// `error` and been destroyed. For a refused connection the `error` handler
// receives the real ECONNREFUSED and schedules a retry, then this callback
// fires late with a generic ERR_SOCKET_CLOSED_BEFORE_CONNECTION. Forwarding
// that late error to `_final` would destroy the stream and cancel the retry,
// so ignore it once the request is gone. When the request is still alive the
// callback error is the primary signal and must reach the retry classifier.
if (request.destroyed || (request as any)?._writableState?.errored) {
return;
}
+43 -2
View File
@@ -1,6 +1,6 @@
import {EventEmitter} from 'node:events';
import {PassThrough as PassThroughStream} from 'node:stream';
import type {Socket} from 'node:net';
import type {AddressInfo, Socket} from 'node:net';
import http from 'node:http';
import https from 'node:https';
import test from 'ava';
@@ -8,7 +8,7 @@ import is from '@sindresorhus/is';
import type {Handler} from 'express';
import getStream from 'get-stream';
import {pEvent} from 'p-event';
import got, {HTTPError, TimeoutError} from '../source/index.js';
import got, {HTTPError, RequestError, TimeoutError} from '../source/index.js';
import type Request from '../source/core/index.js';
import withServer from './helpers/with-server.js';
@@ -1372,3 +1372,44 @@ test('does not retry after promise settles (issue #1489)', async t => {
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);
});