Skip to content

feat: typed StreamFailedError for server stream failures (DEV-723 3/5) - #774

Open
LukasParke wants to merge 1 commit into
lukeparke/dev-723-callmodel-timeoutfrom
lukeparke/dev-723-stream-failed-error
Open

feat: typed StreamFailedError for server stream failures (DEV-723 3/5)#774
LukasParke wants to merge 1 commit into
lukeparke/dev-723-callmodel-timeoutfrom
lukeparke/dev-723-stream-failed-error

Conversation

@LukasParke

@LukasParke LukasParke commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Stack layer 3/5 — typed StreamFailedError for server stream failures

Base: #773 (callModel integration). Rest of stack: #775 (stall retries) → #776 (raw-stream helpers + docs). Linear: DEV-723.

Problem

response.failed events and stream-level error events previously threw bare new Error(...) with stringified JSON payloads, forcing consumers to string-match. Worse, the two consume paths behaved differently: pipeAndConsumeStream hard-failed on any mid-stream error event, while consumeStreamForCompletion ignored error events entirely and died with "Stream ended without completion event".

Changes

  • New StreamFailedError (exported from the package root) carrying:
    • code: the Responses error-field code (e.g. server_error)
    • errorType: canonical OpenRouter ApiErrorType (e.g. provider_unavailable) when present on the failed response
    • response: the full failed OpenResponsesResult (for response.failed)
    • retryable hint: true for transient codes (server_error, timeout, rate_limit_exceeded, provider_overloaded, provider_unavailable), false for validation-style failures
  • isErrorEvent type guard added.
  • Both consumption paths updated. Stream-level error events are recorded, not immediately thrown — surfaced only if the stream ends without a completion, so error-then-recovered streams still resolve.
  • DEV-721 ready: when the API's fail-fast timeout code lands, it joins the transient-code set and clients classify it automatically.

Intentional behavior change: the thrown error type changes from plain Error to StreamFailedError (an Error subclass; message content preserved).

Tests

8 new tests: retryable classification matrix, end-to-end response.failed surfacing (code/type/response populated), non-retryable failure, stream-level error event → StreamFailedError, and error-then-completion still resolving.

Verification

230 unit tests passing; lint / typecheck clean.

…723 phase 3)

response.failed events and stream-level error events previously threw
bare 'new Error(...)' with stringified JSON payloads, forcing consumers
to string-match. They now throw StreamFailedError carrying:

- code: the Responses error-field code (e.g. server_error)
- errorType: the canonical OpenRouter ApiErrorType (e.g.
  provider_unavailable) when present on the failed response
- response: the full failed OpenResponsesResult (response.failed only)
- retryable: true for transient codes (server_error, timeout,
  rate_limit_exceeded, provider_overloaded, provider_unavailable),
  false for validation-style failures

Covers both consumption paths: consumeStreamForCompletion (getText /
getResponse / initial turn) and pipeAndConsumeStream (multi-turn
broadcaster). Stream-level error events are recorded rather than
immediately thrown, and only surface if the stream ends without a
completion - an error followed by a successful completion still
resolves (pipeAndConsumeStream previously hard-failed on any error
event mid-stream; consumeStreamForCompletion previously ignored error
events entirely and died with 'Stream ended without completion event').

StreamFailedError is exported from the package root. This positions the
SDK for DEV-721: when the API adds its fail-fast timeout code, it joins
the transient-code set and clients automatically classify it.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

@LukasParke LukasParke changed the title feat: surface server stream failures as typed StreamFailedError (DEV-723 phase 3) feat: typed StreamFailedError for server stream failures (DEV-723 3/5) Aug 10, 2026

@perry-the-pr-reviewer perry-the-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Perry's Review

Verdict: 💬 Comments / questions

Risk: 🟢 Low

Review details

Summary

This PR introduces StreamFailedError — a typed Error subclass that replaces the bare new Error(...) thrown when the server reports a failure during streaming (response.failed events and stream-level error events). The error carries code, errorType, response, and a retryable getter so consumers can branch on failure semantics instead of string-matching.

Both stream consumption paths (consumeStreamForCompletion and pipeAndConsumeStream) are updated consistently: response.failed throws immediately (terminal state), while error events are recorded and only surfaced if the stream ends without a completion — so an error-then-recovered stream still resolves. This fixes the prior behavioral inconsistency where pipeAndConsumeStream hard-failed on any error event while consumeStreamForCompletion ignored them entirely.

Analysis

  • Type design: StreamFailedError follows the same pattern as the existing StreamStalledError in the codebase — constructor options object, Object.setPrototypeOf for runtime prototype chain, override readonly name, a retryable getter. Consistent and idiomatic.
  • TRANSIENT_FAILURE_CODES: Correctly sourced from both enum spaces — the Code values (server_error, rate_limit_exceeded) used by response.error.code and the ApiErrorType values (server, timeout, provider_overloaded, provider_unavailable) used by response.errorType. The retryable getter checks both code and errorType against the set, covering the case where code is an unrecognized string but errorType is a known transient type.
  • Backward compatibility: StreamFailedError extends Error, so existing instanceof Error checks and catch blocks still work. The message content is preserved (reformatted with code prefix), not lost. No existing tests break.
  • fromErrorEvent factory: Accepts a structural type { code: string | null; message: string } — the ErrorEvent model satisfies this with its extra fields ignored. Correctly does not set errorType since ErrorEvent has no such field.
  • Tests: 8 new tests covering retryable classification matrix, end-to-end response.failed surfacing (code/type/response populated), non-retryable failures, stream-level error → StreamFailedError, and error-then-completion still resolving. Well-structured with SSE fixture helpers.
  • Export: StreamFailedError is exported from the package root in sdk.ts alongside StreamStalledError, so consumers can instanceof check.

Findings

One suggestion (see inline): the 'server' code in TRANSIENT_FAILURE_CODES is the only entry without a dedicated test case.

Risk assessment

Risk: 🟢 Low

Risk assessment:

Dimension Severity Risk Reasoning
Implementation risk 🟩 Low Straightforward typed error subclass mirroring the existing StreamStalledError pattern; both consume paths updated consistently; 8 tests cover the key scenarios.
Premise risk 🟩 Low The diagnosis (bare Error forces string-matching; two consume paths behaved inconsistently) is correct and well-motivated. The approach (typed subclass with code/errorType/retryable) is the standard solution.
Estimated impact 🟩 Low StreamFailedError is an Error subclass — existing instanceof Error catches still work, message content is preserved. The error-then-completion behavior is a fix, not a regression.
Risk Factor Severity Risk Reasoning
Reversibility 🟩 Low Fully reversible — the error type and behavior change are in client-side code with no persisted state.
Detectability 🟩 Low The error type change is immediately observable; 8 tests cover the paths.
Blast radius 🟩 Low Only affects stream-failure error handling in the SDK; no auth, payment, or data paths touched.
Data integrity None No persisted state is touched.
Financial exposure None No billing or payment code is affected.
Security and privacy exposure None No credentials, auth, or tenant isolation involved.
Propagation 🟩 Low Downstream consumers using instanceof Error are unaffected; those string-matching messages may need updating but message content is preserved.
Availability 🟩 Low Error-then-completion streams now resolve instead of failing — an improvement, not a risk.
Recovery cost 🟩 Low Simple revert if needed.
Time to correct 🟩 Low Any issue would be caught by the 8 new tests or immediate consumer feedback.

Comment thread src/lib/stream-errors.ts
*/
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant