11import { isIP , Socket } from 'node:net'
22import { checkServerIdentity , connect as connectTls } from 'node:tls'
3+ import { createLogger } from '@sim/logger'
34import { evaluateAddress , STRICT_EGRESS_POLICY } from '@sim/security/egress'
5+ import { toError } from '@sim/utils/errors'
46import { Agent , Client } from 'undici/index.js'
57import type { OutboundGateway } from '@/lib/core/network/config.server'
68import { OutboundRoutingError } from '@/lib/core/network/routing'
79import type { EgressProfile } from '@/lib/core/security/egress/profiles'
810import { checkResolvedEgress , validateEgressUrl } from '@/lib/core/security/egress/validate'
911
1012const 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. */
1318export 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. */
94131export function createGatewayDispatcher (
95132 gateway : OutboundGateway ,
0 commit comments