Skip to content

Commit 772fcf7

Browse files
authored
fix(egress): retry a gateway CONNECT once on connection failure and log each failed attempt (#9351)
* fix(egress): retry a gateway CONNECT once on connection failure and log each failed attempt * test(egress): cover the 5xx CONNECT retry path
1 parent d76f2cd commit 772fcf7

2 files changed

Lines changed: 99 additions & 8 deletions

File tree

‎apps/sim/lib/core/network/gateway.server.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ let originRequests = 0
4545
let receivedHeaders: Record<string, unknown> = {}
4646
let receivedTarget = ''
4747
const proxiedPorts = new Set<number>()
48+
/** Each queued entry makes one CONNECT fail: `reset` drops the connection, a number answers with that status. */
49+
const connectFailures: Array<'reset' | number> = []
4850
const secureOrigin = httpsServer(async (req, res) => {
4951
res.setHeader('x-via-proxy', proxiedPorts.has(req.socket.remotePort ?? 0) ? 'yes' : 'no')
5052
originRequests++
@@ -70,6 +72,15 @@ proxy.on('connect', (req, socket, head) => {
7072
destination: req.url,
7173
sni: tlsSocket.servername,
7274
})
75+
const failure = connectFailures.shift()
76+
if (failure === 'reset') {
77+
socket.destroy()
78+
return
79+
}
80+
if (failure !== undefined) {
81+
socket.end(`HTTP/1.1 ${failure} Gateway Failure\r\nContent-Length: 0\r\n\r\n`)
82+
return
83+
}
7384
if (!req.headers['proxy-authorization']?.match(/^Bearer (alpha|bravo|a{48})$/)) {
7485
socket.end('HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n')
7586
return
@@ -150,6 +161,11 @@ afterAll(async () => {
150161
rmSync(certificateDirectory, { recursive: true, force: true })
151162
})
152163
const url = () => 'https://origin.invalid'
164+
const gatewayFor = (organizationId: string): OutboundGateway => {
165+
const gateway = state.gateways.get(organizationId)
166+
if (!gateway) throw new Error(`No gateway for ${organizationId}`)
167+
return gateway
168+
}
153169
const options = { profile: 'selfHostedService' as const }
154170

155171
describe('organization gateways over real TLS CONNECT sockets', () => {
@@ -356,4 +372,42 @@ describe('organization gateways over real TLS CONNECT sockets', () => {
356372
await transport.dispatcher.destroy()
357373
}
358374
})
375+
376+
it.each(['reset', 503] as const)(
377+
'retries a CONNECT that fails with %s before the tunnel opens and completes the request',
378+
async (failure) => {
379+
connectFailures.push(failure)
380+
const start = admissions.length
381+
try {
382+
const response = await runWithOutboundOrganization('org_a', () =>
383+
secureFetchWithPinnedIP(url(), '1.1.1.1', options)
384+
)
385+
expect(await response.text()).toBe('tls reached')
386+
expect(admissions.length).toBe(start + 2)
387+
} finally {
388+
connectFailures.length = 0
389+
}
390+
}
391+
)
392+
393+
it('does not retry a rejected proxy credential', async () => {
394+
const start = admissions.length
395+
await expect(
396+
openGatewayTunnel({ ...gatewayFor('org_a'), token: 'denied' }, '1.1.1.1', 443)
397+
).rejects.toMatchObject({ code: 'GATEWAY_UNAVAILABLE' })
398+
expect(admissions.length).toBe(start + 1)
399+
})
400+
401+
it('gives up after two failed CONNECT attempts', async () => {
402+
connectFailures.push('reset', 503)
403+
const start = admissions.length
404+
try {
405+
await expect(openGatewayTunnel(gatewayFor('org_a'), '1.1.1.1', 443)).rejects.toMatchObject({
406+
code: 'GATEWAY_UNAVAILABLE',
407+
})
408+
expect(admissions.length).toBe(start + 2)
409+
} finally {
410+
connectFailures.length = 0
411+
}
412+
})
359413
})

‎apps/sim/lib/core/network/gateway.server.ts‎

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { isIP, Socket } from 'node:net'
22
import { checkServerIdentity, connect as connectTls } from 'node:tls'
3+
import { createLogger } from '@sim/logger'
34
import { evaluateAddress, STRICT_EGRESS_POLICY } from '@sim/security/egress'
5+
import { toError } from '@sim/utils/errors'
46
import { Agent, Client } from 'undici/index.js'
57
import type { OutboundGateway } from '@/lib/core/network/config.server'
68
import { OutboundRoutingError } from '@/lib/core/network/routing'
79
import type { EgressProfile } from '@/lib/core/security/egress/profiles'
810
import { checkResolvedEgress, validateEgressUrl } from '@/lib/core/security/egress/validate'
911

1012
const CONNECT_TIMEOUT_MS = 10_000
13+
const MAX_CONNECT_ATTEMPTS = 2
14+
15+
const logger = createLogger('OutboundGateway')
1116

1217
/** The proxy CA never changes trust for the upstream service. */
1318
export async function secureOutboundTunnel(
@@ -64,7 +69,36 @@ export async function openGatewayTunnel(
6469
) {
6570
throw new OutboundRoutingError('UNSUPPORTED_TRANSPORT')
6671
}
67-
const authority = `${address}:443`
72+
for (let attempt = 1; ; attempt++) {
73+
const result = await attemptGatewayConnect(gateway, `${address}:443`)
74+
if (result.socket) return result.socket
75+
logger.warn('Gateway CONNECT failed', {
76+
gatewayId: gateway.id,
77+
organizationId: gateway.organizationId,
78+
attempt,
79+
statusCode: result.statusCode,
80+
errorName: result.error?.name,
81+
errorCode: getErrorCode(result.error),
82+
})
83+
if (!result.retryable || attempt >= MAX_CONNECT_ATTEMPTS) {
84+
throw new OutboundRoutingError('GATEWAY_UNAVAILABLE')
85+
}
86+
}
87+
}
88+
89+
type GatewayConnectResult =
90+
| { socket: Socket; statusCode?: never; error?: never; retryable?: never }
91+
| { socket?: never; statusCode?: number; error?: Error; retryable: boolean }
92+
93+
/**
94+
* A failure before the proxy answers 200 has sent no upstream bytes, so it is safe to retry.
95+
* Credential or admission rejections (4xx) are not transient and never retry.
96+
*/
97+
async function attemptGatewayConnect(
98+
gateway: OutboundGateway,
99+
authority: string
100+
): Promise<GatewayConnectResult> {
101+
/** A fresh client per attempt opens a new connection, which the NLB can route to another task. */
68102
const client = new Client(gateway.url, {
69103
allowH2: false,
70104
maxHeaderSize: 16_384,
@@ -77,19 +111,22 @@ export async function openGatewayTunnel(
77111
signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS),
78112
headers: { host: authority, 'proxy-authorization': `Bearer ${gateway.token}` },
79113
})
80-
if (statusCode !== 200 || !(socket instanceof Socket)) {
81-
socket.destroy()
82-
throw new OutboundRoutingError('GATEWAY_UNAVAILABLE')
83-
}
84-
return socket
85-
} catch {
86-
throw new OutboundRoutingError('GATEWAY_UNAVAILABLE')
114+
if (statusCode === 200 && socket instanceof Socket) return { socket }
115+
socket.destroy()
116+
return { statusCode, retryable: statusCode >= 500 }
117+
} catch (error) {
118+
return { error: toError(error), retryable: true }
87119
} finally {
88120
/** An upgraded socket is detached from the dispatcher and now belongs to its caller. */
89121
await client.destroy()
90122
}
91123
}
92124

125+
function getErrorCode(error: Error | undefined): string | undefined {
126+
const code = error && 'code' in error ? error.code : undefined
127+
return typeof code === 'string' ? code : undefined
128+
}
129+
93130
/** A dispatcher belongs to one organization and gateway, never a process-wide default. */
94131
export function createGatewayDispatcher(
95132
gateway: OutboundGateway,

0 commit comments

Comments
 (0)