From 0bc2b64770b4dffcd850f991537f923ae984c839 Mon Sep 17 00:00:00 2001 From: nordicnode Date: Mon, 31 Aug 2026 10:50:21 -0700 Subject: [PATCH] fix(sdk): recover mid-stream provider 5xx/429 like severed connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider-reported 500/429 arriving mid-stream — the openai-compatible shim enqueues it as an error part with finishReason='error' — was thrown straight out of the stream and ended the entire run with an error. The same underlying transient event surfacing as a severed body instead took the capped recovery path (note injected into the conversation, retry step forced, capped at MAX_CONSECUTIVE_STREAM_RECOVERIES). The recoverable class was 'the connection failed to speak' and the fatal class was 'the provider reported a failure', which is backwards for flaky endpoints, where both are the same transient event. Route retryable APICallErrors (429, any 5xx) through the same capped recovery path with a message naming the HTTP status. Client-error statuses (400/401/402/403) are deterministic — retrying cannot help — so they stay fatal and still propagate to the run's error handling. Refs #1155 --- sdk/src/__tests__/stream-interruption.test.ts | 62 +++++++++++++++++++ sdk/src/impl/stream-interruption.ts | 24 ++++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/sdk/src/__tests__/stream-interruption.test.ts b/sdk/src/__tests__/stream-interruption.test.ts index c0bb099e85..7b4abdf4dd 100644 --- a/sdk/src/__tests__/stream-interruption.test.ts +++ b/sdk/src/__tests__/stream-interruption.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'bun:test' +import { APICallError } from 'ai' + import { classifyStreamEndRecovery, classifyThrownStreamRecovery, @@ -201,4 +203,64 @@ describe('classifyThrownStreamRecovery', () => { }), ).toBeNull() }) + + it('recovers a provider-reported 500 that arrived mid-stream', () => { + // The openai-compatible shim enqueues a provider 5xx as an error part + // carrying an APICallError — the same transient event as a severed body, + // so it takes the same capped recovery path instead of ending the run. + const recovery = classifyThrownStreamRecovery({ + aborted: false, + error: apiError(500, 'Internal Server Error'), + }) + expect(recovery?.source).toBe('stream-interrupted') + expect(recovery?.message).toContain('HTTP 500') + }) + + it('recovers a provider-reported 429 that arrived mid-stream', () => { + const recovery = classifyThrownStreamRecovery({ + aborted: false, + error: apiError(429, 'Too Many Requests'), + }) + expect(recovery?.source).toBe('stream-interrupted') + expect(recovery?.message).toContain('HTTP 429') + }) + + it('recovers a wrapped provider 503 behind a RetryError cause chain', () => { + const error = new Error('Failed after 4 attempts', { + cause: apiError(503, 'Service Unavailable'), + }) + expect( + classifyThrownStreamRecovery({ aborted: false, error })?.source, + ).toBe('stream-interrupted') + }) + + it('leaves client-error statuses fatal', () => { + for (const statusCode of [400, 401, 402, 403, 404]) { + expect( + classifyThrownStreamRecovery({ + aborted: false, + error: apiError(statusCode, `HTTP ${statusCode}`), + }), + ).toBeNull() + } + }) + + it('does not recover a provider 5xx after user cancellation', () => { + expect( + classifyThrownStreamRecovery({ + aborted: true, + error: apiError(500, 'Internal Server Error'), + }), + ).toBeNull() + }) }) + +function apiError(statusCode: number, message: string): APICallError { + return new APICallError({ + message, + url: 'https://openrouter.ai/api/v1/chat/completions', + requestBodyValues: { prompt: 'x' }, + statusCode, + isRetryable: statusCode === 429 || statusCode >= 500, + }) +} diff --git a/sdk/src/impl/stream-interruption.ts b/sdk/src/impl/stream-interruption.ts index 0e03aca310..0feb147b09 100644 --- a/sdk/src/impl/stream-interruption.ts +++ b/sdk/src/impl/stream-interruption.ts @@ -30,7 +30,10 @@ */ import type { StreamRecoverySource } from '@codebuff/common/types/contracts/llm' -import { isTransientNetworkError } from '@codebuff/common/util/error' +import { + extractApiErrorDetails, + isTransientNetworkError, +} from '@codebuff/common/util/error' export interface StreamFinishInfo { finishReason: string @@ -134,11 +137,26 @@ export function classifyStreamEndRecovery(params: { * `ConnectionClosed` / `ECONNRESET`) instead of the graceful-but-incomplete * stream ending handled by {@link classifyStreamEndRecovery}. Both represent * the same recoverable condition to the agent loop. + * + * A provider-reported 5xx/429 that arrives mid-stream — the openai-compatible + * shim enqueues it as an `error` part with `finishReason='error'` — is the + * same transient event as a severed body: the upstream had a bad moment, and + * the retry the agent loop forces (capped) is the response either way. A + * client-error status (400/401/402/403) is deterministic — retrying cannot + * help — so it stays fatal and propagates to the run's error handling. */ export function classifyThrownStreamRecovery(params: { aborted: boolean error: unknown }): StreamEndRecovery | null { - if (params.aborted || !isTransientNetworkError(params.error)) return null - return STREAM_INTERRUPTED_RECOVERY + if (params.aborted) return null + if (isTransientNetworkError(params.error)) return STREAM_INTERRUPTED_RECOVERY + const { statusCode } = extractApiErrorDetails(params.error) + if (statusCode === 429 || (statusCode !== undefined && statusCode >= 500)) { + return { + source: 'stream-interrupted', + message: `The provider reported a temporary failure (HTTP ${statusCode}) while the response was streaming, so the output above may be cut off mid-thought. Continue from where it left off (or start the step over if nothing useful arrived).`, + } + } + return null }