Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/guide/architecture/failover-circuit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 中
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/proxy/v1/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
applyCompensationHeaders,
FirstByteTimeoutError,
StreamIdleTimeoutError,
UpstreamNoContentStreamError,
type ProxyResult,
type HeaderDiff,
} from "@/lib/services/proxy-client";
Expand Down Expand Up @@ -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";
Expand All @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/components/admin/routing-decision-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,7 @@ function RetryTimeline({
switch (errorType) {
case "timeout":
case "first_byte_timeout":
case "upstream_no_content_stream":
case "stream_idle_timeout":
return <Clock className="w-3 h-3 text-status-warning" />;
case "http_5xx":
Expand Down Expand Up @@ -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",
Expand Down
22 changes: 21 additions & 1 deletion src/lib/services/proxy-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "***";
Expand Down Expand Up @@ -943,6 +961,8 @@ async function waitForFirstStreamContent(options: {
return;
}

const startedAt = Date.now();

let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => {
Expand All @@ -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);
}
});

Expand Down
4 changes: 4 additions & 0 deletions src/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 限流",
Expand Down
1 change: 1 addition & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/api/proxy/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -157,6 +169,7 @@ vi.mock("@/lib/services/proxy-client", () => {
),
FirstByteTimeoutError,
StreamIdleTimeoutError,
UpstreamNoContentStreamError,
};
});

Expand Down
6 changes: 3 additions & 3 deletions tests/unit/services/proxy-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1781,8 +1781,8 @@ describe("proxy-client", () => {
"req-123"
)
).rejects.toMatchObject({
name: "FirstByteTimeoutError",
timeoutMs: 1000,
name: "UpstreamNoContentStreamError",
firstByteTimeoutMs: 1000,
});
});

Expand Down
Loading