Repository navigation
Node.js SDK toText() silently resolves when an SSE stream reports an error #44198
Description
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
Node.js SDK dify-client 3.1.0 from source at 69e9ca4e01ce05da52255939330fbcec454dce23 (main, October 16, 2026). Node.js 24.21.0. No deployed Dify server is needed for the reproduction below.
Cloud or Self Hosted
Self Hosted (Source). This report tests the SDK from the repository against a local HTTP fixture using its real fetch transport; it does not claim an end-to-end deployment or model-provider test.
Steps to reproduce
An HTTP 200 SSE response can report a generation failure in a JSON payload with event: "error". For example, the backend error converter maps InvokeRateLimitError to a payload containing code, status: 429, and message; the chat stream converter includes the error event type. However, the Node.js SDK's toText() helper silently drops such payloads.
Build sdks/nodejs-client using the repository's development setup (vp pack in that directory). Save this as repro.mjs next to its dist directory, then run node repro.mjs:
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import { APIError, ChatClient } from './dist/index.js'
const errorPayload = { event: 'error', status: 429, code: 'rate_limit_error', message: 'Rate limit reached' }
const server = createServer(async (request, response) => {
let body = ''
for await (const chunk of request) body += chunk
const { query } = JSON.parse(body)
response.writeHead(200, { 'content-type': 'text/event-stream', 'x-request-id': 'req-stream' })
if (query === 'partial') response.write('data: {"event":"message","answer":"partial answer"}\n\n')
response.end(`data: ${JSON.stringify(errorPayload)}\n\n`)
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const client = new ChatClient({ apiKey: 'local-fixture', baseUrl: `http://127.0.0.1:${server.address().port}`, maxRetries: 0 })
const results = []
try {
for (const query of ['empty', 'partial']) {
const stream = await client.createChatMessage({ inputs: {}, query, user: 'local-fixture', response_mode: 'streaming' })
try {
results.push({ query, result: 'resolved', text: await stream.toText() })
} catch (error) {
results.push({ query, result: 'rejected', isAPIError: error instanceof APIError, statusCode: error.statusCode, requestId: error.requestId, responseBody: error.responseBody })
}
}
console.log(JSON.stringify(results, null, 2))
for (const result of results) {
assert.equal(result.result, 'rejected')
assert.equal(result.isAPIError, true)
assert.equal(result.statusCode, 429)
assert.equal(result.requestId, 'req-stream')
assert.deepEqual(result.responseBody, errorPayload)
}
} finally {
server.closeAllConnections()
await new Promise(resolve => server.close(resolve))
}This uses only loopback HTTP; the fixture does not call a model or external service.
Related work checked: #43968 handles non-2xx HTTP responses, while this response is HTTP 200. #44177 handles text_chunk and message_replace text collection and does not currently handle error payloads. #44083's SSE whitespace fix is already in the tested baseline.
✔️ Expected Behavior
await stream.toText() rejects when an SSE payload reports event: "error", rather than returning incomplete output as a successful answer. Reusing the SDK's existing APIError, with the payload's status/message/body and the request ID, would allow normal error handling. Consumers iterating the raw event stream should continue receiving error events unchanged for custom handling.
❌ Actual Behavior
Both requests resolve successfully:
[
{ "query": "empty", "result": "resolved", "text": "" },
{ "query": "partial", "result": "resolved", "text": "partial answer" }
]The assertion fails because the error was discarded. toText() currently only extracts answer, text, or delta; an error payload has none of these fields. This can make a rate-limited or otherwise failed generation appear complete to an SDK consumer.
Proposed change: reject from toText() with the existing APIError, while leaving raw event iteration unchanged. A small fix and regression tests reproduce the failure on unchanged production code and pass with the fix; I will link the implementation for review here.
From Codex
Activity
dosubot commentedon Oct 16, 2026
Hi @lulalalual! I'm Dosu and I'm helping the Dify team.
The SDK source supports your diagnosis:
toText()only appends extracted text and never checks for an error payload. An error-only stream therefore contributes'', while an error after text leaves that partial text looking successful. This is source inspection, not an executed reproduction. [1]One detail useful for the fix and any temporary workaround: your fixture’s error discriminator is
event.data.event, not the iterator wrapper’sevent.event. The latter is populated from an SSEevent:line, which your fixture does not send. Raw iteration preserves the parsed JSON payload. [2]Until this is addressed, consumers can iterate the stream and explicitly throw when
event.data.event === 'error'. The existingAPIErroraccepts the payload’s message plus{ statusCode: payload.status, responseBody: payload, requestId: stream.requestId }; the request ID is already passed into the stream wrapper. [3] [4]Use that raw-iteration path instead of calling
toText()afterward: both consume the same iterator. [5]To reply, just mention @dosu.
Share context across your team and agents. Try Dosu.