feat: typed StreamFailedError for server stream failures (DEV-723 3/5) - #774
feat: typed StreamFailedError for server stream failures (DEV-723 3/5)#774LukasParke wants to merge 1 commit into
Conversation
…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.
There was a problem hiding this comment.
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:
StreamFailedErrorfollows the same pattern as the existingStreamStalledErrorin the codebase — constructor options object,Object.setPrototypeOffor runtime prototype chain,override readonly name, aretryablegetter. Consistent and idiomatic. TRANSIENT_FAILURE_CODES: Correctly sourced from both enum spaces — theCodevalues (server_error,rate_limit_exceeded) used byresponse.error.codeand theApiErrorTypevalues (server,timeout,provider_overloaded,provider_unavailable) used byresponse.errorType. Theretryablegetter checks bothcodeanderrorTypeagainst the set, covering the case wherecodeis an unrecognized string buterrorTypeis a known transient type.- Backward compatibility:
StreamFailedError extends Error, so existinginstanceof Errorchecks andcatchblocks still work. The message content is preserved (reformatted with code prefix), not lost. No existing tests break. fromErrorEventfactory: Accepts a structural type{ code: string | null; message: string }— theErrorEventmodel satisfies this with its extra fields ignored. Correctly does not seterrorTypesinceErrorEventhas no such field.- Tests: 8 new tests covering retryable classification matrix, end-to-end
response.failedsurfacing (code/type/response populated), non-retryable failures, stream-level error →StreamFailedError, and error-then-completion still resolving. Well-structured with SSE fixture helpers. - Export:
StreamFailedErroris exported from the package root insdk.tsalongsideStreamStalledError, so consumers caninstanceofcheck.
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. |
| */ | ||
| const TRANSIENT_FAILURE_CODES: ReadonlySet<string> = new Set([ | ||
| 'server_error', | ||
| 'server', |
There was a problem hiding this comment.
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.
Stack layer 3/5 — typed
StreamFailedErrorfor server stream failuresBase: #773 (
callModelintegration). Rest of stack: #775 (stall retries) → #776 (raw-stream helpers + docs). Linear: DEV-723.Problem
response.failedevents and stream-levelerrorevents previously threw barenew Error(...)with stringified JSON payloads, forcing consumers to string-match. Worse, the two consume paths behaved differently:pipeAndConsumeStreamhard-failed on any mid-stream error event, whileconsumeStreamForCompletionignored error events entirely and died with "Stream ended without completion event".Changes
StreamFailedError(exported from the package root) carrying:code: the Responses error-field code (e.g.server_error)errorType: canonical OpenRouterApiErrorType(e.g.provider_unavailable) when present on the failed responseresponse: the full failedOpenResponsesResult(forresponse.failed)retryablehint: true for transient codes (server_error,timeout,rate_limit_exceeded,provider_overloaded,provider_unavailable), false for validation-style failuresisErrorEventtype guard added.errorevents are recorded, not immediately thrown — surfaced only if the stream ends without a completion, so error-then-recovered streams still resolve.Intentional behavior change: the thrown error type changes from plain
ErrortoStreamFailedError(anErrorsubclass; message content preserved).Tests
8 new tests: retryable classification matrix, end-to-end
response.failedsurfacing (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.