diff --git a/src/lib/model-result.ts b/src/lib/model-result.ts index 78336de2e..637fb09e2 100644 --- a/src/lib/model-result.ts +++ b/src/lib/model-result.ts @@ -66,10 +66,12 @@ import { isResponseCompletedEvent, isResponseFailedEvent, isResponseIncompleteEvent, + isErrorEvent, isOutputTextDeltaEvent, isReasoningDeltaEvent, hasTypeProperty, } from './stream-type-guards.js'; +import { StreamFailedError } from './stream-errors.js'; import { applyResponsesStreamWatchdog, hasActiveStreamTimeouts, @@ -319,6 +321,7 @@ export class ModelResult< const consumer = stream.createConsumer(); let completedResponse: models.OpenResponsesResult | null = null; + let errorEvent: models.ErrorEvent | null = null; for await (const event of consumer) { broadcaster.push(event); @@ -326,8 +329,13 @@ export class ModelResult< completedResponse = event.response; } if (isResponseFailedEvent(event)) { - const errorMsg = 'message' in event ? String(event.message) : 'Response failed'; - throw new Error(errorMsg); + throw StreamFailedError.fromFailedResponse(event.response); + } + if (isErrorEvent(event)) { + // Recorded, not thrown: only surfaced if the stream ends without a + // completed response, so an error followed by a successful + // completion keeps working. + errorEvent = event; } if (isResponseIncompleteEvent(event)) { completedResponse = event.response; @@ -341,6 +349,9 @@ export class ModelResult< } satisfies TurnEndEvent); if (!completedResponse) { + if (errorEvent) { + throw StreamFailedError.fromErrorEvent(errorEvent); + } throw new Error('Follow-up stream ended without a completed response'); } diff --git a/src/lib/stream-errors.ts b/src/lib/stream-errors.ts index 117ecc0ac..50532dea3 100644 --- a/src/lib/stream-errors.ts +++ b/src/lib/stream-errors.ts @@ -1,12 +1,20 @@ /** - * Errors for client-side stalled-stream detection (DEV-723). + * Errors for graceful stalled-stream and stream-failure handling (DEV-723). * * A streaming response can return headers quickly and then never emit a * content-bearing event (a "stalled stream"). The stream watchdog * (`stream-watchdog.ts`) enforces the configured deadlines and raises * `StreamStalledError` when one expires. + * + * Separately, the server can report a failure mid-stream — a + * `response.failed` event (carrying the failed response) or an `error` + * event (carrying code/message). Those surface as `StreamFailedError` + * instead of a bare `Error`, so callers can branch on the error code and + * retryability. */ +import type * as models from '../models/index.js'; + /** * Which watchdog deadline expired. * @@ -67,3 +75,86 @@ export class StreamStalledError extends Error { return !this.receivedAnyContent; } } + +/** + * Error codes that indicate a transient condition where retrying the + * request (ideally with backoff) is reasonable. Sourced from both the + * Responses error-field codes (e.g. `server_error`) and the canonical + * OpenRouter `ApiErrorType` values (e.g. `provider_overloaded`). + */ +const TRANSIENT_FAILURE_CODES: ReadonlySet = new Set([ + 'server_error', + 'server', + 'timeout', + 'rate_limit_exceeded', + 'provider_overloaded', + 'provider_unavailable', +]); + +/** + * Raised when the server reports a failure during streaming: a + * `response.failed` event, a stream-level `error` event, or a stream that + * ends after emitting an error without ever completing. + * + * Replaces the bare `new Error(...)` these paths used to throw, so + * consumers can inspect the code, canonical error type, and the failed + * response instead of string-matching messages. + */ +export class StreamFailedError extends Error { + override readonly name = 'StreamFailedError'; + + /** Provider/API error code (e.g. `server_error`), when reported. */ + readonly code: string | null; + /** Canonical OpenRouter error type (`ApiErrorType`), when reported. */ + readonly errorType: string | null; + /** The failed response object, when the failure came from `response.failed`. */ + readonly response: models.OpenResponsesResult | null; + + constructor(options: { + message: string; + code?: string | null | undefined; + errorType?: string | null | undefined; + response?: models.OpenResponsesResult | null | undefined; + }) { + super(options.message); + this.code = options.code ?? null; + this.errorType = options.errorType ?? null; + this.response = options.response ?? null; + + Object.setPrototypeOf(this, StreamFailedError.prototype); + } + + /** + * Whether the failure looks transient (server error, timeout, rate + * limit, provider overloaded/unavailable) and a retry with backoff is + * reasonable. Validation-style failures (invalid prompt, content + * policy, ...) return false. + */ + get retryable(): boolean { + return ( + (this.code !== null && TRANSIENT_FAILURE_CODES.has(this.code)) || + (this.errorType !== null && TRANSIENT_FAILURE_CODES.has(this.errorType)) + ); + } + + /** Build from a `response.failed` event's response payload. */ + static fromFailedResponse(response: models.OpenResponsesResult): StreamFailedError { + const code = typeof response.error?.code === 'string' ? response.error.code : null; + const errorType = typeof response.errorType === 'string' ? response.errorType : null; + const detail = response.error?.message ?? 'no error detail provided'; + return new StreamFailedError({ + message: `Response failed${code ? ` (${code})` : ''}: ${detail}`, + code, + errorType, + response, + }); + } + + /** Build from a stream-level `error` event. */ + static fromErrorEvent(event: { code: string | null; message: string }): StreamFailedError { + return new StreamFailedError({ + message: `Stream error${event.code ? ` (${event.code})` : ''}: ${event.message}`, + code: event.code, + }); + } +} diff --git a/src/lib/stream-transformers.ts b/src/lib/stream-transformers.ts index 4c0dd0c2b..000b6ce06 100644 --- a/src/lib/stream-transformers.ts +++ b/src/lib/stream-transformers.ts @@ -8,6 +8,7 @@ import type { } from '../models/claude-message.js'; import type { ReusableReadableStream } from './reusable-stream.js'; import type { ParsedToolCall, Tool } from './tool-types.js'; +import { StreamFailedError } from './stream-errors.js'; import { isOutputTextDeltaEvent, isReasoningDeltaEvent, @@ -17,6 +18,7 @@ import { isResponseCompletedEvent, isResponseFailedEvent, isResponseIncompleteEvent, + isErrorEvent, isFunctionCallArgumentsDoneEvent, isOutputMessage, isFunctionCallItem, @@ -503,6 +505,7 @@ export async function consumeStreamForCompletion( stream: ReusableReadableStream, ): Promise { const consumer = stream.createConsumer(); + let errorEvent: models.ErrorEvent | null = null; for await (const event of consumer) { if (!('type' in event)) { @@ -515,7 +518,14 @@ export async function consumeStreamForCompletion( if (isResponseFailedEvent(event)) { // The failed event contains the full response with error information - throw new Error(`Response failed: ${JSON.stringify(event.response.error)}`); + throw StreamFailedError.fromFailedResponse(event.response); + } + + if (isErrorEvent(event)) { + // Recorded, not thrown: only surfaced if the stream ends without a + // completion event, so an error followed by a successful completion + // keeps working. + errorEvent = event; } if (isResponseIncompleteEvent(event)) { @@ -524,6 +534,9 @@ export async function consumeStreamForCompletion( } } + if (errorEvent) { + throw StreamFailedError.fromErrorEvent(errorEvent); + } throw new Error('Stream ended without completion event'); } diff --git a/src/lib/stream-type-guards.ts b/src/lib/stream-type-guards.ts index 01de468e9..5c6308815 100644 --- a/src/lib/stream-type-guards.ts +++ b/src/lib/stream-type-guards.ts @@ -49,6 +49,10 @@ export function isResponseFailedEvent( return 'type' in event && event.type === 'response.failed'; } +export function isErrorEvent(event: models.StreamEvents): event is models.ErrorEvent { + return 'type' in event && event.type === 'error'; +} + export function isResponseIncompleteEvent( event: models.StreamEvents, ): event is models.StreamEventsResponseIncomplete { diff --git a/src/sdk/sdk.ts b/src/sdk/sdk.ts index e61fc0d40..d107db20f 100644 --- a/src/sdk/sdk.ts +++ b/src/sdk/sdk.ts @@ -43,7 +43,11 @@ import type { RequestOptions } from "../lib/sdks.js"; import { type Tool, ToolType } from "../lib/tool-types.js"; export { ToolType }; -export { StreamStalledError, type StreamStallPhase } from "../lib/stream-errors.js"; +export { + StreamFailedError, + StreamStalledError, + type StreamStallPhase, +} from "../lib/stream-errors.js"; export type { StreamTimeoutOptions } from "../lib/stream-watchdog.js"; // #endregion imports diff --git a/tests/unit/stream-failed-error.test.ts b/tests/unit/stream-failed-error.test.ts new file mode 100644 index 000000000..956e44eea --- /dev/null +++ b/tests/unit/stream-failed-error.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; +import { OpenRouter } from '../../src/index.js'; +import { HTTPClient } from '../../src/lib/http.js'; +import { StreamFailedError } from '../../src/lib/stream-errors.js'; + +// ============================================================================ +// SSE fixture helpers (wire shapes) +// ============================================================================ + +function resultJson(options: { + status: 'in_progress' | 'completed' | 'failed'; + output?: unknown[]; + outputText?: string; + error?: { code: string; message: string } | null; + errorType?: string; +}): Record { + return { + id: 'resp_1', + object: 'response', + created_at: 0, + completed_at: options.status === 'completed' ? 1 : null, + model: 'test-model', + status: options.status, + output: options.output ?? [], + ...(options.outputText !== undefined ? { output_text: options.outputText } : {}), + error: options.error ?? null, + ...(options.errorType !== undefined ? { error_type: options.errorType } : {}), + incomplete_details: null, + instructions: null, + metadata: null, + temperature: null, + top_p: null, + presence_penalty: null, + frequency_penalty: null, + tools: [], + tool_choice: 'auto', + parallel_tool_calls: false, + }; +} + +function messageItem(text: string): Record { + return { + type: 'message', + id: 'msg_1', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text, annotations: [] }], + }; +} + +function sse(event: Record): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +/** Build a client whose single request answers with the given SSE frames then closes. */ +function sseClient(frames: string[]): OpenRouter { + const encoder = new TextEncoder(); + const fetcher = async (): Promise => { + const body = new ReadableStream({ + start(controller) { + for (const frame of frames) { + controller.enqueue(encoder.encode(frame)); + } + controller.close(); + }, + }); + return new Response(body, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + }; + return new OpenRouter({ + apiKey: 'test-api-key', + httpClient: new HTTPClient({ fetcher }), + retryConfig: { strategy: 'none' }, + }); +} + +// ============================================================================ +// StreamFailedError unit behavior +// ============================================================================ + +describe('StreamFailedError', () => { + it('classifies transient codes as retryable', () => { + for (const code of [ + 'server_error', + 'timeout', + 'rate_limit_exceeded', + 'provider_overloaded', + 'provider_unavailable', + ]) { + const error = new StreamFailedError({ message: 'x', code }); + expect(error.retryable, code).toBe(true); + } + }); + + it('classifies validation-style codes as not retryable', () => { + for (const code of ['invalid_prompt', 'image_too_large', 'bio_policy', null]) { + const error = new StreamFailedError({ message: 'x', code }); + expect(error.retryable, String(code)).toBe(false); + } + }); + + it('falls back to errorType for retryability when code is unhelpful', () => { + const error = new StreamFailedError({ + message: 'x', + code: 'something_unknown', + errorType: 'provider_overloaded', + }); + expect(error.retryable).toBe(true); + }); + + it('is an instanceof Error and StreamFailedError with a stable name', () => { + const error = new StreamFailedError({ message: 'x' }); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(StreamFailedError); + expect(error.name).toBe('StreamFailedError'); + }); +}); + +// ============================================================================ +// End-to-end surfacing through callModel +// ============================================================================ + +describe('callModel server-failure surfacing', () => { + it('surfaces response.failed as StreamFailedError with code, type, and response', async () => { + const client = sseClient([ + sse({ + type: 'response.created', + sequence_number: 0, + response: resultJson({ status: 'in_progress' }), + }), + sse({ + type: 'response.failed', + sequence_number: 1, + response: resultJson({ + status: 'failed', + error: { code: 'server_error', message: 'upstream provider exploded' }, + errorType: 'provider_unavailable', + }), + }), + 'data: [DONE]\n\n', + ]); + + const result = client.callModel({ model: 'test-model', input: 'hi' }); + const error = await result.getText().then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(StreamFailedError); + const failure = error as StreamFailedError; + expect(failure.code).toBe('server_error'); + expect(failure.errorType).toBe('provider_unavailable'); + expect(failure.message).toContain('server_error'); + expect(failure.message).toContain('upstream provider exploded'); + expect(failure.response?.status).toBe('failed'); + expect(failure.retryable).toBe(true); + }); + + it('surfaces a non-retryable response.failed correctly', async () => { + const client = sseClient([ + sse({ + type: 'response.failed', + sequence_number: 0, + response: resultJson({ + status: 'failed', + error: { code: 'invalid_prompt', message: 'prompt rejected' }, + }), + }), + 'data: [DONE]\n\n', + ]); + + const result = client.callModel({ model: 'test-model', input: 'hi' }); + const error = await result.getText().then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(StreamFailedError); + expect((error as StreamFailedError).code).toBe('invalid_prompt'); + expect((error as StreamFailedError).retryable).toBe(false); + }); + + it('surfaces a stream-level error event as StreamFailedError when the stream never completes', async () => { + const client = sseClient([ + sse({ + type: 'response.created', + sequence_number: 0, + response: resultJson({ status: 'in_progress' }), + }), + sse({ + type: 'error', + code: 'server_error', + message: 'mid-stream failure', + param: null, + sequence_number: 1, + }), + 'data: [DONE]\n\n', + ]); + + const result = client.callModel({ model: 'test-model', input: 'hi' }); + const error = await result.getText().then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(StreamFailedError); + const failure = error as StreamFailedError; + expect(failure.code).toBe('server_error'); + expect(failure.message).toContain('mid-stream failure'); + expect(failure.response).toBeNull(); + }); + + it('an error event followed by a successful completion does not throw', async () => { + const client = sseClient([ + sse({ + type: 'error', + code: 'server_error', + message: 'transient blip, recovered', + param: null, + sequence_number: 0, + }), + sse({ + type: 'response.output_text.delta', + delta: 'Hello world', + item_id: 'msg_1', + content_index: 0, + output_index: 0, + logprobs: [], + sequence_number: 1, + }), + sse({ + type: 'response.completed', + sequence_number: 2, + response: resultJson({ + status: 'completed', + output: [messageItem('Hello world')], + outputText: 'Hello world', + }), + }), + 'data: [DONE]\n\n', + ]); + + const result = client.callModel({ model: 'test-model', input: 'hi' }); + expect(await result.getText()).toBe('Hello world'); + }); +});