diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index cf36642d53..e396e67c54 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -205,6 +205,37 @@ describe('Provider error classification', () => { ); }); + test('retries transport failures wrapped as "Cannot connect to API" when isRetryable is set', () => { + // The AI SDK wraps TLS/transport failures as APICallError with isRetryable: + // true and a message like "Cannot connect to API: ". Maka's classifier + // must honor the isRetryable flag even when the wrapped message contains no + // recognized network keywords (#3756). + const tlsFailure = Object.assign( + new Error( + 'Cannot connect to API: 80E1BDF601000000:error:0A000119:SSL routines:tls_get_more_records:decryption failed or bad record mac:../deps/openssl/openssl/ssl/record/methods/tls_common.c:869:', + ), + { + name: 'AI_APICallError', + isRetryable: true, + }, + ); + + assert.equal(classifyError(tlsFailure), 'AI_APICallError'); + assert.deepEqual(providerRetryMetadata(tlsFailure), { retryable: true }); + assert.equal(providerFailureDiagnostic(tlsFailure).retryable, true); + }); + + test('honors isRetryable on a cause error when the top-level error has no structured evidence', () => { + const cause = Object.assign(new Error('ECONNRESET'), { isRetryable: true }); + const wrapped = Object.assign(new Error('Cannot connect to API: ECONNRESET'), { + name: 'AI_APICallError', + cause, + }); + + assert.equal(classifyError(wrapped), 'AI_APICallError'); + assert.deepEqual(providerRetryMetadata(wrapped), { retryable: true }); + }); + test('retries incremental Responses transport failures with stable classification', () => { const websocketFailure = Object.assign(new Error('closed before completion'), { name: 'OpenAiResponsesTransportError', diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 7a81e589d3..e56bcbf993 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -220,7 +220,13 @@ function parseRetryAfterMs(headers: Record): number | null | und */ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { const facts = normalizeProviderError(error); - if (!facts) return { retryable: false }; + if (!facts) { + // Even when normalizeProviderError cannot extract structured evidence, + // the AI SDK may have set isRetryable on the error or on a cause in the + // chain — honor that as a last-resort retry signal. + if (isRetryableInChain(error)) return { retryable: true }; + return { retryable: false }; + } const { evidence } = facts; if (RUNTIME_RETRYABLE_ERROR_CODES.has(evidence.code)) return { retryable: true }; @@ -246,7 +252,12 @@ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { status === 408 || status === 409 || (status >= 500 && status <= 599); - if (!retryable) return { retryable: false }; + if (!retryable) { + // The classifier did not surface a retryable class, but the AI SDK may + // have marked the error or a wrapped cause as retryable — honor that. + if (isRetryableInChain(error)) return { retryable: true }; + return { retryable: false }; + } if (retryAfterMs === null) return { retryable: false }; return { retryable: true, @@ -254,6 +265,29 @@ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { }; } +/** + * Walks the `cause` chain of an error looking for the AI SDK's `isRetryable` + * flag. The AI SDK sets `APICallError.isRetryable = true` for transport + * failures (TLS errors, connection resets, etc.) that it wraps as + * `Cannot connect to API: ${cause.message}`. Without this, Maka's classifier + * sees only the wrapped message and classifies it as non-retryable. + */ +function isRetryableInChain(error: unknown): boolean { + let current = error; + const seen = new Set(); + for (let depth = 0; depth < 8 && current !== undefined && !seen.has(current); depth += 1) { + seen.add(current); + if (typeof current === 'object' && current !== null) { + const record = current as Record; + if (record.isRetryable === true) return true; + current = record.cause; + } else { + break; + } + } + return false; +} + /** Collects `code`/`type` strings from a payload and from its `error` wrapper. */ function collectStructuredCodes(payload: unknown, out: string[]): void { const fromRecord = (record: Record | undefined) => {