Skip to content

Commit 7167ed6

Browse files
authored
fix(knowledge): stop one env knob from setting the embedding request fan-out (#6852)
* fix(knowledge): stop one env knob from setting the embedding request fan-out KB_CONFIG_CONCURRENCY_LIMIT was read in three places with three meanings: the document-processing queue depth, the number of embedding requests issued concurrently inside a single embed call, and (divided by five) the in-process document concurrency. The first two multiply — every admitted task run reaches the embed path and opens its own fan-out — so the default put roughly a thousand requests in flight against one provider key. A rate limit is per key, so the pipeline held itself at the limit, and no retry policy can absorb a load its own concurrency is generating. Each variable is now read by exactly one consumer, which also removes the drift that hid this: the same variable was read with a different inline fallback in each place, and since createEnv runs with skipValidation the declared defaults never execute, so the fallbacks were the real ones and disagreed. The divisors are gone and the previous effective values are the declared defaults, so only the embedding fan-out changes: 50 to 8. KB_CONFIG_BATCH_SIZE had the same conflation between chunks-per-embedding-request and documents-per-batch, and is split the same way. Rate-limit rejections also discarded what the provider said about when to come back. The response headers were dropped when building EmbeddingAPIError, so the retry loop's support for a server-stated wait was dead code on this path and every attempt fired blind, exhausting the budget inside a window that had not reopened. The headers now travel with the error the way fetchWithRetry already does for connectors, and the wait is read from Retry-After or, failing that, the reset header for whichever limit dimension is actually exhausted. Those carry a Go duration rather than the epoch seconds the shared connector helper expects, so the reading lives with the provider instead of changing retry behaviour for every connector. The retry budget is sized against a rate-limit window rather than a blip, since a 10s ceiling clamped every stated wait below the reopen time. * fix(knowledge): stop retrying an embedding wait we will not honor Honoring the provider's stated wait introduced a case the retry budget could not serve. When a provider states a reset longer than the ceiling, the loop clamps every attempt to that ceiling, so the whole budget is spent inside a window that has not reopened — and with five attempts at thirty seconds that delayed the fallback provider by around two and a half minutes, where the previous blind backoff reached it in about seven seconds. A stated wait past the ceiling now refuses the retry outright. The error still classifies as transient, and the fallback chain classifies separately through shouldFallback, so the next provider is reached immediately instead of after the budget burns down. Retrying was never going to succeed in that window, so nothing is given up. * fix(knowledge): measure a stated wait against the whole retry budget Refusing to retry once the stated wait passed the per-attempt ceiling was too blunt. Each wait is clamped individually but the attempts accumulate, so a window a little longer than one clamped delay still reopens partway through the budget: a 35s wait is reachable on the second attempt. Rejecting those stranded a caller with no fallback provider, which would have recovered by waiting. The comparison is now against the budget the attempts span in total. A window inside it is retried and can recover; only one that outlasts every attempt is unreachable, and that still fails fast so the fallback chain is reached at once rather than after the budget burns down.
1 parent fc2087b commit 7167ed6

6 files changed

Lines changed: 419 additions & 17 deletions

File tree

apps/sim/lib/core/config/env.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,11 @@ export const env = createEnv({
390390
KB_CONFIG_RETRY_FACTOR: z.number().optional().default(2), // Retry backoff factor
391391
KB_CONFIG_MIN_TIMEOUT: z.number().optional().default(1000), // Min timeout in ms
392392
KB_CONFIG_MAX_TIMEOUT: z.number().optional().default(10000), // Max timeout in ms
393-
KB_CONFIG_CONCURRENCY_LIMIT: z.number().optional().default(50), // Concurrent embedding API calls
393+
KB_CONFIG_CONCURRENCY_LIMIT: z.number().optional().default(20), // Concurrent document-processing task runs (Trigger.dev queue depth)
394+
KB_CONFIG_EMBEDDING_CONCURRENCY: z.number().optional().default(8), // Concurrent embedding API requests within one embed call
395+
KB_CONFIG_DOCUMENT_CONCURRENCY: z.number().optional().default(4), // Concurrent documents in the in-process (non-Trigger) path
394396
KB_CONFIG_BATCH_SIZE: z.number().optional().default(2000), // Chunks to process per embedding batch
397+
KB_CONFIG_DOCUMENT_BATCH_SIZE: z.number().optional().default(10), // Documents per batch in the in-process (non-Trigger) path
395398
KB_CONFIG_DELAY_BETWEEN_BATCHES: z.number().optional().default(0), // Delay between batches in ms (0 for max speed)
396399
KB_CONFIG_DELAY_BETWEEN_DOCUMENTS: z.number().optional().default(50), // Delay between documents in ms
397400
KB_CONFIG_CHUNK_CONCURRENCY: z.number().optional().default(10), // Concurrent PDF chunk OCR processing
@@ -727,6 +730,13 @@ export { getEnv }
727730
* `z.number()` arrive as raw strings when sourced from `process.env` or Helm.
728731
* Use this helper anywhere a numeric env override is consumed to normalize the
729732
* type at the boundary instead of relying on JS implicit coercion.
733+
*
734+
* Skipping validation also means the schema never runs, so a `.default(...)` in
735+
* the declaration above never executes: **the fallback passed here is the real
736+
* default**, and the declared one is documentation. Keep the two in agreement —
737+
* a variable read in more than one place with a different fallback each time has
738+
* no single default at all, which is how one knob came to set both the
739+
* document-processing queue depth and the embedding request fan-out.
730740
*/
731741
export function envNumber(
732742
value: number | string | undefined | null,

apps/sim/lib/embeddings/client.test.ts

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { resetEnvMock, setEnv } from '@sim/testing'
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66
import {
7+
EMBEDDING_MAX_RETRIES,
78
EmbeddingAPIError,
89
embed,
910
embedKnowledgeForDeployment,
@@ -28,11 +29,13 @@ vi.mock('@/lib/api-key/byok', () => ({
2829

2930
const originalFetch = global.fetch
3031

31-
function jsonResponse(body: unknown, status = 200): Response {
32+
function jsonResponse(body: unknown, status = 200, responseHeaders?: HeadersInit): Response {
3233
return {
3334
ok: status >= 200 && status < 300,
3435
status,
3536
statusText: String(status),
37+
// A real Response always carries these; the failure path reads them for rate-limit signals.
38+
headers: new Headers(responseHeaders),
3639
json: async () => body,
3740
text: async () => JSON.stringify(body),
3841
} as Response
@@ -677,11 +680,12 @@ describe('knowledge embedding transport fallback', () => {
677680
await vi.runAllTimersAsync()
678681
const result = await pending
679682

680-
expect(fetchMock).toHaveBeenCalledTimes(5)
681-
expect(fetchMock.mock.calls.slice(0, 4).every(([url]) => url.includes('api.openai.com'))).toBe(
682-
true
683-
)
684-
expect(fetchMock.mock.calls[4][0]).toBe('https://openrouter.ai/api/v1/embeddings')
683+
const attempts = EMBEDDING_MAX_RETRIES + 1
684+
expect(fetchMock).toHaveBeenCalledTimes(attempts + 1)
685+
expect(
686+
fetchMock.mock.calls.slice(0, attempts).every(([url]) => url.includes('api.openai.com'))
687+
).toBe(true)
688+
expect(fetchMock.mock.calls[attempts][0]).toBe('https://openrouter.ai/api/v1/embeddings')
685689
expect(projectInputs).toHaveBeenCalledOnce()
686690
expect(result.embeddings).toEqual([[7, 8]])
687691
})
@@ -713,13 +717,111 @@ describe('knowledge embedding transport fallback', () => {
713717
.filter(([url]) => url === 'https://openrouter.ai/api/v1/embeddings')
714718
.flatMap(([, init]) => JSON.parse((init as RequestInit).body as string).input as string[])
715719
expect(openRouterInputs).toEqual([secondInput])
716-
expect(fetchMock).toHaveBeenCalledTimes(6)
720+
// The succeeding batch, every attempt on the failing one, then its fallback.
721+
expect(fetchMock).toHaveBeenCalledTimes(1 + (EMBEDDING_MAX_RETRIES + 1) + 1)
717722
expect(result.embeddings).toEqual([[1], [2]])
718723
expect(result.totalTokens).toBe(6)
719724
expect(result.billableTokens).toBe(3)
720725
expect(result.isBYOK).toBe(false)
721726
})
722727

728+
/**
729+
* The retry loop replaces its own backoff with a provider-stated wait, but only
730+
* if the wait reaches it. Nothing downstream of the transport could see the
731+
* response headers, so a rate-limited embedding request retried blind.
732+
*/
733+
it('carries the provider-stated retry wait onto the thrown error', async () => {
734+
vi.useFakeTimers()
735+
setEnv({ OPENAI_API_KEY: 'openai-test' })
736+
fetchMock.mockResolvedValue({
737+
ok: false,
738+
status: 429,
739+
statusText: '429',
740+
headers: new Headers({ 'retry-after': '42' }),
741+
json: async () => ({ error: 'rate limited' }),
742+
text: async () => 'rate limited',
743+
} as Response)
744+
745+
const pending = embed(['hello'], { ...options, apiKey: 'openai-test' }).catch((e) => e)
746+
await vi.runAllTimersAsync()
747+
const error = await pending
748+
749+
expect(error).toBeInstanceOf(EmbeddingAPIError)
750+
expect(error.status).toBe(429)
751+
expect(error.retryAfterMs).toBe(42_000)
752+
})
753+
754+
/**
755+
* A stated wait past the ceiling cannot be honoured, so retrying only clamps
756+
* every attempt below the reopen time and spends the budget for nothing. The
757+
* error still classifies as transient, so the fallback chain takes over at
758+
* once rather than after the retries burn down.
759+
*/
760+
it('does not retry a wait it cannot honour, falling back immediately', async () => {
761+
vi.useFakeTimers()
762+
setEnv({ OPENAI_API_KEY: 'openai-test', OPENROUTER_API_KEY: 'or-test' })
763+
const fetchMock = vi.fn().mockImplementation(async (url: string) =>
764+
url === 'https://api.openai.com/v1/embeddings'
765+
? ({
766+
ok: false,
767+
status: 429,
768+
statusText: '429',
769+
// Six minutes, far past EMBEDDING_MAX_RETRY_DELAY_MS.
770+
headers: new Headers({
771+
'x-ratelimit-remaining-tokens': '0',
772+
'x-ratelimit-reset-tokens': '6m0s',
773+
}),
774+
json: async () => ({ error: 'rate limited' }),
775+
text: async () => 'rate limited',
776+
} as Response)
777+
: jsonResponse(openAIBody([[9, 9]], 2))
778+
)
779+
vi.stubGlobal('fetch', fetchMock)
780+
781+
const pending = embedKnowledgeForDeployment(['hello'], options, false)
782+
await vi.runAllTimersAsync()
783+
const result = await pending
784+
785+
const openAICalls = fetchMock.mock.calls.filter(([url]) => url.includes('api.openai.com'))
786+
expect(openAICalls).toHaveLength(1)
787+
expect(result.embeddings).toEqual([[9, 9]])
788+
})
789+
790+
/**
791+
* A window shorter than the whole budget is still reachable: the individual
792+
* waits are clamped below it but they accumulate, so a later attempt lands
793+
* after it reopens. Refusing these would strand a single-provider caller that
794+
* had only to wait a little longer than one clamped delay.
795+
*/
796+
it('keeps retrying a wait the budget can outlast, and recovers', async () => {
797+
vi.useFakeTimers()
798+
setEnv({ OPENAI_API_KEY: 'openai-test' })
799+
let call = 0
800+
const fetchMock = vi.fn().mockImplementation(async () => {
801+
call++
802+
if (call === 1) {
803+
return {
804+
ok: false,
805+
status: 429,
806+
statusText: '429',
807+
// Above the per-attempt ceiling, well inside the total budget.
808+
headers: new Headers({ 'retry-after': '35' }),
809+
json: async () => ({ error: 'rate limited' }),
810+
text: async () => 'rate limited',
811+
} as Response
812+
}
813+
return jsonResponse(openAIBody([[4, 4]], 2))
814+
})
815+
vi.stubGlobal('fetch', fetchMock)
816+
817+
const pending = embed(['hello'], { ...options, apiKey: 'openai-test' })
818+
await vi.runAllTimersAsync()
819+
const result = await pending
820+
821+
expect(fetchMock).toHaveBeenCalledTimes(2)
822+
expect(result.embeddings).toEqual([[4, 4]])
823+
})
824+
723825
it('classifies only transient embedding failures for failover', () => {
724826
expect(isTransientEmbeddingError(new EmbeddingAPIError('unavailable', 503))).toBe(true)
725827
expect(isTransientEmbeddingError(new EmbeddingAPIError('rate limited', 429))).toBe(true)

apps/sim/lib/embeddings/client.ts

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,34 @@ import {
2020
import { resolveProviderKey } from '@/lib/embeddings/keys'
2121
import { DEFAULT_OPENROUTER_EMBEDDING_MODEL } from '@/lib/embeddings/openrouter-models'
2222
import { getAdapterFactory } from '@/lib/embeddings/providers'
23+
import { resolveEmbeddingRetryDelayMs } from '@/lib/embeddings/rate-limit'
2324
import type {
2425
EmbeddingProviderAdapter,
2526
EmbeddingTaskType,
2627
EmbedOptions,
2728
EmbedResult,
2829
OpenRouterEmbedOptions,
2930
} from '@/lib/embeddings/types'
30-
import { isRetryableError, retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils'
31+
import {
32+
attachRetryHeaders,
33+
isRetryableError,
34+
retryWithExponentialBackoff,
35+
} from '@/lib/knowledge/documents/utils'
3136
import { batchByTokenLimit, estimateTokenCount, truncateToTokenLimit } from '@/lib/tokenization'
3237

3338
const logger = createLogger('EmbeddingClient')
3439

35-
const MAX_CONCURRENT_BATCHES = envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 50)
40+
/**
41+
* Embedding requests issued concurrently within a single embed call.
42+
*
43+
* A provider's rate limit is per API key, so this multiplies with however many
44+
* documents are being processed at once: the document-processing queue admits
45+
* {@link env.KB_CONFIG_CONCURRENCY_LIMIT} task runs, each reaching here. It was
46+
* previously read from that same variable, so one knob set both factors and the
47+
* product reached four figures of in-flight requests against one key — enough to
48+
* hold a provider at its limit indefinitely, which no retry policy can absorb.
49+
*/
50+
const MAX_CONCURRENT_BATCHES = envNumber(env.KB_CONFIG_EMBEDDING_CONCURRENCY, 8)
3651
const EMBEDDING_REQUEST_TIMEOUT_MS = 60_000
3752

3853
/**
@@ -47,16 +62,60 @@ const EMBEDDING_REQUEST_TIMEOUT_MS = 60_000
4762
*/
4863
const BATCH_TOKEN_TARGET = 8192
4964

65+
/** Retries after the initial attempt, per embedding request. */
66+
export const EMBEDDING_MAX_RETRIES = 5
67+
68+
/** Ceiling on a single wait between embedding attempts, including a provider-stated one. */
69+
export const EMBEDDING_MAX_RETRY_DELAY_MS = 30_000
70+
71+
/**
72+
* Longest a request can stay in the retry loop: every attempt waits at most the
73+
* ceiling, so the budget is what the attempts span in total. A provider window
74+
* that reopens inside this is still reachable even though each individual wait
75+
* is clamped below it.
76+
*/
77+
const EMBEDDING_RETRY_BUDGET_MS = EMBEDDING_MAX_RETRIES * EMBEDDING_MAX_RETRY_DELAY_MS
78+
5079
export class EmbeddingAPIError extends Error {
5180
public status: number
5281

82+
/**
83+
* Wait the provider asked for, read from the rejected response. Consumed by
84+
* {@link retryWithExponentialBackoff}, which prefers it over its own backoff.
85+
*/
86+
public retryAfterMs?: number
87+
5388
constructor(message: string, status: number) {
5489
super(message)
5590
this.name = 'EmbeddingAPIError'
5691
this.status = status
5792
}
5893
}
5994

95+
/**
96+
* True when the provider's stated wait outlasts the entire retry budget.
97+
*
98+
* The comparison is against {@link EMBEDDING_RETRY_BUDGET_MS} rather than the
99+
* per-attempt ceiling, because a window longer than one wait is still reachable:
100+
* the attempts are clamped individually but accumulate, so a 35s window reopens
101+
* before the second one lands. Only a window outlasting every attempt is
102+
* genuinely unreachable, and retrying into it spends the budget waiting on
103+
* something that cannot happen.
104+
*
105+
* The error stays transient — it just is not worth retrying here — so refusing
106+
* the retry surfaces it immediately and the fallback chain, which classifies
107+
* separately via `shouldFallback`, reaches the next provider at once. Where no
108+
* fallback is configured the request fails either way; this only decides whether
109+
* it fails now or after the budget burns down for nothing.
110+
*/
111+
function statedWaitOutlastsBudget(error: unknown): boolean {
112+
return (
113+
error instanceof EmbeddingAPIError &&
114+
error.retryAfterMs !== undefined &&
115+
error.retryAfterMs > EMBEDDING_RETRY_BUDGET_MS
116+
)
117+
}
118+
60119
export function isTransientEmbeddingError(error: unknown): boolean {
61120
if (error instanceof EmbeddingAPIError) {
62121
return error.status === 429 || error.status >= 500
@@ -188,10 +247,27 @@ async function callEmbeddingAPI(
188247

189248
if (!response.ok) {
190249
const errorText = await response.text()
191-
throw new EmbeddingAPIError(
250+
const error = new EmbeddingAPIError(
192251
`Embedding API failed: ${response.status} ${response.statusText} - ${errorText}`,
193252
response.status
194253
)
254+
255+
/**
256+
* Carry the provider's own answer to "when may I retry" onto the error,
257+
* the way `fetchWithRetry` does for connectors. Without it the retry
258+
* loop had nothing but blind exponential backoff and would exhaust every
259+
* attempt inside a rate-limit window that had not yet reopened.
260+
*
261+
* The headers travel non-enumerably so the retry condition can re-read
262+
* them without the bag reaching a log line.
263+
*/
264+
attachRetryHeaders(error, response.headers)
265+
const waitMs = resolveEmbeddingRetryDelayMs(response.headers)
266+
if (waitMs !== null) {
267+
error.retryAfterMs = waitMs
268+
}
269+
270+
throw error
195271
}
196272

197273
const json = await response.json()
@@ -208,10 +284,22 @@ async function callEmbeddingAPI(
208284
return { embeddings, totalTokens }
209285
},
210286
{
211-
maxRetries: 3,
287+
/**
288+
* Sized against a rate-limit window rather than a transient blip. The
289+
* provider states its reset in tens of seconds, and the loop clamps that
290+
* stated wait to `maxDelayMs` — at the previous 10s ceiling every attempt
291+
* fired before the window reopened, so the budget was spent without one
292+
* retry landing in the reopened window.
293+
*
294+
* Bounded so a fully saturated provider cannot outlive the task: five
295+
* attempts at the ceiling is well inside `KB_CONFIG_MAX_DURATION`, and
296+
* batches wait concurrently rather than one after another.
297+
*/
298+
maxRetries: EMBEDDING_MAX_RETRIES,
212299
initialDelayMs: 1000,
213-
maxDelayMs: 10000,
214-
retryCondition: isTransientEmbeddingError,
300+
maxDelayMs: EMBEDDING_MAX_RETRY_DELAY_MS,
301+
retryCondition: (error) =>
302+
isTransientEmbeddingError(error) && !statedWaitOutlastsBudget(error),
215303
}
216304
)
217305
}

0 commit comments

Comments
 (0)