diff --git a/package-lock.json b/package-lock.json index ddc99a2d..798b9cab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3041,16 +3041,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, "node_modules/@types/node/node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -3588,18 +3578,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3658,18 +3636,6 @@ "node": ">= 14" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -5474,15 +5440,6 @@ "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -5859,34 +5816,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, - "node_modules/formdata-node/node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -6328,15 +6257,6 @@ "node": ">= 14" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -7621,25 +7541,13 @@ } }, "node_modules/openai": { - "version": "4.104.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", - "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "version": "6.44.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz", + "integrity": "sha512-09/gH+8jH0RgUwsgWHAaxsKGRT5zVZ95IaJUnqAWj6XejIBmnFRwq2WUIF37VtDEsmGrtPmvCs5+yBSeZGWvkA==", "license": "Apache-2.0", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - }, - "bin": { - "openai": "bin/cli" - }, "peerDependencies": { "ws": "^8.18.0", - "zod": "^3.23.8" + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { "ws": { @@ -7650,35 +7558,6 @@ } } }, - "node_modules/openai/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/openai/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9579,12 +9458,6 @@ "node": ">=16" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -9922,12 +9795,6 @@ "node": ">=20.18.1" } }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -10286,12 +10153,6 @@ "node": ">= 8" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -10302,16 +10163,6 @@ "node": ">=20" } }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10770,7 +10621,7 @@ "moment": "^2.24.0", "mysql": "^2.17.1", "node-cron": "^4.2.1", - "openai": "^4.103.0", + "openai": "^6.44.0", "prom-client": "^15.1.3", "sentence-splitter": "^5.0.0", "sentiment": "^5.0.2", diff --git a/packages/backend/package.json b/packages/backend/package.json index 321c9bc3..208ea869 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -40,7 +40,7 @@ "moment": "^2.24.0", "mysql": "^2.17.1", "node-cron": "^4.2.1", - "openai": "^4.103.0", + "openai": "^6.44.0", "prom-client": "^15.1.3", "sentence-splitter": "^5.0.0", "sentiment": "^5.0.2", diff --git a/packages/backend/src/lib/resilientOpenAIClient.spec.ts b/packages/backend/src/lib/resilientOpenAIClient.spec.ts index de1acf2a..095a919f 100644 --- a/packages/backend/src/lib/resilientOpenAIClient.spec.ts +++ b/packages/backend/src/lib/resilientOpenAIClient.spec.ts @@ -129,6 +129,28 @@ describe('ResilientOpenAIClient', () => { expect(result).toEqual(makeResponse('ok after 429')); }); + it('retries on FetchError with Premature close and succeeds', async () => { + const transientFetchError = Object.assign( + new Error('Invalid response body while trying to fetch https://api.openai.com/v1/responses: Premature close'), + { + name: 'FetchError', + }, + ); + const createMock = vi + .fn() + .mockRejectedValueOnce(transientFetchError) + .mockResolvedValue(makeResponse('ok after premature close')); + + const client = new ResilientOpenAIClient(makeUnderlying(createMock), fastConfig(), registry); + + const resultPromise = client.responses.create({ model: 'gpt-4o', input: 'hi' }); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + expect(createMock).toHaveBeenCalledTimes(2); + expect(result).toEqual(makeResponse('ok after premature close')); + }); + it('honors Retry-After header on 429 response', async () => { const retryAfterSeconds = 2; const rateLimitError = Object.assign(new Error('Rate limited'), { diff --git a/packages/backend/src/lib/resilientOpenAIClient.ts b/packages/backend/src/lib/resilientOpenAIClient.ts index 60422262..0768a639 100644 --- a/packages/backend/src/lib/resilientOpenAIClient.ts +++ b/packages/backend/src/lib/resilientOpenAIClient.ts @@ -360,33 +360,22 @@ export class ResilientOpenAIClient implements OpenAIClientLike { // --------------------------------------------------------------------------- private isRetriable(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - // Timeout errors are retriable if (error instanceof ResilientOpenAIError && error.code === 'TIMEOUT') { return true; } - // Network / connection errors - if ( - error.message.includes('ECONNRESET') || - error.message.includes('ECONNREFUSED') || - error.message.includes('ETIMEDOUT') || - error.message.includes('socket hang up') || - error.message.includes('fetch failed') || - error.message.includes('network') - ) { - return true; - } - // OpenAI HTTP status codes: 429 (rate-limit) and 5xx are retriable const statusCode = this.getStatusCode(error); if (statusCode === 429 || (statusCode !== undefined && statusCode >= 500)) { return true; } + // Network / transport failures are typically transient and should be retried. + if (this.hasRetriableNetworkSignal(error)) { + return true; + } + return false; } @@ -398,6 +387,51 @@ export class ResilientOpenAIClient implements OpenAIClientLike { return undefined; } + private hasRetriableNetworkSignal(error: unknown): boolean { + const queue: unknown[] = [error]; + const visited = new Set(); + + while (queue.length > 0) { + const current = queue.shift(); + if (typeof current !== 'object' || current === null || visited.has(current)) { + continue; + } + + visited.add(current); + + const message = Reflect.get(current, 'message'); + const name = Reflect.get(current, 'name'); + const code = Reflect.get(current, 'code'); + + const normalizedSignals = [message, name, code] + .filter((value): value is string => typeof value === 'string') + .map((value) => value.toLowerCase()) + .join(' '); + + if ( + normalizedSignals.includes('econnreset') || + normalizedSignals.includes('econnrefused') || + normalizedSignals.includes('etimedout') || + normalizedSignals.includes('socket hang up') || + normalizedSignals.includes('fetch failed') || + normalizedSignals.includes('fetcherror') || + normalizedSignals.includes('premature close') || + normalizedSignals.includes('und_err_socket') || + normalizedSignals.includes('network error') || + normalizedSignals.includes('networkerror') + ) { + return true; + } + + const cause = Reflect.get(current, 'cause'); + if (cause !== undefined) { + queue.push(cause); + } + } + + return false; + } + // --------------------------------------------------------------------------- // Backoff with full jitter; respects Retry-After header on 429 // ---------------------------------------------------------------------------