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); +});