diff --git a/docs/guide/architecture/failover-circuit.md b/docs/guide/architecture/failover-circuit.md
index ebd12aa8..5f4b1be5 100644
--- a/docs/guide/architecture/failover-circuit.md
+++ b/docs/guide/architecture/failover-circuit.md
@@ -105,12 +105,14 @@ export const DEFAULT_FAILOVER_CONFIG: FailoverConfig = {
代理层把两类错误判定为可故障转移:
-**异常类(`isFailoverableError`,`route.ts:842-863`)**:
+**异常类(`isFailoverableError`,`route.ts:844-869`)**:
- `CircuitBreakerOpenError`
-- `FirstByteTimeoutError` / `StreamIdleTimeoutError`
+- `FirstByteTimeoutError` / `StreamIdleTimeoutError` / `UpstreamNoContentStreamError`
- 错误消息包含 `timed out` / `timeout` / `econnrefused` / `econnreset` / `socket hang up` / `network` / `fetch failed` / `circuit breaker`
+`UpstreamNoContentStreamError` 表示上游 SSE 流正常关闭、但没有产生任何 content-bearing chunk(典型表现是只发送 `response.created` 等 metadata 事件就 `[DONE]`)。它与 `FirstByteTimeoutError` 的关键区别在于:首字超时计时器从未触发,上游是「主动提前结束流」,因此错误文案会同时携带实际耗时与配置阈值,避免把配置值误读成实际等待时长。
+
**HTTP 响应类(`shouldTriggerFailover`,`failover-config.ts:57-73`)**:
- 状态码非 2xx 且不在 `excludeStatusCodes` 中
diff --git a/src/app/api/proxy/v1/[...path]/route.ts b/src/app/api/proxy/v1/[...path]/route.ts
index 44938145..549fd86e 100644
--- a/src/app/api/proxy/v1/[...path]/route.ts
+++ b/src/app/api/proxy/v1/[...path]/route.ts
@@ -17,6 +17,7 @@ import {
applyCompensationHeaders,
FirstByteTimeoutError,
StreamIdleTimeoutError,
+ UpstreamNoContentStreamError,
type ProxyResult,
type HeaderDiff,
} from "@/lib/services/proxy-client";
@@ -823,6 +824,7 @@ function getErrorType(
): FailoverAttempt["error_type"] {
if (error instanceof CircuitBreakerOpenError) return "circuit_open";
if (error instanceof FirstByteTimeoutError) return "first_byte_timeout";
+ if (error instanceof UpstreamNoContentStreamError) return "upstream_no_content_stream";
if (error instanceof StreamIdleTimeoutError) return "stream_idle_timeout";
if (statusCode === 429) return "http_429";
if (statusCode && statusCode >= 400 && statusCode < 500) return "http_4xx";
@@ -843,7 +845,11 @@ function isFailoverableError(error: unknown): boolean {
if (error instanceof CircuitBreakerOpenError) {
return true;
}
- if (error instanceof FirstByteTimeoutError || error instanceof StreamIdleTimeoutError) {
+ if (
+ error instanceof FirstByteTimeoutError ||
+ error instanceof StreamIdleTimeoutError ||
+ error instanceof UpstreamNoContentStreamError
+ ) {
return true;
}
if (error instanceof Error) {
diff --git a/src/components/admin/routing-decision-timeline.tsx b/src/components/admin/routing-decision-timeline.tsx
index 6e8bd1f3..59dcd392 100644
--- a/src/components/admin/routing-decision-timeline.tsx
+++ b/src/components/admin/routing-decision-timeline.tsx
@@ -591,6 +591,7 @@ function RetryTimeline({
switch (errorType) {
case "timeout":
case "first_byte_timeout":
+ case "upstream_no_content_stream":
case "stream_idle_timeout":
return ;
case "http_5xx":
@@ -665,6 +666,7 @@ function RetryTimeline({
attempt.error_type === "http_5xx" && "text-status-error",
(attempt.error_type === "timeout" ||
attempt.error_type === "first_byte_timeout" ||
+ attempt.error_type === "upstream_no_content_stream" ||
attempt.error_type === "stream_idle_timeout") &&
"text-status-warning",
attempt.error_type === "http_429" && "text-orange-500",
diff --git a/src/lib/services/proxy-client.ts b/src/lib/services/proxy-client.ts
index 2a78d01a..13599174 100644
--- a/src/lib/services/proxy-client.ts
+++ b/src/lib/services/proxy-client.ts
@@ -87,6 +87,24 @@ export class StreamIdleTimeoutError extends Error {
}
}
+/**
+ * Error raised when an upstream SSE stream closes cleanly before producing any
+ * content-bearing chunk. This is distinct from FirstByteTimeoutError: the
+ * configured first-byte timer never fired — the upstream simply finished the
+ * stream early (often after sending only metadata events).
+ */
+export class UpstreamNoContentStreamError extends Error {
+ constructor(
+ public readonly elapsedMs: number,
+ public readonly firstByteTimeoutMs: number
+ ) {
+ super(
+ `Upstream closed SSE stream after ${(elapsedMs / 1000).toFixed(2)}s without producing any content-bearing chunk (first-byte timeout config: ${Math.round(firstByteTimeoutMs / 1000)}s)`
+ );
+ this.name = "UpstreamNoContentStreamError";
+ }
+}
+
function maskSecretValue(value: string): string {
const trimmed = value.trim();
if (trimmed.length <= 6) return "***";
@@ -943,6 +961,8 @@ async function waitForFirstStreamContent(options: {
return;
}
+ const startedAt = Date.now();
+
let timeoutId: ReturnType | null = null;
const timeoutPromise = new Promise((_resolve, reject) => {
timeoutId = setTimeout(() => {
@@ -953,7 +973,7 @@ async function waitForFirstStreamContent(options: {
const streamDoneBeforeContentPromise = options.onStreamDone.then(() => {
if (!options.hasFirstContent()) {
- throw new FirstByteTimeoutError(options.timeoutMs ?? 0);
+ throw new UpstreamNoContentStreamError(Date.now() - startedAt, options.timeoutMs ?? 0);
}
});
diff --git a/src/messages/en.json b/src/messages/en.json
index 355ab8c3..6fbdb403 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -587,6 +587,10 @@
"journeyRequestNotSent": "Did not send the request upstream; the flow ended inside the gateway",
"retryErrorType": {
"timeout": "timeout",
+ "first_byte_timeout": "first-byte timeout",
+ "upstream_no_content_stream": "upstream no-content stream",
+ "stream_idle_timeout": "stream idle timeout",
+ "stream_error": "stream error",
"http_5xx": "HTTP 5xx",
"http_4xx": "HTTP 4xx",
"http_429": "HTTP 429",
diff --git a/src/messages/zh-CN.json b/src/messages/zh-CN.json
index 16386c34..053e652f 100644
--- a/src/messages/zh-CN.json
+++ b/src/messages/zh-CN.json
@@ -592,6 +592,10 @@
"journeyRequestNotSent": "未发送到上游,流程在网关内结束",
"retryErrorType": {
"timeout": "超时",
+ "first_byte_timeout": "首字超时",
+ "upstream_no_content_stream": "上游空内容流",
+ "stream_idle_timeout": "流空闲超时",
+ "stream_error": "流异常",
"http_5xx": "5xx 错误",
"http_4xx": "4xx 错误",
"http_429": "429 限流",
diff --git a/src/types/api.ts b/src/types/api.ts
index 5f7469da..fd02a953 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -470,6 +470,7 @@ export interface TestUpstreamResponse {
export type FailoverErrorType =
| "timeout"
| "first_byte_timeout"
+ | "upstream_no_content_stream"
| "stream_idle_timeout"
| "stream_error"
| "http_5xx"
diff --git a/tests/unit/api/proxy/route.test.ts b/tests/unit/api/proxy/route.test.ts
index 126407f9..4f1beb4d 100644
--- a/tests/unit/api/proxy/route.test.ts
+++ b/tests/unit/api/proxy/route.test.ts
@@ -121,6 +121,18 @@ vi.mock("@/lib/services/proxy-client", () => {
}
}
+ class UpstreamNoContentStreamError extends Error {
+ constructor(
+ public readonly elapsedMs: number,
+ public readonly firstByteTimeoutMs: number
+ ) {
+ super(
+ `Upstream closed SSE stream after ${(elapsedMs / 1000).toFixed(2)}s without producing any content-bearing chunk (first-byte timeout config: ${Math.round(firstByteTimeoutMs / 1000)}s)`
+ );
+ this.name = "UpstreamNoContentStreamError";
+ }
+ }
+
return {
forwardRequest: vi.fn(),
prepareUpstreamForProxy: vi.fn((upstream, timeoutConfig) => ({
@@ -157,6 +169,7 @@ vi.mock("@/lib/services/proxy-client", () => {
),
FirstByteTimeoutError,
StreamIdleTimeoutError,
+ UpstreamNoContentStreamError,
};
});
diff --git a/tests/unit/services/proxy-client.test.ts b/tests/unit/services/proxy-client.test.ts
index 71a880ba..d597bdfe 100644
--- a/tests/unit/services/proxy-client.test.ts
+++ b/tests/unit/services/proxy-client.test.ts
@@ -1758,7 +1758,7 @@ describe("proxy-client", () => {
await assertion;
});
- it("should fail first-byte validation when stream closes before content-bearing data", async () => {
+ it("should raise UpstreamNoContentStreamError when stream closes before content-bearing data", async () => {
const sseData = 'data: {"type":"response.created"}\n\ndata: [DONE]\n\n';
const mockResponse = new Response(sseData, {
status: 200,
@@ -1781,8 +1781,8 @@ describe("proxy-client", () => {
"req-123"
)
).rejects.toMatchObject({
- name: "FirstByteTimeoutError",
- timeoutMs: 1000,
+ name: "UpstreamNoContentStreamError",
+ firstByteTimeoutMs: 1000,
});
});