Skip to content

Commit 2c614ae

Browse files
authored
fix(providers): retry transient failures on OpenAI Responses and Gemini (#8383)
* fix(providers): retry transient failures on OpenAI Responses and Gemini * fix(providers): read 429 bodies without a tee, honor Gemini RetryInfo, resolve shadowed payloads * improvement(providers): surface long server delays, narrow transport retries, pin SDK retry budgets * fix(providers): classify transport failures by socket error code, not message * fix(providers): cancel a discarded retry body instead of draining it * fix(providers): bound how long a 429 body may take to classify * fix(providers): limit transport retries to network socket codes
1 parent 2827611 commit 2c614ae

18 files changed

Lines changed: 870 additions & 79 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* True when a provider rejection body reports an exhausted balance rather than a rate
3+
* limit. OpenAI returns 429 for both, but only a rate limit reopens: a spent account
4+
* stands until someone adds credit, so retrying it cannot succeed.
5+
*/
6+
export function isQuotaExhaustionBody(errorText: string): boolean {
7+
try {
8+
const body = JSON.parse(errorText) as { error?: { type?: string; code?: string } }
9+
const type = body.error?.type
10+
const code = body.error?.code
11+
return (
12+
type === 'insufficient_quota' ||
13+
code === 'insufficient_quota' ||
14+
code === 'credit_balance_exhausted'
15+
)
16+
} catch {
17+
return false
18+
}
19+
}

‎apps/sim/lib/core/errors/retryable-infrastructure.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ export function isRetryableInfrastructureError(error: unknown): boolean {
9797
return Boolean(describeRetryableInfrastructureError(error))
9898
}
9999

100+
/**
101+
* A network-level failure only — a dropped, refused, or timed-out socket anywhere in the
102+
* cause chain. Narrower than {@link isRetryableInfrastructureError}: database and application
103+
* codes are excluded, so a caller replaying a billed request never mistakes one for a socket.
104+
*/
105+
export function isRetryableNetworkError(error: unknown): boolean {
106+
return getErrorChain(error).some(
107+
(candidate) =>
108+
(typeof candidate.code === 'string' && RETRYABLE_NETWORK_ERROR_CODES.has(candidate.code)) ||
109+
(typeof candidate.errno === 'string' && RETRYABLE_NETWORK_ERROR_CODES.has(candidate.errno))
110+
)
111+
}
112+
100113
/**
101114
* A retryable infrastructure failure raised strictly BEFORE the guarded
102115
* operation performed any effect (no workflow block ran, no mutation

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

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
wireFallback,
1212
} from '@/lib/core/config/env-capabilities'
1313
import { isHosted } from '@/lib/core/config/env-flags'
14+
import { isQuotaExhaustionBody } from '@/lib/core/errors/provider-quota'
1415
import {
1516
ProviderQuotaExhaustedError,
1617
recordProviderCooldown,
@@ -254,26 +255,6 @@ export function isBYOKEmbeddingCredentialRejection(error: unknown): error is Emb
254255
)
255256
}
256257

257-
/**
258-
* True when a rejection body reports an exhausted balance rather than a rate
259-
* limit. OpenAI returns 429 for both, but only a rate limit reopens: a spent
260-
* account stands until someone adds credit, so retrying it cannot succeed.
261-
*/
262-
function isQuotaExhaustionBody(errorText: string): boolean {
263-
try {
264-
const body = JSON.parse(errorText) as { error?: { type?: string; code?: string } }
265-
const type = body.error?.type
266-
const code = body.error?.code
267-
return (
268-
type === 'insufficient_quota' ||
269-
code === 'insufficient_quota' ||
270-
code === 'credit_balance_exhausted'
271-
)
272-
} catch {
273-
return false
274-
}
275-
}
276-
277258
/** Reads a bounded provider body for internal diagnostics and quota classification. */
278259
async function readEmbeddingErrorBody(response: Response, signal?: AbortSignal): Promise<string> {
279260
try {

‎apps/sim/providers/anthropic/index.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { StreamingExecution } from '@/executor/types'
44
import { executeAnthropicProviderRequest } from '@/providers/anthropic/core'
55
import { getCachedProviderClient } from '@/providers/client-cache'
66
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
7+
import { PROVIDER_MAX_RETRIES } from '@/providers/transport'
78
import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types'
89

910
const logger = createLogger('AnthropicProvider')
@@ -24,7 +25,10 @@ export const anthropicProvider: ProviderConfig = {
2425
providerLabel: 'Anthropic',
2526
createClient: (apiKey) => {
2627
const cacheKey = `anthropic::${apiKey}`
27-
return getCachedProviderClient(cacheKey, () => new Anthropic({ apiKey }))
28+
return getCachedProviderClient(
29+
cacheKey,
30+
() => new Anthropic({ apiKey, maxRetries: PROVIDER_MAX_RETRIES })
31+
)
2832
},
2933
logger,
3034
})

‎apps/sim/providers/azure-anthropic/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { StreamingExecution } from '@/executor/types'
66
import { executeAnthropicProviderRequest } from '@/providers/anthropic/core'
77
import { getCachedProviderClient } from '@/providers/client-cache'
88
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
9+
import { PROVIDER_MAX_RETRIES } from '@/providers/transport'
910
import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types'
1011

1112
const logger = createLogger('AzureAnthropicProvider')
@@ -79,6 +80,7 @@ export const azureAnthropicProvider: ProviderConfig = {
7980
new Anthropic({
8081
baseURL,
8182
apiKey,
83+
maxRetries: PROVIDER_MAX_RETRIES,
8284
...(pinnedFetch ? { fetch: pinnedFetch } : {}),
8385
defaultHeaders: {
8486
'anthropic-version': anthropicVersion,

‎apps/sim/providers/azure-openai/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
4848
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
4949
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
5050
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
51+
import { openAICompatTransport } from '@/providers/transport'
5152
import type {
5253
FunctionCallResponse,
5354
ProviderConfig,
@@ -97,6 +98,7 @@ async function executeChatCompletionsRequest(
9798
apiKey: request.apiKey!,
9899
apiVersion: azureApiVersion,
99100
endpoint: azureEndpoint,
101+
...openAICompatTransport(),
100102
...(pinnedFetch ? { fetch: pinnedFetch } : {}),
101103
})
102104

‎apps/sim/providers/conversation-generation-coverage.test.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,55 @@ function insideNamedAncestor(node: ts.Node, name: string): boolean {
2121
}
2222

2323
function preparedPayload(node: ts.Node): boolean {
24+
if (ts.isIdentifier(node)) return preparedBinding(node)
2425
return (
2526
ts.isAwaitExpression(node) &&
2627
ts.isCallExpression(node.expression) &&
2728
node.expression.expression.getText() === 'prepareConversationGeneration'
2829
)
2930
}
3031

32+
/**
33+
* Whether the nearest declaration of `identifier` is a `const` bound to a prepared payload.
34+
* Retried sends prepare once and replay the binding, since preparing can itself call a model
35+
* to compact history. The nearest declaration decides, so an inner shadow must be prepared too.
36+
*/
37+
function preparedBinding(identifier: ts.Identifier): boolean {
38+
const declares = (name: ts.BindingName): boolean =>
39+
ts.isIdentifier(name)
40+
? name.text === identifier.text
41+
: name.elements.some((element) => !ts.isOmittedExpression(element) && declares(element.name))
42+
for (let scope = identifier.parent; scope; scope = scope.parent) {
43+
if (ts.isFunctionLike(scope) && scope.parameters.some((parameter) => declares(parameter.name)))
44+
return false
45+
if (
46+
ts.isCatchClause(scope) &&
47+
scope.variableDeclaration &&
48+
declares(scope.variableDeclaration.name)
49+
)
50+
return false
51+
if (
52+
(ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
53+
scope.initializer &&
54+
ts.isVariableDeclarationList(scope.initializer) &&
55+
scope.initializer.declarations.some((declaration) => declares(declaration.name))
56+
)
57+
return false
58+
if (!ts.isBlock(scope) && !ts.isSourceFile(scope)) continue
59+
for (const statement of scope.statements) {
60+
if (!ts.isVariableStatement(statement)) continue
61+
const declaration = statement.declarationList.declarations.find((d) => declares(d.name))
62+
if (!declaration) continue
63+
return (
64+
Boolean(statement.declarationList.flags & ts.NodeFlags.Const) &&
65+
declaration.initializer !== undefined &&
66+
preparedPayload(declaration.initializer)
67+
)
68+
}
69+
}
70+
return false
71+
}
72+
3173
describe('provider generation context coverage', () => {
3274
it('guards every model SDK send, shared stream callback, retry and finalizer', () => {
3375
const uncovered: string[] = []
@@ -60,7 +102,7 @@ describe('provider generation context coverage', () => {
60102
file.endsWith('/openai-compat/streaming-tool-loop.ts')
61103
) {
62104
argument = 0
63-
} else if (callee === 'JSON.stringify' && insideNamedAncestor(node, 'postOnce')) {
105+
} else if (callee === 'JSON.stringify' && insideNamedAncestor(node, 'post')) {
64106
argument = 0
65107
}
66108
if (argument !== undefined) {

‎apps/sim/providers/gemini/core.test.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import type { GenerateContentParameters, GenerateContentResponse } from '@google/genai'
1+
import {
2+
ApiError,
3+
type GenerateContentParameters,
4+
type GenerateContentResponse,
5+
} from '@google/genai'
26
import { providersMock } from '@sim/testing/mocks/providers.mock'
37
import {
48
providersConversationHistoryMock,
@@ -142,6 +146,25 @@ describe('Vertex Gemini request compatibility', () => {
142146
}
143147
)
144148

149+
/** The SDK's own retry is off (it would replace every error message), so ours must run. */
150+
it('replays a transient server error before reporting it', async () => {
151+
vi.useFakeTimers()
152+
try {
153+
const generateContent = vi
154+
.fn()
155+
.mockRejectedValueOnce(new ApiError({ message: 'Internal error', status: 500 }))
156+
.mockResolvedValue(textTurn())
157+
158+
const pending = run('vertex/gemini-3.8-flash', generateContent)
159+
await vi.runAllTimersAsync()
160+
161+
await expect(pending).resolves.toMatchObject({ content: 'answer' })
162+
expect(generateContent).toHaveBeenCalledTimes(2)
163+
} finally {
164+
vi.useRealTimers()
165+
}
166+
})
167+
145168
it('prices Vertex-only catalog entries using the namespaced model ID', async () => {
146169
const generateContent = vi.fn().mockResolvedValue(textTurn())
147170
const result = await run('vertex/gemini-3.8-flash', generateContent)

‎apps/sim/providers/gemini/core.ts‎

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@ import {
3232
ensureStructResponse,
3333
extractAllFunctionCallParts,
3434
extractTextContent,
35+
geminiRetryDelayMs,
3536
mapToThinkingBudget,
3637
mapToThinkingLevel,
3738
supportsDisablingGemini25Thinking,
3839
} from '@/providers/google/utils'
3940
import { getModelCapabilities, isKnownModelId } from '@/providers/models'
41+
import { withProviderRetry } from '@/providers/retry'
4042
import { executeProviderTool } from '@/providers/runtime-context'
4143
import { createSettledAgentEventStream } from '@/providers/stream-events'
4244
import { createStreamingExecution } from '@/providers/streaming-execution'
@@ -962,6 +964,12 @@ export async function executeGeminiRequest(
962964
}
963965

964966
const logger = createLogger(providerType === 'google' ? 'GoogleProvider' : 'VertexProvider')
967+
const retryOptions = {
968+
logger,
969+
label: providerType === 'google' ? 'Gemini' : 'Vertex AI',
970+
abortSignal: request.abortSignal,
971+
retryAfterMs: geminiRetryDelayMs,
972+
}
965973

966974
logger.info(`Preparing ${providerType} Gemini request`, {
967975
model,
@@ -1148,12 +1156,14 @@ export async function executeGeminiRequest(
11481156
if (shouldStream) {
11491157
logger.info('Handling Gemini streaming response')
11501158

1151-
const streamGenerator = await ai.models.generateContentStream(
1152-
await prepareConversationGeneration(request, 'gemini', {
1153-
model,
1154-
contents,
1155-
config: geminiConfig,
1156-
})
1159+
const streamPayload = await prepareConversationGeneration(request, 'gemini', {
1160+
model,
1161+
contents,
1162+
config: geminiConfig,
1163+
})
1164+
const streamGenerator = await withProviderRetry(
1165+
() => ai.models.generateContentStream(streamPayload),
1166+
retryOptions
11571167
)
11581168
const firstResponseTime = Date.now() - initialCallTime
11591169

@@ -1202,12 +1212,14 @@ export async function executeGeminiRequest(
12021212
}
12031213

12041214
// Non-streaming request
1205-
const response = await ai.models.generateContent(
1206-
await prepareConversationGeneration(request, 'gemini', {
1207-
model,
1208-
contents,
1209-
config: geminiConfig,
1210-
})
1215+
const responsePayload = await prepareConversationGeneration(request, 'gemini', {
1216+
model,
1217+
contents,
1218+
config: geminiConfig,
1219+
})
1220+
const response = await withProviderRetry(
1221+
() => ai.models.generateContent(responsePayload),
1222+
retryOptions
12111223
)
12121224
if (!extractAllFunctionCallParts(response.candidates?.[0]).length) {
12131225
await captureProviderConversationStep(
@@ -1256,12 +1268,14 @@ export async function executeGeminiRequest(
12561268
}
12571269

12581270
const finalStartTime = Date.now()
1259-
const finalResponse = await ai.models.generateContent(
1260-
await prepareConversationGeneration(request, 'gemini', {
1261-
model,
1262-
contents: currentState.contents,
1263-
config: finalConfig,
1264-
})
1271+
const finalResponsePayload = await prepareConversationGeneration(request, 'gemini', {
1272+
model,
1273+
contents: currentState.contents,
1274+
config: finalConfig,
1275+
})
1276+
const finalResponse = await withProviderRetry(
1277+
() => ai.models.generateContent(finalResponsePayload),
1278+
retryOptions
12651279
)
12661280
if (!extractAllFunctionCallParts(finalResponse.candidates?.[0]).length) {
12671281
await captureProviderConversationStep(
@@ -1375,12 +1389,14 @@ export async function executeGeminiRequest(
13751389

13761390
/** Resolve the final turn, then project its settled answer when streaming was requested. */
13771391
const nextModelStartTime = Date.now()
1378-
const nextResponse = await ai.models.generateContent(
1379-
await prepareConversationGeneration(request, 'gemini', {
1380-
model,
1381-
contents: state.contents,
1382-
config: nextConfig,
1383-
})
1392+
const nextResponsePayload = await prepareConversationGeneration(request, 'gemini', {
1393+
model,
1394+
contents: state.contents,
1395+
config: nextConfig,
1396+
})
1397+
const nextResponse = await withProviderRetry(
1398+
() => ai.models.generateContent(nextResponsePayload),
1399+
retryOptions
13841400
)
13851401
if (!extractAllFunctionCallParts(nextResponse.candidates?.[0]).length) {
13861402
await captureProviderConversationStep(

‎apps/sim/providers/gemini/streaming-tool-loop.ts‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ import {
3737
cleanSchemaForGemini,
3838
convertUsageMetadata,
3939
ensureStructResponse,
40+
geminiRetryDelayMs,
4041
} from '@/providers/google/utils'
42+
import { withProviderRetry } from '@/providers/retry'
4143
import { executeProviderTool } from '@/providers/runtime-context'
4244
import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events'
4345
import {
@@ -309,15 +311,22 @@ export function createGeminiStreamingToolLoopStream(
309311
)
310312

311313
const modelStart = Date.now()
312-
const streamGenerator = await ai.models.generateContentStream(
313-
await prepareConversationGeneration(request, 'gemini', {
314-
model,
315-
contents,
316-
config: {
317-
...turnConfig,
318-
abortSignal: loopAbortController.signal,
319-
},
320-
})
314+
const turnPayload = await prepareConversationGeneration(request, 'gemini', {
315+
model,
316+
contents,
317+
config: {
318+
...turnConfig,
319+
abortSignal: loopAbortController.signal,
320+
},
321+
})
322+
const streamGenerator = await withProviderRetry(
323+
() => ai.models.generateContentStream(turnPayload),
324+
{
325+
logger,
326+
label: 'Gemini',
327+
abortSignal: loopAbortController.signal,
328+
retryAfterMs: geminiRetryDelayMs,
329+
}
321330
)
322331

323332
const drained = await drainGeminiTurn(

0 commit comments

Comments
 (0)