Skip to content

Commit 445522b

Browse files
authored
fix(proxy): fail the request when the CONNECT tunnel drops instead of looping (#5441)
When a proxy tore down the socket while the CONNECT tunnel was still being established, the inner client rejected with UND_ERR_SOCKET. The proxy-agent catch only special-cased ERR_TLS_CERT_ALTNAME_INVALID and passed every other error through raw. client.js#onError treats UND_ERR_SOCKET as a recoverable error on an established connection, so with no running request it left the request queued and connect() was re-driven forever, hammering the proxy while the request never settled. Wrap a tunnel-establishment socket failure in a new ProxyConnectionError (UND_ERR_PRX_CONN) so onError fails the request instead of retrying, mirroring the existing SecureProxyConnectionError handling for the TLS case. Fixes #3897
1 parent ae3efd4 commit 445522b

6 files changed

Lines changed: 98 additions & 1 deletion

File tree

docs/docs/api/Errors.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { errors } from 'undici'
2626
| `InformationalError` | `UND_ERR_INFO` | expected error with reason |
2727
| `ResponseExceededMaxSizeError` | `UND_ERR_RES_EXCEEDED_MAX_SIZE` | response body exceed the max size allowed |
2828
| `SecureProxyConnectionError` | `UND_ERR_PRX_TLS` | tls connection to a proxy failed |
29+
| `ProxyConnectionError` | `UND_ERR_PRX_CONN` | establishing the proxy `CONNECT` tunnel failed |
2930
| `MessageSizeExceededError` | `UND_ERR_WS_MESSAGE_SIZE_EXCEEDED` | WebSocket decompressed message exceeded the maximum allowed size |
3031
| `AbortError` | `UND_ERR_ABORT` | the operation was aborted (base class of `RequestAbortedError`). |
3132
| `RequestRetryError` | `UND_ERR_REQ_RETRY` | request failed and could not be retried; carries `statusCode`, `headers` and `data`. |

lib/core/errors.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,25 @@ class SecureProxyConnectionError extends UndiciError {
403403
}
404404
}
405405

