Skip to content

Commit 0bc2b64

Browse files
committed
fix(sdk): recover mid-stream provider 5xx/429 like severed connections
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
1 parent 28663a3 commit 0bc2b64

2 files changed

Lines changed: 83 additions & 3 deletions

File tree

sdk/src/__tests__/stream-interruption.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, it } from 'bun:test'
22

3+
import { APICallError } from 'ai'
4+
35
import {
46
classifyStreamEndRecovery,
57
classifyThrownStreamRecovery,
@@ -201,4 +203,64 @@ describe('classifyThrownStreamRecovery', () => {
201203
}),
202204
).toBeNull()
203205
})
206+
207+
it('recovers a provider-reported 500 that arrived mid-stream', () => {
208+
// The openai-compatible shim enqueues a provider 5xx as an error part
209+
// carrying an APICallError — the same transient event as a severed body,
210+
// so it takes the same capped recovery path instead of ending the run.
211+
const recovery = classifyThrownStreamRecovery({
212+
aborted: false,
213+
error: apiError(500, 'Internal Server Error'),
214+
})
215+
expect(recovery?.source).toBe('stream-interrupted')
216+
expect(recovery?.message).toContain('HTTP 500')
217+
})
218+
219+
it('recovers a provider-reported 429 that arrived mid-stream', () => {
220+
const recovery = classifyThrownStreamRecovery({
221+
aborted: false,
222+
error: apiError(429, 'Too Many Requests'),
223+
})
224+
expect(recovery?.source).toBe('stream-interrupted')
225+
expect(recovery?.message).toContain('HTTP 429')
226+
})
227+
228+
it('recovers a wrapped provider 503 behind a RetryError cause chain', () => {
229+
const error = new Error('Failed after 4 attempts', {
230+
cause: apiError(503, 'Service Unavailable'),
231+
})
232+
expect(
233+
classifyThrownStreamRecovery({ aborted: false, error })?.source,
234+
).toBe('stream-interrupted')
235+
})
236+
237+
it('leaves client-error statuses fatal', () => {
238+
for (const statusCode of [400, 401, 402, 403, 404]) {
239+
expect(
240+
classifyThrownStreamRecovery({
241+
aborted: false,
242+
error: apiError(statusCode, `HTTP ${statusCode}`),
243+
}),
244+
).toBeNull()
245+
}
246+
})
247+
248+
it('does not recover a provider 5xx after user cancellation', () => {
249+
expect(
250+
classifyThrownStreamRecovery({
251+
aborted: true,
252+
error: apiError(500, 'Internal Server Error'),
253+
}),
254+
).toBeNull()
255+
})
204256
})
257+
258+
function apiError(statusCode: number, message: string): APICallError {
259+
return new APICallError({
260+
message,
261+
url: 'https://openrouter.ai/api/v1/chat/completions',
262+
requestBodyValues: { prompt: 'x' },
263+
statusCode,
264+
isRetryable: statusCode === 429 || statusCode >= 500,
265+
})
266+
}

sdk/src/impl/stream-interruption.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@
3030
*/
3131

3232
import type { StreamRecoverySource } from '@codebuff/common/types/contracts/llm'
33-
import { isTransientNetworkError } from '@codebuff/common/util/error'
33+
import {
34+
extractApiErrorDetails,
35+
isTransientNetworkError,
36+
} from '@codebuff/common/util/error'
3437

3538
export interface StreamFinishInfo {
3639
finishReason: string
@@ -134,11 +137,26 @@ export function classifyStreamEndRecovery(params: {
134137
* `ConnectionClosed` / `ECONNRESET`) instead of the graceful-but-incomplete
135138
* stream ending handled by {@link classifyStreamEndRecovery}. Both represent
136139
* the same recoverable condition to the agent loop.
140+
*
141+
* A provider-reported 5xx/429 that arrives mid-stream — the openai-compatible
142+
* shim enqueues it as an `error` part with `finishReason='error'` — is the
143+
* same transient event as a severed body: the upstream had a bad moment, and
144+
* the retry the agent loop forces (capped) is the response either way. A
145+
* client-error status (400/401/402/403) is deterministic — retrying cannot
146+
* help — so it stays fatal and propagates to the run's error handling.
137147
*/
138148
export function classifyThrownStreamRecovery(params: {
139149
aborted: boolean
140150
error: unknown
141151
}): StreamEndRecovery | null {
142-
if (params.aborted || !isTransientNetworkError(params.error)) return null
143-
return STREAM_INTERRUPTED_RECOVERY
152+
if (params.aborted) return null
153+
if (isTransientNetworkError(params.error)) return STREAM_INTERRUPTED_RECOVERY
154+
const { statusCode } = extractApiErrorDetails(params.error)
155+
if (statusCode === 429 || (statusCode !== undefined && statusCode >= 500)) {
156+
return {
157+
source: 'stream-interrupted',
158+
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).`,
159+
}
160+
}
161+
return null
144162
}

0 commit comments

Comments
 (0)