From da3c8894db13eb0e5523ecde7832ff502f4f9677 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Mon, 24 Nov 2025 18:21:57 +0000 Subject: [PATCH] fix: resolve 30-second connection hang in v6.0.0+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #406 This commit resolves the connection hang issue introduced in PR #377 when the library migrated from axios to Node.js native fetch. Root cause: Node.js native fetch uses undici with a global HTTP connection pool that keeps connections alive by default (~30s timeout). This caused processes to hang after API calls completed, waiting for connections to close. Solution: Configure undici's HTTP Agent with very short keepAlive timeouts (1ms) and pass it to fetch calls via the dispatcher option. This ensures connections close immediately after requests complete. Changes: - Add undici (^7.16.0) as a dependency - Create HTTP agent with minimal keepAlive timeouts - Configure all native fetch calls to use the custom agent Testing: - Verified with real API calls: process exits in ~3s vs ~31s before fix - All Jest tests pass without timeout issues - No functional changes to API behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package-lock.json | 15 +++++++++++++++ package.json | 1 + src/utils/fetch-with-retry.ts | 12 ++++++++++++ 3 files changed, 28 insertions(+) diff --git a/package-lock.json b/package-lock.json index 9939ad79..c78a281f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "emoji-regex": "10.6.0", "form-data": "4.0.4", "ts-custom-error": "^3.2.0", + "undici": "^7.16.0", "uuid": "11.1.0", "zod": "4.1.12" }, @@ -8912,6 +8913,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -15713,6 +15723,11 @@ "which-boxed-primitive": "^1.0.2" } }, + "undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==" + }, "undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index bdccb335..10af96bd 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "emoji-regex": "10.6.0", "form-data": "4.0.4", "ts-custom-error": "^3.2.0", + "undici": "^7.16.0", "uuid": "11.1.0", "zod": "4.1.12" }, diff --git a/src/utils/fetch-with-retry.ts b/src/utils/fetch-with-retry.ts index e268627b..1d348a51 100644 --- a/src/utils/fetch-with-retry.ts +++ b/src/utils/fetch-with-retry.ts @@ -1,3 +1,4 @@ +import { Agent } from 'undici' import type { HttpResponse, RetryConfig, CustomFetch, CustomFetchResponse } from '../types/http' import { isNetworkError } from '../types/http' @@ -14,6 +15,15 @@ const DEFAULT_RETRY_CONFIG: RetryConfig = { }, } +/** + * HTTP agent with keepAlive disabled to prevent hanging connections + * This ensures the process exits immediately after requests complete + */ +const httpAgent = new Agent({ + keepAliveTimeout: 1, // Close connections after 1ms of idle time + keepAliveMaxTimeout: 1, // Maximum time to keep connections alive +}) + /** * Converts Headers object to a plain object */ @@ -113,6 +123,8 @@ export async function fetchWithRetry(args: { const nativeResponse = await fetch(url, { ...fetchOptions, signal: requestSignal, + // @ts-expect-error - dispatcher is a valid option for Node.js fetch but not in the TS types + dispatcher: httpAgent, }) fetchResponse = convertResponseToCustomFetch(nativeResponse) }