Skip to content

Fix/stream error detection and channel fallback - #6523

Open
liyunshan0823-glitch wants to merge 2 commits into
QuantumNous:mainfrom
liyunshan0823-glitch:fix/stream-error-detection-and-channel-fallback
Open

Fix/stream error detection and channel fallback#6523
liyunshan0823-glitch wants to merge 2 commits into
QuantumNous:mainfrom
liyunshan0823-glitch:fix/stream-error-detection-and-channel-fallback

Conversation

@liyunshan0823-glitch

@liyunshan0823-glitch liyunshan0823-glitch commented Jul 29, 2026

Copy link
Copy Markdown

📝 变更描述

修复流式请求中 SSE 流内嵌错误无法触发通道切换的问题。

问题: gpt-5.5 等模型在流式场景下,上游返回 HTTP 200 正常建立 SSE 连接后,在流中嵌入 rate_limit_exceeded 错误,new-api
无法感知该错误(只透传数据给客户端),导致 newAPIError == nil 不触发通道重试。同时客户端中断连接后计费为 0。

根因: OaiStreamHandler 的 dataHandler 仅检测 JSON 解析错误,不检测 SSE chunk 中的 API 业务错误。而图片流处理
(relay_image.go) 已有同等的 isOpenAIImageStreamErrorEvent() 机制。

修复:

  • 新增 isOpenAITextStreamErrorChunk() 函数,检测 SSE 数据中的三种错误模式:
    1. 标准 OpenAI error 信封
    2. 已知失败 error code(rate_limit_exceeded 等)
    3. type: "error" / "upstream_error" 类型
  • OaiStreamHandler 首个 chunk 未向客户端发送数据前检测到错误时,调用 sr.Stop() 中止流
  • StreamScannerHandler 返回后,若流非正常结束且无数据发送,向上层返回 error 触发重试/兜底

🚀 变更类型

  • 🐛 Bug 修复 (Bug fix)