406+
const kProxyConnectionError = Symbol.for('undici.error.UND_ERR_PRX_CONN')
407+
class ProxyConnectionError extends UndiciError {
408+
constructor (cause, message, options = {}) {
409+
super(message, { cause, ...options })
410+
this.name = 'ProxyConnectionError'
411+
this.message = message || 'Proxy Connection failed'
412+
this.code = 'UND_ERR_PRX_CONN'
413+
this.cause = cause
414+
}
415+
416+
static [Symbol.hasInstance] (instance) {
417+
return instance && instance[kProxyConnectionError] === true
418+
}
419+
420+
get [kProxyConnectionError] () {
421+
return true
422+
}
423+
}
424+
406425
const kMaxOriginsReachedError = Symbol.for('undici.error.UND_ERR_MAX_ORIGINS_REACHED')
407426
class MaxOriginsReachedError extends UndiciError {
408427
constructor (message) {
@@ -471,6 +490,7 @@ module.exports = {
471490
RequestRetryError,
472491
ResponseError,
473492
SecureProxyConnectionError,
493+
ProxyConnectionError,
474494
MaxOriginsReachedError,
475495
Socks5ProxyError,
476496
MessageSizeExceededError

lib/dispatcher/proxy-agent.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const { kProxy, kClose, kDestroy, kDispatch } = require('../core/symbols')
44
const Agent = require('./agent')
55
const Pool = require('./pool')
66
const DispatcherBase = require('./dispatcher-base')
7-
const { InvalidArgumentError, RequestAbortedError, SecureProxyConnectionError } = require('../core/errors')
7+
const { InvalidArgumentError, RequestAbortedError, SecureProxyConnectionError, ProxyConnectionError } = require('../core/errors')
88
const buildConnector = require('../core/connect')
99
const Client = require('./client')
1010
const { channels } = require('../core/diagnostics')
@@ -231,6 +231,14 @@ class ProxyAgent extends DispatcherBase {
231231
if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
232232
// Throw a custom error to avoid loop in client.js#connect
233233
callback(new SecureProxyConnectionError(err))
234+
} else if (err.code === 'UND_ERR_SOCKET') {
235+
// A socket failure while establishing the tunnel means the CONNECT
236+
// never completed, so there is nothing to recover - the proxy just
237+
// tore down the connection. client.js#onError treats UND_ERR_SOCKET
238+
// as a recoverable error on an established connection and leaves the
239+
// request queued, which makes connect() retry forever. Surface it as
240+
// a non-recoverable proxy error so the request fails instead. (#3897)
241+
callback(new ProxyConnectionError(err))
234242
} else {
235243
callback(err)
236244
}

test/issue-3897.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use strict'
2+
3+
const { tspl } = require('@matteo.collina/tspl')
4+
const { test, after } = require('node:test')
5+
const { createServer } = require('node:http')
6+
const { once } = require('node:events')
7+
const { request } = require('..')
8+
const { ProxyConnectionError } = require('../lib/core/errors')
9+
const ProxyAgent = require('../lib/dispatcher/proxy-agent')
10+
11+
// Regression test for https://github.com/nodejs/undici/issues/3897
12+
//
13+
// When the proxy tears down the socket while the CONNECT tunnel is being
14+
// established, the inner client rejects with UND_ERR_SOCKET. client.js#onError
15+
// treats UND_ERR_SOCKET as a recoverable error on an established connection and
16+
// leaves the request queued, so connect() is retried forever - the proxy gets
17+
// hammered with CONNECT attempts and the request never settles. The fix surfaces
18+
// a tunnel-establishment socket failure as a non-recoverable ProxyConnectionError
19+
// so the request fails after a single attempt.
20+
test('a proxy that drops the CONNECT tunnel fails the request instead of looping', { timeout: 5000 }, async (t) => {
21+
t = tspl(t, { plan: 3 })
22+
23+
let connectAttempts = 0
24+
const proxy = createServer()
25+
proxy.on('connect', (req, socket) => {
26+
connectAttempts++
27+
// Tear the tunnel down before it is established, like a proxy that does not
28+
// implement CONNECT or rejects the upstream.
29+
socket.destroy()
30+
})
31+
32+
proxy.listen(0)
33+
await once(proxy, 'listening')
34+
35+
const proxyUrl = `http://127.0.0.1:${proxy.address().port}`
36+
const proxyAgent = new ProxyAgent(proxyUrl)
37+
38+
after(async () => {
39+
await proxyAgent.close()
40+
proxy.close()
41+
})
42+
43+
await t.rejects(
44+
request('http://localhost/', { dispatcher: proxyAgent }),
45+
(err) => {
46+
t.ok(err instanceof ProxyConnectionError)
47+
t.strictEqual(connectAttempts, 1)
48+
return true
49+
}
50+
)
51+
52+
await t.completed
53+
})

test/types/errors.test-d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ expectAssignable<errors.SecureProxyConnectionError>(new errors.SecureProxyConnec
110110
expectAssignable<'SecureProxyConnectionError'>(new errors.SecureProxyConnectionError().name)
111111
expectAssignable<'UND_ERR_PRX_TLS'>(new errors.SecureProxyConnectionError().code)
112112

113+
expectAssignable<errors.UndiciError>(new errors.ProxyConnectionError())
114+
expectAssignable<errors.ProxyConnectionError>(new errors.ProxyConnectionError())
115+
expectAssignable<'ProxyConnectionError'>(new errors.ProxyConnectionError().name)
116+
expectAssignable<'UND_ERR_PRX_CONN'>(new errors.ProxyConnectionError().code)
117+
113118
expectAssignable<errors.UndiciError>(new errors.MaxOriginsReachedError())
114119
expectAssignable<errors.MaxOriginsReachedError>(new errors.MaxOriginsReachedError())
115120
expectAssignable<'MaxOriginsReachedError'>(new errors.MaxOriginsReachedError().name)

types/errors.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,16 @@ declare namespace Errors {
154154
code: 'UND_ERR_PRX_TLS'
155155
}
156156

157+
export class ProxyConnectionError extends UndiciError {
158+
constructor (
159+
cause?: Error,
160+
message?: string,
161+
options?: Record<any, any>
162+
)
163+
name: 'ProxyConnectionError'
164+
code: 'UND_ERR_PRX_CONN'
165+
}
166+
157167
export class MaxOriginsReachedError extends UndiciError {
158168
name: 'MaxOriginsReachedError'
159169
code: 'UND_ERR_MAX_ORIGINS_REACHED'

0 commit comments

Comments
 (0)