From 017b847750ccc24bb9ece71ba32147287f2306f7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 28 Aug 2026 03:35:54 +0200 Subject: [PATCH] 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 --- source/core/index.ts | 11 ++++++++++- test/retry.ts | 45 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/source/core/index.ts b/source/core/index.ts index a8a1561..cd2255e 100644 --- a/source/core/index.ts +++ b/source/core/index.ts @@ -762,7 +762,16 @@ export default class Request extends Duplex implements RequestEvents { 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; } diff --git a/test/retry.ts b/test/retry.ts index 0e50659..c74d16b 100644 --- a/test/retry.ts +++ b/test/retry.ts @@ -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(resolve => { + server.listen(0, '127.0.0.1', resolve); + }); + + const {port} = server.address() as AddressInfo; + + await new Promise(resolve => { + server.close(() => { + resolve(); + }); + }); + + let retries = 0; + + const error = await t.throwsAsync(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); +});