✅ 提交前检查项

  • 人工确认: 我已亲自整理并撰写此描述
  • 非重复提交: 已搜索现有 Issues 与 PRs
  • 变更理解: 已理解更改的工作原理及影响
  • 范围聚焦: 仅修改了两个文件,未包含无关改动
  • 本地验证: 编译通过(go build ./relay/...),现有测试通过(go test ./relay/channel/openai/...
  • 安全合规: 无敏感凭据,符合项目规范

📸 运行证明

$ go test ./relay/channel/openai/...
ok github.com/QuantumNous/new-api/relay/channel/openai (cached)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of OpenAI streaming errors, including rate limits and upstream service failures.
    • Stops affected streams early and surfaces a clearer error, enabling retry through an alternative channel.
    • Prevents terminal upstream errors from being mistaken for successful streamed responses.

liyunshan and others added 2 commits July 29, 2026 11:37
…hunk

When a streaming (SSE) request gets an HTTP 200 but the upstream embeds
a terminal error (e.g. rate_limit_exceeded) inside the SSE stream before
any content, the relay would silently forward the error to the client,
return nil error to the retry loop, and never fall back to another
channel. This caused the scenarios where streaming requests hit a
rate-limited channel but never retried to a higher-priority channel.

Changes:
- Add isOpenAITextStreamErrorChunk() to detect inline SSE errors by:
  1. Standard OpenAI error envelope ({"error": {"message": ...}})
  2. Known failure codes (rate_limit_exceeded, server_error, etc.)
  3. Explicit error/upstream_error type field
- In OaiStreamHandler, when such an error is detected before any data
  has been sent to the client (SendResponseCount == 0), stop the stream
  immediately via sr.Stop().
- After StreamScannerHandler returns, if the stream ended abnormally
  and nothing was sent to the client, return a non-nil error so the
  retry loop in controller/relay.go can try another channel.

The image stream handler (relay_image.go) already had equivalent error
detection via isOpenAIImageStreamErrorEvent(); this brings text stream
handling to parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

OpenAI streaming now parses SSE payloads for terminal upstream errors and stops the stream before sending a response when such an error is detected.

Changes

OpenAI stream error handling

Layer / File(s) Summary
Detect terminal SSE errors
relay/channel/openai/helper.go
Adds JSON-based detection for error messages, known upstream error codes, and error event types.
Abort before response output
relay/channel/openai/relay-openai.go
Checks unreturned stream chunks for upstream errors, logs detected errors, and stops the stream early.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: calcium-ion

Sequence Diagram(s)

sequenceDiagram
  participant OpenAISSE
  participant OaiStreamHandler
  participant StreamResponse
  OpenAISSE->>OaiStreamHandler: Send streaming SSE chunk
  OaiStreamHandler->>OaiStreamHandler: Parse and classify error payload
  OaiStreamHandler->>StreamResponse: Stop before response output
Loading

Poem

A rabbit parses errors bright,
Through streaming chunks of JSON light.
If upstream falters, streams depart,
Before a token warms the heart.
Hop, retry, and code anew!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: detecting stream errors and triggering channel fallback.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
relay/channel/openai/relay-openai.go (1)

141-159: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Run the terminal-error check before processTokenData/collectStreamFunctionCallNames.

Error chunks (e.g. rate_limit_exceeded) are currently fed into collectStreamFunctionCallNames and processTokenData first. Since these aren't valid token/tool-call payloads, this wastes work and produces a spurious "error processing stream token data" log alongside the intended "upstream stream returned error before sending data" log, muddying diagnostics for the exact failure this PR is meant to surface clearly.

♻️ Proposed reordering
 		if len(data) > 0 {
+			// Detect upstream business errors embedded in the SSE stream (e.g.
+			// rate_limit_exceeded) before treating the payload as token data.
+			if info.SendResponseCount == 0 {
+				if isErr, errMsg := isOpenAITextStreamErrorChunk(data); isErr {
+					logger.LogError(c, "upstream stream returned error before sending data: "+errMsg)
+					sr.Stop(fmt.Errorf("upstream stream error: %s", errMsg))
+					return
+				}
+			}
+
 			// 对音频模型,保存倒数第二个stream data
 			if isAudioModel && lastStreamData != "" {
 				secondLastStreamData = lastStreamData
 			}

 			lastStreamData = data
 			collectStreamFunctionCallNames(data, seenStreamToolCalls, &streamFunctionCallNames)
 			if err := processTokenData(info.RelayMode, data, &responseTextBuilder, &toolCount); err != nil {
 				logger.LogError(c, "error processing stream token data: "+err.Error())
 				sr.Error(err)
 			}
-
-			// Detect upstream business errors embedded in the SSE stream (e.g.
-			// rate_limit_exceeded). When detected before any data has been
-			// sent to the client (SendResponseCount == 0), abort the stream so
-			// the caller can retry with a different channel.
-			if info.SendResponseCount == 0 {
-				if isErr, errMsg := isOpenAITextStreamErrorChunk(data); isErr {
-					logger.LogError(c, "upstream stream returned error before sending data: "+errMsg)
-					sr.Stop(fmt.Errorf("upstream stream error: %s", errMsg))
-					return
-				}
-			}
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/channel/openai/relay-openai.go` around lines 141 - 159, Move the
terminal-error detection block in the stream loop to run immediately after
assigning `lastStreamData`, before `collectStreamFunctionCallNames` and
`processTokenData`. When an upstream error chunk is detected before any response
data, log it, stop the stream, and return without processing the chunk as token
or tool-call data; preserve normal processing for non-error chunks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@relay/channel/openai/helper.go`:
- Around line 258-301: The isOpenAITextStreamErrorChunk helper currently
discards partially unmarshaled payloads when optional top-level code or type
fields have incompatible JSON types. Change the payload fields used for code,
type, and message parsing to json.RawMessage, allow unmarshaling to succeed for
unrelated malformed fields, and parse each raw field only within its
corresponding detection path while preserving standard error-envelope detection
and known-code/type behavior.

---

Nitpick comments:
In `@relay/channel/openai/relay-openai.go`:
- Around line 141-159: Move the terminal-error detection block in the stream
loop to run immediately after assigning `lastStreamData`, before
`collectStreamFunctionCallNames` and `processTokenData`. When an upstream error
chunk is detected before any response data, log it, stop the stream, and return
without processing the chunk as token or tool-call data; preserve normal
processing for non-error chunks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 43423035-a4b8-454c-a0b4-c714cd659c52

📥 Commits

Reviewing files that changed from the base of the PR and between 66ee6b8 and 772df84.

📒 Files selected for processing (2)
  • relay/channel/openai/helper.go
  • relay/channel/openai/relay-openai.go

Comment on lines +258 to +301
func isOpenAITextStreamErrorChunk(data string) (bool, string) {
if data == "" {
return false, ""
}

var payload struct {
Error json.RawMessage `json:"error"`
Code string `json:"code"`
Type string `json:"type"`
Message string `json:"message"`
}
if err := common.UnmarshalJsonStr(data, &payload); err != nil {
return false, ""
}

// 1. Standard OpenAI error envelope: {"error": {"message": "...", ...}}
if len(payload.Error) > 0 {
var oaiErr types.OpenAIError
if err := common.Unmarshal(payload.Error, &oaiErr); err == nil && oaiErr.Message != "" {
return true, oaiErr.Message
}
}

// 2. Known failure codes embedded in SSE data chunks
if isKnownUpstreamErrorCode(payload.Code) {
msg := payload.Message
if msg == "" {
msg = fmt.Sprintf("upstream error code: %s", payload.Code)
}
return true, msg
}

// 3. Explicit error/upstream_error type
payloadType := strings.ToLower(strings.TrimSpace(payload.Type))
if payloadType == "error" || payloadType == "upstream_error" {
msg := payload.Message
if msg == "" {
msg = "upstream stream returned error event"
}
return true, msg
}

return false, ""
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'json.go' common
rg -nP -A 20 'func UnmarshalJsonStr' common

Repository: QuantumNous/new-api

Length of output: 1015


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- openai helper relevant section ---\n'
sed -n '230,330p' relay/channel/openai/helper.go

printf '\n--- common/json.go ---\n'
sed -n '1,60p' common/json.go

printf '\n--- isKnownUpstreamErrorCode usages/implementation ---\n'
rg -n -B 5 -A 25 'func isKnownUpstream errorCode|isKnownUpstreamErrorCode' relay common || true

printf '\n--- deterministic Go stdlib partial field mismatch probe ---\n'
cat > /tmp/decode_probe.go <<'GO'
package main

import (
	"encoding/json"
	"fmt"
)

type Payload struct {
	Error   json.RawMessage `json:"error"`
	Code    string          `json:"code"`
	Type    string          `json:"type"`
	Message string          `json:"message"`
}

func main() {
	input := `{"error":{"message":"oops"},"code":42,"message":"msg"}`
	var p Payload
	err := json.Unmarshal([]byte(input), &p)
	fmt.Printf("input=%q\n", input)
	fmt.Printf("err=%T: %v\n", err, err)
	fmt.Printf("p.Error=%q\n", p.Error)
	fmt.Printf("len(p.Error)=%d\n", len(p.Error))
	fmt.Printf("p.Message=%q\n", p.Message)
}
GO
if command -v go >/dev/null 2>&1; then
  go run /tmp/decode_probe.go
else
  echo "go not available"
fi

Repository: QuantumNous/new-api

Length of output: 248


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- openai helper relevant section ---'
sed -n '230,330p' relay/channel/openai/helper.go

printf '%s\n' ''
printf '%s\n' '--- common/json.go ---'
sed -n '1,60p' common/json.go

printf '%s\n' ''
printf '%s\n' '--- isKnownUpstreamErrorCode usages/implementation ---'
rg -n -B 5 -A 25 'func isKnownUpstream errorCode|isKnownUpstreamErrorCode' relay common || true

printf '%s\n' ''
printf '%s\n' '--- deterministic Go stdlib partial field mismatch probe ---'
cat > /tmp/decode_probe.go <<'GO'
package main

import (
	"encoding/json"
	"fmt"
)

type Payload struct {
	Error   json.RawMessage `json:"error"`
	Code    string          `json:"code"`
	Type    string          `json:"type"`
	Message string          `json:"message"`
}

func main() {
	input := `{"error":{"message":"oops"},"code":42,"message":"msg"}`
	var p Payload
	err := json.Unmarshal([]byte(input), &p)
	fmt.Printf("input=%q\n", input)
	fmt.Printf("err=%T: %v\n", err, err)
	fmt.Printf("p.Error=%q\n", p.Error)
	fmt.Printf("len(p.Error)=%d\n", len(p.Error))
	fmt.Printf("p.Message=%q\n", p.Message)
}
GO
if command -v go >/dev/null 2>&1; then
  go run /tmp/decode_probe.go
else
  echo "go not available"
fi

Repository: QuantumNous/new-api

Length of output: 7010


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat > /tmp/decode_probe2.go <<'GO'
package main

import (
	"encoding/json"
	"fmt"
	"strings"
)

type Payload struct {
	Error   json.RawMessage `json:"error"`
	Code    string          `json:"code"`
	Type    string          `json:"type"`
	Message string          `json:"message"`
}

func main() {
	inputs := []string{
		`{"error":{"message":"oops"},"code":42,"message":"msg"}`,
		`{"error":{"message":"oops"},"type":403,"message":"msg"}`,
		`{"error":{"message":"oops"},"code":"server_error","message":"msg"}`,
		`{"error":22,"code":"server_error","message":"msg"}`,
	}
	for _, s := range inputs {
		var p Payload
		err := json.Unmarshal([]byte(s), &p)
		fmt.Printf("err=%T: %v | Error=%q | Code=%q | Type=%q | Message=%q\n",
			err, err, p.Error, p.Code, p.Type, p.Message)

		after := strings.ToLower(strings.TrimSpace(p.Type))
		fmt.Printf("  after lower trim: %q matches error/upstream_error=%v\n", after, after == "error" || after == "upstream_error")
	}
}
GO
go run /tmp/decode_probe2.go

Repository: QuantumNous/new-api

Length of output: 921


Parse the top-level fields after unmarshaling instead of abandoning partial payloads.

UnmarshalJsonStr delegates to stdlib json.Unmarshal, which populates valid fields such as error.message before returning an UnmarshalTypeError for a mismatched field. When code or type is a number (e.g. 403), this helper returns false, "" and hides the OpenAI error envelope. Use json.RawMessage for optional top-level fields and parse only the fields needed by each detection path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/channel/openai/helper.go` around lines 258 - 301, The
isOpenAITextStreamErrorChunk helper currently discards partially unmarshaled
payloads when optional top-level code or type fields have incompatible JSON
types. Change the payload fields used for code, type, and message parsing to
json.RawMessage, allow unmarshaling to succeed for unrelated malformed fields,
and parse each raw field only within its corresponding detection path while
preserving standard error-envelope detection and known-code/type behavior.

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