Skip to content

difyctl SSE read failures become unhandled rejections while the consumer processes an event #43942

Closed

Description

@huyengiang101086-spec

Self Checks

  • I have read the Contributing Guide and Language Policy.
  • This is only for bug report, if you would like to ask a question, please head to Discussions.
  • I have searched for existing issues search for existing issues, including closed ones.
  • I confirm that I am using English to submit this report, otherwise it will be closed.
  • 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :)
  • Please do not modify this template :) and fill in all the required fields.

Dify version

Source main 0838e2511da0ef0953b59d90d5407e0b441e2b32, difyctl 1.17.1.

Cloud or Self Hosted

Self Hosted (Source). Isolated CLI stream reproduction on Windows, Node.js 24.18.0; no deployed server or model call.

Steps to reproduce

cli/src/call/sse.ts starts a background read pump and only awaits its Promise when the consumer drains the queue. If the response body fails while the async generator is suspended at a yielded event, the pump rejects before a handler is attached. Node's default unhandled-rejection policy terminates the process before the consumer can handle the stream error.

With the CLI dependencies installed, save this as cli/repro.mjs and run node --unhandled-rejections=strict cli/repro.mjs from the repository root:

import { parseSSE } from './src/call/sse.ts'

let controller
const body = new ReadableStream({ start(c) { controller = c } })
const events = parseSSE(body)
controller.enqueue(new TextEncoder().encode('data: hello\n\n'))
console.log('first event:', await events.next())
controller.error(new Error('network disconnected'))
// Represents time spent writing/processing the previously yielded event.
await new Promise(resolve => setTimeout(resolve, 30))
try {
  await events.next()
} catch (error) {
  console.log('caught by consumer:', error.message)
}

The reproduction used an unchanged snapshot of sse.ts with the repository-pinned eventsource-parser@4.1.1. It exits with status 1 before printing caught by consumer.

Proposed focused fix: attach a rejection observer to the pump immediately, retaining the existing await that propagates the original error to the consumer. Add a regression that yields one event, fails the response body while the consumer is suspended, and verifies that resuming consumption rejects with that error without an unhandled rejection. This does not add retries or change SSE event ordering.

✔️ Expected Behavior

A failed response body is reported through the iterator and can reach the CLI's normal error handling, including when the consumer is temporarily processing an event.

❌ Actual Behavior

first event:
Error: network disconnected
    at .../repro.mjs:...
Node.js v24.18.0

The process exits before the surrounding consumer's catch runs.

From Codex

Activity

  1. dosubot commented on Oct 12, 2026

    @dosubot

    Hi @huyengiang101086-spec! I'm Dosu and I'm helping the Dify team.

    Your diagnosis matches the code at that commit. The pump async IIFE starts immediately, and its only consumer is await pump, which runs only after the queue drains and done is true. If reader.read() rejects while the generator is suspended at yield ev, pump rejects with no handler attached, and Node treats that as an unhandled rejection.

    Your proposed fix should work. Add a no-op observer right after the pump is created:

    const pump = (async () => { /* ... */ })()
    pump.catch(() => {})

    pump.catch() returns a new promise, so the original pump stays rejected and the existing await pump in the done branch still rethrows the original error to the consumer.

    The same observer covers a second case: if the consumer exits early through return() or break and pump rejects afterwards, the rejection is also unhandled, because the finally block never awaits pump.

    For the regression test, enqueue one event, call controller.error(...), wait a few ms, then assert that the next events.next() rejects with that error. The test also needs to fail on an unhandled rejection. Vitest does this by default, or you can register a process.on('unhandledRejection') listener in the test and assert it was never called.

    To reply, just mention @dosu.


    Share context across your team and agents. Try Dosu.

    Leave Feedback Learn about dify with Dosu

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

    review: lowghfind author score; see https://ghfind.com

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions