Skip to content

Commit bbe1183

Browse files
authored
fix(knowledge): stop retrying an embedding key with no credit left (#6868)
OpenAI answers an exhausted balance with 429, the same status as a rate limit, but the two are not alike: a rate limit reopens and a spent account does not. Both were classified transient, so every document burned its full retry budget against a key that could never accept it, and because the sweep re-queues failed documents on every sync the account turned into permanent load rather than a one-off failure. A connector sync ran the full hour and timed out doing this. The rejection body is what separates them — insufficient_quota, or a credit_balance_exhausted code — so it is read when the error is built and the retries stop immediately. Retrying and failing over are decided separately here. An exhausted balance rules out the key just used but says nothing about the next provider in the chain, so the error stays eligible for failover and only the retries against the spent key are dropped.
1 parent efea9de commit bbe1183

2 files changed

Lines changed: 129 additions & 2 deletions

File tree

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,87 @@ describe('knowledge embedding transport fallback', () => {
822822
expect(result.embeddings).toEqual([[4, 4]])
823823
})
824824

825+
/**
826+
* OpenAI returns 429 for an exhausted balance as well as for a rate limit, but
827+
* only one of them reopens. Retrying a spent account cannot succeed, and since
828+
* the sweep re-queues failed documents every sync it turns into permanent load
829+
* — this was observed burning every attempt on thousands of documents for
830+
* weeks against an account with no credit.
831+
*/
832+
it('does not retry a 429 that reports an exhausted balance', async () => {
833+
vi.useFakeTimers()
834+
setEnv({ OPENAI_API_KEY: 'openai-test' })
835+
const fetchMock = vi.fn().mockImplementation(
836+
async () =>
837+
({
838+
ok: false,
839+
status: 429,
840+
statusText: 'Too Many Requests',
841+
headers: new Headers(),
842+
json: async () => ({}),
843+
text: async () =>
844+
JSON.stringify({
845+
error: {
846+
message: 'You have no credits remaining.',
847+
type: 'insufficient_quota',
848+
code: 'credit_balance_exhausted',
849+
},
850+
}),
851+
}) as Response
852+
)
853+
vi.stubGlobal('fetch', fetchMock)
854+
855+
const pending = embed(['hello'], { ...options, apiKey: 'openai-test' }).catch((e) => e)
856+
await vi.runAllTimersAsync()
857+
const error = await pending
858+
859+
expect(error).toBeInstanceOf(EmbeddingAPIError)
860+
expect(fetchMock).toHaveBeenCalledTimes(1)
861+
})
862+
863+
/**
864+
* A rate limit with the same status must keep its retries — the two are only
865+
* distinguishable by the body.
866+
*/
867+
it('still retries a 429 that reports a rate limit', async () => {
868+
vi.useFakeTimers()
869+
setEnv({ OPENAI_API_KEY: 'openai-test' })
870+
let call = 0
871+
const fetchMock = vi.fn().mockImplementation(async () => {
872+
call++
873+
if (call === 1) {
874+
return {
875+
ok: false,
876+
status: 429,
877+
statusText: 'Too Many Requests',
878+
headers: new Headers(),
879+
json: async () => ({}),
880+
text: async () =>
881+
JSON.stringify({ error: { message: 'slow down', type: 'rate_limit_exceeded' } }),
882+
} as Response
883+
}
884+
return jsonResponse(openAIBody([[5, 5]], 2))
885+
})
886+
vi.stubGlobal('fetch', fetchMock)
887+
888+
const pending = embed(['hello'], { ...options, apiKey: 'openai-test' })
889+
await vi.runAllTimersAsync()
890+
const result = await pending
891+
892+
expect(fetchMock).toHaveBeenCalledTimes(2)
893+
expect(result.embeddings).toEqual([[5, 5]])
894+
})
895+
896+
/**
897+
* An exhausted key rules out the key just used, not the next one in the chain,
898+
* so failover must still consider it.
899+
*/
900+
it('keeps an exhausted-balance error eligible for failover', () => {
901+
const error = new EmbeddingAPIError('Embedding API failed: 429', 429)
902+
error.quotaExhausted = true
903+
expect(isTransientEmbeddingError(error)).toBe(true)
904+
})
905+
825906
it('classifies only transient embedding failures for failover', () => {
826907
expect(isTransientEmbeddingError(new EmbeddingAPIError('unavailable', 503))).toBe(true)
827908
expect(isTransientEmbeddingError(new EmbeddingAPIError('rate limited', 429))).toBe(true)

apps/sim/lib/embeddings/client.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ const EMBEDDING_RETRY_BUDGET_MS = EMBEDDING_MAX_RETRIES * EMBEDDING_MAX_RETRY_DE
7979
export class EmbeddingAPIError extends Error {
8080
public status: number
8181

82+
/**
83+
* The provider rejected this for an exhausted balance rather than a rate that
84+
* will recover. Both arrive as 429.
85+
*/
86+
public quotaExhausted?: boolean
87+
8288
/**
8389
* Wait the provider asked for, read from the rejected response. Consumed by
8490
* {@link retryWithExponentialBackoff}, which prefers it over its own backoff.
@@ -92,6 +98,31 @@ export class EmbeddingAPIError extends Error {
9298
}
9399
}
94100

101+
/**
102+
* True when a rejection body reports an exhausted balance rather than a rate
103+
* limit.
104+
*
105+
* OpenAI returns 429 for both, but only one of them reopens. `insufficient_quota`
106+
* stands until somebody adds credit, so retrying it cannot succeed no matter how
107+
* long the loop waits — and because a failed document is re-queued by the sweep
108+
* on every sync, an account that has run out turns into a permanent load: the
109+
* budget is spent per document, per attempt, forever.
110+
*/
111+
function isQuotaExhaustionBody(errorText: string): boolean {
112+
try {
113+
const body = JSON.parse(errorText) as { error?: { type?: string; code?: string } }
114+
const type = body.error?.type
115+
const code = body.error?.code
116+
return (
117+
type === 'insufficient_quota' ||
118+
code === 'insufficient_quota' ||
119+
code === 'credit_balance_exhausted'
120+
)
121+
} catch {
122+
return false
123+
}
124+
}
125+
95126
/**
96127
* True when the provider's stated wait outlasts the entire retry budget.
97128
*
@@ -116,6 +147,21 @@ function statedWaitOutlastsBudget(error: unknown): boolean {
116147
)
117148
}
118149

150+
/**
151+
* Whether another attempt against the same provider could plausibly succeed.
152+
*
153+
* Deliberately narrower than {@link isTransientEmbeddingError}, which also
154+
* decides whether the fallback chain should try a *different* provider. Those
155+
* two questions differ: an exhausted balance rules out the key we just used, but
156+
* says nothing about the next one in the chain, so a quota rejection stops the
157+
* retries here while remaining eligible for failover.
158+
*/
159+
function isWorthRetrying(error: unknown): boolean {
160+
if (!isTransientEmbeddingError(error)) return false
161+
if (error instanceof EmbeddingAPIError && error.quotaExhausted) return false
162+
return !statedWaitOutlastsBudget(error)
163+
}
164+
119165
export function isTransientEmbeddingError(error: unknown): boolean {
120166
if (error instanceof EmbeddingAPIError) {
121167
return error.status === 429 || error.status >= 500
@@ -251,6 +297,7 @@ async function callEmbeddingAPI(
251297
`Embedding API failed: ${response.status} ${response.statusText} - ${errorText}`,
252298
response.status
253299
)
300+
error.quotaExhausted = isQuotaExhaustionBody(errorText)
254301

255302
/**
256303
* Carry the provider's own answer to "when may I retry" onto the error,
@@ -298,8 +345,7 @@ async function callEmbeddingAPI(
298345
maxRetries: EMBEDDING_MAX_RETRIES,
299346
initialDelayMs: 1000,
300347
maxDelayMs: EMBEDDING_MAX_RETRY_DELAY_MS,
301-
retryCondition: (error) =>
302-
isTransientEmbeddingError(error) && !statedWaitOutlastsBudget(error),
348+
retryCondition: isWorthRetrying,
303349
}
304350
)
305351
}

0 commit comments

Comments
 (0)