Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/lib/model-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -319,15 +321,21 @@ 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);
if (isResponseCompletedEvent(event)) {
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;
Expand All @@ -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');
}

Expand Down
93 changes: 92 additions & 1 deletion src/lib/stream-errors.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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<string> = new Set([
'server_error',
'server',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'server' entry (from ApiErrorType.Server) is the only code in TRANSIENT_FAILURE_CODES without a dedicated test case — the retryable-classification test at line 80 covers the other five. Consider adding 'server' to that loop so the errorType fallback path for this code is exercised.

'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,
});
}
}
15 changes: 14 additions & 1 deletion src/lib/stream-transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,6 +18,7 @@ import {
isResponseCompletedEvent,
isResponseFailedEvent,
isResponseIncompleteEvent,
isErrorEvent,
isFunctionCallArgumentsDoneEvent,
isOutputMessage,
isFunctionCallItem,
Expand Down Expand Up @@ -503,6 +505,7 @@ export async function consumeStreamForCompletion(
stream: ReusableReadableStream<models.StreamEvents>,
): Promise<models.OpenResponsesResult> {
const consumer = stream.createConsumer();
let errorEvent: models.ErrorEvent | null = null;

for await (const event of consumer) {
if (!('type' in event)) {
Expand All @@ -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)) {
Expand All @@ -524,6 +534,9 @@ export async function consumeStreamForCompletion(
}
}

if (errorEvent) {
throw StreamFailedError.fromErrorEvent(errorEvent);
}
throw new Error('Stream ended without completion event');
}

Expand Down
4 changes: 4 additions & 0 deletions src/lib/stream-type-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion src/sdk/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading