Skip to content

Commit 20688fc

Browse files
authored
fix: always pass an explicit HTTP agent to avoid Node's 5s idle timeout (cut 1.1.110) (#1344)
* fix(sdk): always pass an explicit HTTP agent to avoid Node's 5s idle timeout Node >=19's global HTTP/HTTPS agent enables keepAlive with a 5s socket timeout, which Node applies as a per-socket inactivity timeout. setupSdk only supplied an explicit agent for the proxy and SSL_CERT_FILE cases, so the common path inherited the global agent's 5s timeout even when SOCKET_CLI_API_TIMEOUT is unset. This caused upload-manifest-files to fail intermittently: the SDK streams the multipart body with Transfer-Encoding: chunked, and when the server takes >5s to parse auth/multipart before sending any response byte, the socket goes idle, Node fires the 5s timeout, and the SDK destroys the request, so the client disconnects before receiving any response. Always pass a fresh Agent (no timeout) so a request is bounded only by an explicit SOCKET_CLI_API_TIMEOUT or until interrupted. Reproduced locally against a slow mock server with no load balancer in the path. * fix(api): use an explicit agent for the raw apiFetch stack; cut 1.1.110 apiFetch's https.request used the default (global) agent when no CA cert was configured, inheriting Node >=19's keepAlive 5s socket timeout — the same issue just fixed for the SDK. getHttpsAgent now always returns an explicit HttpsAgent (no timeout), covering queryApiSafe*/sendApiRequest and the direct apiFetch download paths (streaming full-scan responses, binary and tarball downloads). Bumps the version to 1.1.110 and adds the changelog entry. * refactor(api): tighten getHttpsAgent return type to HttpsAgent getHttpsAgent now always creates an agent on first call, so its return type is HttpsAgent (was HttpsAgent | undefined) and the _httpsRequestFetch agent parameter drops | undefined. The cached _httpsAgent keeps | undefined since it is the lazy-init sentinel (undefined only before the first call). The _httpsAgentResolved flag is removed: a set _httpsAgent is itself the "resolved" signal. Pure polish from review; no behavior change.
1 parent 5175316 commit 20688fc

6 files changed

Lines changed: 82 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1212
### Changed
1313
- **Bazel diagnostics**`socket manifest bazel --verbose` now emits bounded subprocess traces with argv, cwd, duration, exit status, output sizes, and failure stderr tails to make customer log-only triage safer and faster.
1414

15+
## [1.1.110](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.110) - 2026-05-29
16+
17+
### Fixed
18+
- Resolved intermittent ~5-second timeouts affecting manifest uploads for reachability analysis and `socket fix`, along with other long-running API requests. Socket CLI now uses an explicit HTTP agent for all API traffic, so slow uploads and large streaming responses are no longer dropped prematurely.
19+
1520
## [1.1.109](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.109) - 2026-05-28
1621

1722
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "socket",
3-
"version": "1.1.109",
3+
"version": "1.1.110",
44
"description": "CLI for Socket.dev",
55
"homepage": "https://github.com/SocketDev/socket-cli",
66
"license": "MIT AND OFL-1.1",

