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, }); });