From e20b41fffca7a08f11656e603f516c345c8e04ad Mon Sep 17 00:00:00 2001 From: Void Freud <246163318+voidfreud@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:15:03 +0300 Subject: [PATCH] fix: allow slower remote embedding responses --- gitnexus/.env.example | 1 + gitnexus/README.md | 1 + gitnexus/src/core/embeddings/http-client.ts | 23 ++++++++++++++++++--- gitnexus/test/unit/http-embedder.test.ts | 15 ++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/gitnexus/.env.example b/gitnexus/.env.example index 8b90c7ae7..16baa3a75 100644 --- a/gitnexus/.env.example +++ b/gitnexus/.env.example @@ -10,6 +10,7 @@ # GITNEXUS_EMBEDDING_MAX_ATTEMPTS=3 # GITNEXUS_EMBEDDING_RETRY_CAP_MS=5000 # GITNEXUS_EMBEDDING_MIN_INTERVAL_MS=0 +# GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS=180000 # Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. # See README for details. diff --git a/gitnexus/README.md b/gitnexus/README.md index 5c4c7b72a..db24b2693 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -296,6 +296,7 @@ export GITNEXUS_EMBEDDING_API_KEY=your-key # optional, default: "unused" export GITNEXUS_EMBEDDING_MAX_ATTEMPTS=3 # optional, total attempts (1-20) export GITNEXUS_EMBEDDING_RETRY_CAP_MS=5000 # optional, maximum retry delay export GITNEXUS_EMBEDDING_MIN_INTERVAL_MS=0 # optional, minimum request spacing +export GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS=180000 # optional, per-request timeout (max 300000) gitnexus analyze . --embeddings ``` diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts index f3eb3bb5a..e937cd364 100644 --- a/gitnexus/src/core/embeddings/http-client.ts +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -13,7 +13,8 @@ import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared'; -const HTTP_TIMEOUT_MS = 30_000; +const DEFAULT_HTTP_TIMEOUT_MS = 180_000; +const MAX_HTTP_TIMEOUT_MS = 300_000; const HTTP_MAX_RETRIES = 2; const HTTP_RETRY_BACKOFF_MS = 1_000; const HTTP_RETRY_CAP_MS = 5_000; @@ -21,6 +22,8 @@ const HTTP_BATCH_SIZE = 64; const DEFAULT_DIMS = 384; const HTTP_BREAKER_KEY = 'embeddings-http'; +const HTTP_TIMEOUT_ENV = 'GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS'; + interface HttpConfig { baseUrl: string; model: string; @@ -29,6 +32,7 @@ interface HttpConfig { maxAttempts: number; retryCapMs: number; minIntervalMs: number; + timeoutMs: number; requestDimensions?: number; } @@ -187,6 +191,11 @@ const readConfig = (): HttpConfig | null => { 300_000, ), minIntervalMs: parseNonNegativeIntegerEnv('GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', 0, 300_000), + timeoutMs: parsePositiveIntegerEnv( + HTTP_TIMEOUT_ENV, + DEFAULT_HTTP_TIMEOUT_MS, + MAX_HTTP_TIMEOUT_MS, + ), requestDimensions, }; }; @@ -209,6 +218,11 @@ export const isHttpMode = (): boolean => */ export const getHttpDimensions = (): number | undefined => readConfig()?.dimensions; +/** + * Return the configured per-request HTTP timeout for HTTP mode, or undefined + * when HTTP mode is not active. + */ +export const getHttpTimeoutMs = (): number | undefined => readConfig()?.timeoutMs; /** * Return a safe representation of a URL for logs and error messages. * Strips query string (may contain tokens) and userinfo (may contain @@ -323,6 +337,7 @@ const httpEmbedBatch = async ( maxAttempts = HTTP_MAX_RETRIES + 1, retryCapMs = HTTP_RETRY_CAP_MS, minIntervalMs = 0, + timeoutMs = DEFAULT_HTTP_TIMEOUT_MS, ): Promise => { const requestBody: { input: string[]; model: string; dimensions?: number } = { input: batch, @@ -349,7 +364,7 @@ const httpEmbedBatch = async ( fetchImpl: async (input, init) => { await paceHttpRequest(minIntervalMs, requestOptions.signal); throwIfAborted(requestOptions.signal); - const timeoutSignal = AbortSignal.timeout(HTTP_TIMEOUT_MS); + const timeoutSignal = AbortSignal.timeout(timeoutMs); const signal = requestOptions.signal ? AbortSignal.any([requestOptions.signal, timeoutSignal]) : timeoutSignal; @@ -383,7 +398,7 @@ const httpEmbedBatch = async ( } if (err instanceof DOMException && err.name === 'TimeoutError') { throw new HttpEmbeddingError( - `Embedding request timed out after ${HTTP_TIMEOUT_MS}ms (${safeUrl(url)}, batch ${batchIndex})`, + `Embedding request timed out after ${timeoutMs}ms (${safeUrl(url)}, batch ${batchIndex})`, { cause: err }, ); } @@ -464,6 +479,7 @@ export const httpEmbed = async ( config.maxAttempts, config.retryCapMs, config.minIntervalMs, + config.timeoutMs, ); if (items.length !== batch.length) { @@ -521,6 +537,7 @@ export const httpEmbedQuery = async ( config.maxAttempts, config.retryCapMs, config.minIntervalMs, + config.timeoutMs, ); if (!items.length) { throw new HttpEmbeddingError(`Embedding endpoint returned empty response (${safeUrl(url)})`); diff --git a/gitnexus/test/unit/http-embedder.test.ts b/gitnexus/test/unit/http-embedder.test.ts index fb4dde0af..52cd10866 100644 --- a/gitnexus/test/unit/http-embedder.test.ts +++ b/gitnexus/test/unit/http-embedder.test.ts @@ -6,6 +6,7 @@ const ENV_KEYS = [ 'GITNEXUS_EMBEDDING_MODEL', 'GITNEXUS_EMBEDDING_API_KEY', 'GITNEXUS_EMBEDDING_DIMS', + 'GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS', 'GITNEXUS_EMBEDDING_MAX_ATTEMPTS', 'GITNEXUS_EMBEDDING_RETRY_CAP_MS', 'GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', @@ -682,6 +683,19 @@ describe('HTTP embedding backend', () => { }); describe('timeout and network error handling', () => { + it('uses a 180-second default timeout and accepts a bounded override', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + + const { getHttpTimeoutMs } = await import('../../src/core/embeddings/http-client.js'); + expect(getHttpTimeoutMs()).toBe(180_000); + + process.env.GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS = '120000'; + expect(getHttpTimeoutMs()).toBe(120_000); + process.env.GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS = '300001'; + expect(() => getHttpTimeoutMs()).toThrow('GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS'); + }); + it('does not retry on timeout', async () => { process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; @@ -814,6 +828,7 @@ describe('HTTP embedding backend', () => { }); it.each([ + ['GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS', '0'], ['GITNEXUS_EMBEDDING_MAX_ATTEMPTS', '0'], ['GITNEXUS_EMBEDDING_RETRY_CAP_MS', '-1'], ['GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', 'nope'],