src/utils/api.mts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,30 +54,34 @@ import type {
5454
const MAX_REDIRECTS = 20
5555
const NO_ERROR_MESSAGE = 'No error message returned'
5656

57-
// Cached HTTPS agent for extra CA certificate support in direct API calls.
57+
// Cached HTTPS agent for direct API calls. Undefined only until the first
58+
// getHttpsAgent() call lazily creates it.
5859
let _httpsAgent: HttpsAgent | undefined
59-
let _httpsAgentResolved = false
6060

61-
// Returns an HTTPS agent configured with extra CA certificates when
62-
// SSL_CERT_FILE is set but NODE_EXTRA_CA_CERTS is not.
63-
function getHttpsAgent(): HttpsAgent | undefined {
64-
if (_httpsAgentResolved) {
61+
// Returns an explicit HTTPS agent for direct API calls, carrying extra CA
62+
// certificates when SSL_CERT_FILE is set but NODE_EXTRA_CA_CERTS is not. An
63+
// explicit agent is always returned. Node >=19's global agent enables keepAlive
64+
// with a 5s socket timeout that Node applies as a per-socket inactivity
65+
// timeout. A request made without an explicit agent inherits it and is torn
66+
// down after 5s of socket inactivity, prematurely dropping slow or idle-gapped
67+
// requests (e.g. streaming full-scan responses, large downloads) even when no
68+
// timeout was requested. A fresh Agent carries no timeout.
69+
function getHttpsAgent(): HttpsAgent {
70+
if (_httpsAgent) {
6571
return _httpsAgent
6672
}
67-
_httpsAgentResolved = true
6873
const ca = getExtraCaCerts()
69-
if (!ca) {
70-
return undefined
71-
}
72-
_httpsAgent = new HttpsAgent({ ca })
73-
return _httpsAgent
74+
const agent = ca ? new HttpsAgent({ ca }) : new HttpsAgent()
75+
_httpsAgent = agent
76+
return agent
7477
}
7578

7679
// All outbound API requests use node:https.request rather than global fetch.
7780
// This ensures no body timeout is applied — large streaming ND-JSON responses
78-
// (e.g. full scan results) can transfer without a hard deadline. When
79-
// SSL_CERT_FILE is configured, a custom HttpsAgent carrying the extra CA
80-
// certificates is passed; otherwise the default agent is used.
81+
// (e.g. full scan results) can transfer without a hard deadline. An explicit
82+
// HttpsAgent is always passed (carrying extra CA certificates when
83+
// SSL_CERT_FILE is configured) so requests do not inherit Node's global-agent
84+
// keepAlive socket timeout.
8185
export type ApiFetchInit = {
8286
body?: string | undefined
8387
headers?: Record<string, string> | undefined
@@ -88,7 +92,7 @@ export type ApiFetchInit = {
8892
function _httpsRequestFetch(
8993
url: string,
9094
init: ApiFetchInit,
91-
agent: HttpsAgent | undefined,
95+
agent: HttpsAgent,
9296
redirectCount: number,
9397
): Promise<Response> {
9498
return new Promise((resolve, reject) => {

src/utils/api.test.mts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* Test Coverage:
99
* - apiFetch always uses node:https.request (no undici body timeout).
1010
* - apiFetch passes a custom HttpsAgent when CA certs are set via SSL_CERT_FILE.
11-
* - apiFetch passes no agent (undefined) when no CA certs are configured.
11+
* - apiFetch passes an explicit HttpsAgent (no timeout) when no CA certs are configured.
1212
* - Response object construction from https.request output.
1313
* - POST requests with JSON body through https.request path.
1414
* - Error propagation from https.request failures.
@@ -114,7 +114,7 @@ describe('apiFetch with extra CA certificates', () => {
114114
globalThis.fetch = originalFetch
115115
})
116116

117-
it('should use https.request with no agent when no extra CA certs are needed', async () => {
117+
it('should use https.request with an explicit no-timeout agent when no extra CA certs are needed', async () => {
118118
const mockReq = {
119119
end: vi.fn(),
120120
on: vi.fn(),
@@ -148,11 +148,15 @@ describe('apiFetch with extra CA certificates', () => {
148148

149149
// Always uses https.request — no undici body timeout.
150150
expect(mockHttpsRequest).toHaveBeenCalled()
151-
// No custom HttpsAgent created when CA certs are not configured.
152-
expect(MockHttpsAgent).not.toHaveBeenCalled()
153-
// agent is undefined when no CA certs are configured.
151+
// An explicit HttpsAgent is created so the request does not inherit Node's
152+
// global agent (keepAlive plus a 5s socket timeout).
153+
expect(MockHttpsAgent).toHaveBeenCalledTimes(1)
154+
// A fresh agent carries no timeout, so no per-socket inactivity timeout.
155+
const agentOpts = MockHttpsAgent.mock.calls[0]?.[0]
156+
expect(agentOpts?.timeout).toBeUndefined()
157+
// The request is made with that explicit agent.
154158
const callArgs = mockHttpsRequest.mock.calls[0]
155-
expect(callArgs[1]).toEqual(expect.objectContaining({ agent: undefined }))
159+
expect(callArgs[1].agent).toMatchObject({ _isHttpsAgent: true })
156160
expect(result.ok).toBe(true)
157161
})
158162

src/utils/sdk.mts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
*/
2626

2727
import { readFileSync } from 'node:fs'
28+
import { Agent as HttpAgent } from 'node:http'
2829
import { Agent as HttpsAgent } from 'node:https'
2930
import { rootCertificates } from 'node:tls'
3031

@@ -182,25 +183,31 @@ export async function setupSdk(
182183

183184
// Usage of HttpProxyAgent vs. HttpsProxyAgent based on the chart at:
184185
// https://github.com/delvedor/hpagent?tab=readme-ov-file#usage
185-
const ProxyAgent = apiBaseUrl?.startsWith('http:')
186-
? HttpProxyAgent
187-
: HttpsProxyAgent
186+
const isHttp = apiBaseUrl?.startsWith('http:')
187+
const ProxyAgent = isHttp ? HttpProxyAgent : HttpsProxyAgent
188188

189189
// Load extra CA certificates for SSL_CERT_FILE support when
190190
// NODE_EXTRA_CA_CERTS was not set at process startup.
191191
const ca = getExtraCaCerts()
192192

193+
// Always pass an explicit agent. Node >=19's global agent enables keepAlive
194+
// with a 5s socket timeout that Node applies as a per-socket inactivity
195+
// timeout. A request made without an explicit agent inherits it and is torn
196+
// down after 5s of socket inactivity, even when SOCKET_CLI_API_TIMEOUT is
197+
// unset. This breaks slow endpoints like upload-manifest-files, which streams
198+
// a chunked multipart body while the server parses auth/multipart before
199+
// sending any response byte. A fresh Agent carries no timeout, so a request
200+
// is bounded only by a real SOCKET_CLI_API_TIMEOUT (applied below via the
201+
// SDK's timeout option) or until interrupted.
193202
const sdkOptions = {
194-
...(apiProxy
195-
? {
196-
agent: new ProxyAgent({
197-
proxy: apiProxy,
198-
...(ca ? { ca, proxyConnectOptions: { ca } } : {}),
199-
}),
200-
}
201-
: ca
202-
? { agent: new HttpsAgent({ ca }) }
203-
: {}),
203+
agent: apiProxy
204+
? new ProxyAgent({
205+
proxy: apiProxy,
206+
...(ca ? { ca, proxyConnectOptions: { ca } } : {}),
207+
})
208+
: isHttp
209+
? new HttpAgent()
210+
: new HttpsAgent(ca ? { ca } : undefined),
204211
...(apiBaseUrl ? { baseUrl: apiBaseUrl } : {}),
205212
timeout: constants.ENV.SOCKET_CLI_API_TIMEOUT,
206213
userAgent: createUserAgentFromPkgJson({

src/utils/sdk.test.mts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,24 @@ describe('SDK setup with telemetry hooks', () => {
453453
expect(result.data.options.userAgent).toBe('socket-cli/1.1.34')
454454
}
455455
})
456+
457+
it('should pass an explicit agent with no idle timeout by default', async () => {
458+
// Regression: Node >=19's global agent applies a 5s socket inactivity
459+
// timeout. Requests made without an explicit agent inherit it, so
460+
// uploadManifestFiles (which streams a chunked multipart body while the
461+
// server parses auth/multipart before responding) was torn down at ~5s
462+
// with no response. setupSdk must always supply an explicit agent that
463+
// has no such timeout.
464+
const result = await setupSdk({ apiToken: 'test-token' })
465+
466+
expect(result.ok).toBe(true)
467+
if (result.ok) {
468+
expect(result.data.options.agent).toBeDefined()
469+
expect(MockHttpsAgent).toHaveBeenCalled()
470+
const agentOpts = MockHttpsAgent.mock.calls.at(-1)?.[0]
471+
expect(agentOpts?.timeout).toBeUndefined()
472+
}
473+
})
456474
})
457475

458476
describe('hook integration', () => {
@@ -703,14 +721,19 @@ describe('setupSdk with extra CA certificates', () => {
703721
}
704722
})
705723

706-
it('should not create agent when no extra CA certs are needed', async () => {
724+
it('should create a default HttpsAgent with no timeout when no proxy or CA certs are configured', async () => {
707725
const { setupSdk: fn } = await import('./sdk.mts')
708726
const result = await fn({ apiToken: 'test-token' })
709727

710728
expect(result.ok).toBe(true)
711729
if (result.ok) {
712-
expect(result.data.options.agent).toBeUndefined()
713-
expect(MockHttpsAgent).not.toHaveBeenCalled()
730+
// The default branch must still pass an explicit agent so requests do
731+
// not inherit Node's global agent (keepAlive plus a 5s socket timeout).
732+
expect(result.data.options.agent).toBeDefined()
733+
expect(MockHttpsAgent).toHaveBeenCalledTimes(1)
734+
// A fresh agent carries no timeout, so no per-socket inactivity timeout.
735+
const agentOpts = MockHttpsAgent.mock.calls[0]?.[0]
736+
expect(agentOpts?.timeout).toBeUndefined()
714737
}
715738
})
716739
})

0 commit comments

Comments
 (0)