Skip to content

Workflow SSE streams silently yield event: error frames as normal data instead of raising #264

Description

@shoemoney

Note on how this is filed

I have a fix, tests, and a pushed branch ready, but pull request creation against this repo is currently blocked at the platform level for external forks (POST /repos/mistralai/client-ts/pulls and the GraphQL createPullRequest mutation both return a hard permission denial for a fork-based PR, independent of branch/content -- confirmed with gh and with a raw curl call). The last external fork PR that actually got created was #200 in March 2026, closed and landed via a maintainer cherry-pick into #230 crediting the original author -- so I'm following that same path and filing this as an issue with the fix attached, per CONTRIBUTING.md.

Branch (open for a maintainer to pull/cherry-pick directly): https://github.com/shoemoney/client-ts/tree/fix/workflow-stream-error-frames
Diff: https://github.com/mistralai/client-ts/compare/main...shoemoney:client-ts:fix/workflow-stream-error-frames.diff

The bug

client-python already guards against this. client-ts never got the equivalent.

The four workflow-stream endpoints (workflow execution stream, workflow events stream, and the two log-stream endpoints) send an HTTP 200 and then, if something goes wrong server-side mid-stream, emit an in-band event: error SSE frame instead of just closing the connection. The generated response model documents this itself:

// src/models/operations/streamv1workflowsexecutionsexecutionidstreamget.ts:29
/**
 * SSE event name. `error` indicates the stream failed after HTTP 200.
 */
event?: string | undefined;

EventStream (src/lib/event-streams.ts) parses and yields every SSE frame uniformly, with no special case for event: error. The documented usage pattern in this repo has no error check either:

// docs/sdks/workflowsevents/README.md
for await (const event of result) {
  console.log(event);
}

So a consumer following the SDK's own docs gets the error frame handed to them as an ordinary item and has no reason to suspect the stream failed. client-python closed this gap in mistralai/client-python#596 (WorkflowStreamErrorHook, raising StreamDisconnectedError when a frame scanner detects an error frame). The fix below ports that hook to client-ts.

Reachable from workflows.executions.stream(), workflows.events.getStreamEvents(), and the two log-stream methods.

The fix

New hook src/hooks/stream_error_hook.ts, registered in src/hooks/registration.ts alongside the existing three (CustomUserAgentHook, DeprecationWarningHook, TracingHook) -- same shape as those, nothing else touched.

For the four guarded operation IDs (matching client-python's STREAM_OPERATIONS_WITH_ERROR_EVENT exactly: get_stream_events_v1_workflows_events_stream_get, stream_v1_workflows_executions__execution_id__stream_get, stream_deployment_logs, stream_workflow_execution_logs), the hook's afterSuccess wraps response.body in a TransformStream that buffers bytes and scans for SSE frame boundaries -- a frame scanner rather than a naive per-chunk check, because the frame (including the blank-line boundary itself) can be split across arbitrary HTTP chunk boundaries. When it finds a complete event: error frame it throws WorkflowStreamDisconnectedError (mirroring client-python's StreamDisconnectedError: same reason/error fields, same reason union of read_error / stream_error / internal_error) instead of forwarding the frame, so the consumer's for await loop throws mid-iteration instead of receiving the error payload as data. Every other operation and every non-SSE response passes through completely untouched.

No suitable existing public error type covered this (everything in src/models/errors/ is generated and shaped around an HTTP error response, not a mid-stream failure after 200). WorkflowStreamDisconnectedError follows the existing hand-written precedent for this exact situation: src/extra/realtime/errors.ts's RealtimeTranscriptionException/RealtimeTranscriptionWSError, a plain Error subclass colocated with its subsystem.

Nothing generated is touched: src/lib/event-streams.ts, src/funcs/, src/models/ are all untouched. The change lives entirely in a new hand-written hook file plus the one hand-written registration file, both explicitly marked "free to be modified" in their own header comments.

What I ran

From client-ts/:

  • npm install (root deps)
  • npx tsgo --noEmit -- clean
  • npm run lint (oxlint, --max-warnings=0 --deny-warnings) -- 0 warnings, 0 errors, 333 files
  • cd tests && npm install && npx vitest run -- 186 passed (179 pre-existing + 7 new), 0 failed

RED/GREEN check on the new tests: deleted src/hooks/stream_error_hook.ts and reverted registration.ts -- the new test file fails hard (module not found). Restored both files -- back to 186/186 green.

New tests (tests/hooks/stream_error_hook.test.ts):

  • a stream ending in an event: error frame throws through EventStream, and the frame(s) before it are still delivered first
  • a normal stream with no error frame is byte-for-byte unchanged, and yields every event through EventStream with no throw
  • an operation ID outside the guarded set is untouched (same Response instance returned) even when its body looks like an error frame
  • an error frame split across a chunk boundary is still detected -- both a split inside the blank-line boundary itself and a split mid-field
  • a non-text/event-stream response for a guarded operation ID is untouched

Out of scope

client-python also has a WorkflowEncodingHook with no client-ts equivalent. Noting it as an observation only -- not addressed here.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions