diff --git a/src/app/api/admin/upstream-failure-rules/[id]/route.ts b/src/app/api/admin/upstream-failure-rules/[id]/route.ts
index 242cac22..396f6ac7 100644
--- a/src/app/api/admin/upstream-failure-rules/[id]/route.ts
+++ b/src/app/api/admin/upstream-failure-rules/[id]/route.ts
@@ -8,6 +8,7 @@ import {
parseFailureRuleMatch,
updateFailureRule,
} from "@/lib/services/upstream-failure-rules";
+import { failoverErrorTypeSchema } from "@/lib/constants/failover-error-types";
import { createLogger } from "@/lib/utils/logger";
import { z } from "zod";
@@ -17,7 +18,7 @@ type RouteContext = { params: Promise<{ id: string }> };
const failureRuleMatchSchema = z.object({
status_codes: z.array(z.number().int().min(100).max(599)).nullable().optional(),
- error_types: z.array(z.string().trim().min(1)).nullable().optional(),
+ error_types: z.array(failoverErrorTypeSchema).nullable().optional(),
body_pattern: z.string().nullable().optional(),
header_name: z.string().nullable().optional(),
header_pattern: z.string().nullable().optional(),
diff --git a/src/app/api/admin/upstream-failure-rules/route.ts b/src/app/api/admin/upstream-failure-rules/route.ts
index af6d5d92..2d557dd9 100644
--- a/src/app/api/admin/upstream-failure-rules/route.ts
+++ b/src/app/api/admin/upstream-failure-rules/route.ts
@@ -8,6 +8,7 @@ import {
listFailureRules,
parseFailureRuleMatch,
} from "@/lib/services/upstream-failure-rules";
+import { failoverErrorTypeSchema } from "@/lib/constants/failover-error-types";
import { createLogger } from "@/lib/utils/logger";
import { z } from "zod";
@@ -15,7 +16,7 @@ const log = createLogger("admin-upstream-failure-rules");
const failureRuleMatchSchema = z.object({
status_codes: z.array(z.number().int().min(100).max(599)).nullable().optional(),
- error_types: z.array(z.string().trim().min(1)).nullable().optional(),
+ error_types: z.array(failoverErrorTypeSchema).nullable().optional(),
body_pattern: z.string().nullable().optional(),
header_name: z.string().nullable().optional(),
header_pattern: z.string().nullable().optional(),
diff --git a/src/app/api/admin/upstreams/[id]/failure-rules/route.ts b/src/app/api/admin/upstreams/[id]/failure-rules/route.ts
index 59c75355..b69b17f8 100644
--- a/src/app/api/admin/upstreams/[id]/failure-rules/route.ts
+++ b/src/app/api/admin/upstreams/[id]/failure-rules/route.ts
@@ -8,6 +8,7 @@ import {
listFailureRules,
parseFailureRuleMatch,
} from "@/lib/services/upstream-failure-rules";
+import { failoverErrorTypeSchema } from "@/lib/constants/failover-error-types";
import { createLogger } from "@/lib/utils/logger";
import { z } from "zod";
@@ -17,7 +18,7 @@ type RouteContext = { params: Promise<{ id: string }> };
const failureRuleMatchSchema = z.object({
status_codes: z.array(z.number().int().min(100).max(599)).nullable().optional(),
- error_types: z.array(z.string().trim().min(1)).nullable().optional(),
+ error_types: z.array(failoverErrorTypeSchema).nullable().optional(),
body_pattern: z.string().nullable().optional(),
header_name: z.string().nullable().optional(),
header_pattern: z.string().nullable().optional(),
diff --git a/src/components/admin/failover-error-type-multi-select.tsx b/src/components/admin/failover-error-type-multi-select.tsx
new file mode 100644
index 00000000..245bb924
--- /dev/null
+++ b/src/components/admin/failover-error-type-multi-select.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { ChevronDown, X } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import {
+ FAILOVER_ERROR_TYPES,
+ isKnownFailoverErrorType,
+} from "@/lib/constants/failover-error-types";
+import { cn } from "@/lib/utils";
+import type { FailoverErrorType } from "@/types/api";
+
+export interface FailoverErrorTypeMultiSelectProps {
+ value: string[];
+ onChange: (next: string[]) => void;
+ getLabel: (type: FailoverErrorType) => string;
+ placeholder: string;
+ selectAllLabel: string;
+ clearAllLabel: string;
+ unknownTooltip: string;
+ removeAriaLabel: string;
+ className?: string;
+}
+
+export function FailoverErrorTypeMultiSelect({
+ value,
+ onChange,
+ getLabel,
+ placeholder,
+ selectAllLabel,
+ clearAllLabel,
+ unknownTooltip,
+ removeAriaLabel,
+ className,
+}: FailoverErrorTypeMultiSelectProps) {
+ const [open, setOpen] = useState(false);
+
+ const selectedSet = useMemo(() => new Set(value), [value]);
+ const knownSelected = useMemo(
+ () => FAILOVER_ERROR_TYPES.filter((type) => selectedSet.has(type)),
+ [selectedSet]
+ );
+ const unknownSelected = useMemo(
+ () => value.filter((entry) => !isKnownFailoverErrorType(entry)),
+ [value]
+ );
+ const allKnownSelected = knownSelected.length === FAILOVER_ERROR_TYPES.length;
+
+ const toggleType = (type: FailoverErrorType, checked: boolean) => {
+ if (checked) {
+ if (selectedSet.has(type)) return;
+ onChange([...value, type]);
+ } else {
+ onChange(value.filter((entry) => entry !== type));
+ }
+ };
+
+ const removeEntry = (entry: string) => {
+ onChange(value.filter((existing) => existing !== entry));
+ };
+
+ const selectAllKnown = () => {
+ const merged = [...unknownSelected, ...FAILOVER_ERROR_TYPES];
+ onChange(merged);
+ };
+
+ const clearAll = () => {
+ onChange([]);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {FAILOVER_ERROR_TYPES.map((type) => {
+ const checked = selectedSet.has(type);
+ const inputId = `failover-error-type-${type}`;
+ return (
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/components/admin/upstream-failure-rules-editor.tsx b/src/components/admin/upstream-failure-rules-editor.tsx
index cea5c9ea..47817aec 100644
--- a/src/components/admin/upstream-failure-rules-editor.tsx
+++ b/src/components/admin/upstream-failure-rules-editor.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo, useState } from "react";
+import { useCallback, useMemo, useState, type ReactNode } from "react";
import { Loader2, Plus, Search, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
@@ -10,6 +10,8 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
+import { FailoverErrorTypeMultiSelect } from "@/components/admin/failover-error-type-multi-select";
+import { isKnownFailoverErrorType } from "@/lib/constants/failover-error-types";
import {
useCreateGlobalUpstreamFailureRule,
useCreateUpstreamFailureRule,
@@ -41,16 +43,17 @@ function parseStatusCodes(value: string): number[] | null {
function buildMatch(input: {
statusCodes: string;
- errorTypes: string;
+ errorTypes: string[];
bodyPattern: string;
headerName: string;
headerPattern: string;
}): UpstreamFailureRuleMatch {
const normalizedHeaderName = input.headerName.trim();
const normalizedHeaderPattern = input.headerPattern.trim();
+ const knownErrorTypes = input.errorTypes.filter(isKnownFailoverErrorType);
return {
status_codes: parseStatusCodes(input.statusCodes),
- error_types: parseCsvList(input.errorTypes) as FailoverErrorType[],
+ error_types: knownErrorTypes.length ? knownErrorTypes : null,
body_pattern: input.bodyPattern.trim() || null,
header_name: normalizedHeaderName && normalizedHeaderPattern ? normalizedHeaderName : null,
header_pattern:
@@ -70,12 +73,41 @@ function hasRuleCondition(match: UpstreamFailureRuleMatch): boolean {
interface RuleConditionDetail {
key: string;
label: string;
- value: string;
+ value: ReactNode;
+}
+
+function renderErrorTypeChips(
+ errorTypes: readonly string[],
+ getErrorTypeLabel: (type: FailoverErrorType) => string,
+ unknownTooltip: string
+): ReactNode {
+ return (
+
+ {errorTypes.map((entry) =>
+ isKnownFailoverErrorType(entry) ? (
+
+ {getErrorTypeLabel(entry)}
+
+ ) : (
+
+ {entry}
+
+ )
+ )}
+
+ );
}
function getRuleConditionDetails(
match: UpstreamFailureRuleMatch,
- t: ReturnType
+ t: ReturnType,
+ getErrorTypeLabel: (type: FailoverErrorType) => string,
+ unknownTooltip: string
): RuleConditionDetail[] {
const details: RuleConditionDetail[] = [];
if (match.status_codes?.length) {
@@ -89,7 +121,7 @@ function getRuleConditionDetails(
details.push({
key: "error_types",
label: t("failureRuleErrorTypes"),
- value: match.error_types.join(", "),
+ value: renderErrorTypeChips(match.error_types, getErrorTypeLabel, unknownTooltip),
});
}
if (match.body_pattern) {
@@ -235,6 +267,12 @@ export function UpstreamFailureRulesEditor({
scope = "upstream",
}: UpstreamFailureRulesEditorProps) {
const t = useTranslations("upstreams");
+ const tErrorType = useTranslations("requestLogs.retryErrorType");
+ const getErrorTypeLabel = useCallback(
+ (type: FailoverErrorType) => tErrorType(type),
+ [tErrorType]
+ );
+ const unknownTooltip = t("failureRuleErrorTypeUnknownTooltip");
const localRulesQuery = useUpstreamFailureRules(
upstreamId,
scope === "upstream" && Boolean(upstreamId)
@@ -248,7 +286,7 @@ export function UpstreamFailureRulesEditor({
const [name, setName] = useState("");
const [enabled, setEnabled] = useState(true);
const [statusCodes, setStatusCodes] = useState("");
- const [errorTypes, setErrorTypes] = useState("");
+ const [errorTypes, setErrorTypes] = useState([]);
const [bodyPattern, setBodyPattern] = useState("");
const [headerName, setHeaderName] = useState("");
const [headerPattern, setHeaderPattern] = useState("");
@@ -290,7 +328,7 @@ export function UpstreamFailureRulesEditor({
setName("");
setEnabled(true);
setStatusCodes("");
- setErrorTypes("");
+ setErrorTypes([]);
setBodyPattern("");
setHeaderName("");
setHeaderPattern("");
@@ -343,7 +381,12 @@ export function UpstreamFailureRulesEditor({
{filteredRules.length ? (
filteredRules.map((rule) => {
- const conditionDetails = getRuleConditionDetails(rule.match, t);
+ const conditionDetails = getRuleConditionDetails(
+ rule.match,
+ t,
+ getErrorTypeLabel,
+ unknownTooltip
+ );
return (
{detail.label}
-
@@ -477,10 +526,15 @@ export function UpstreamFailureRulesEditor({
onChange={(event) => setStatusCodes(event.target.value)}
placeholder={t("failureRuleStatusCodesPlaceholder")}
/>
- setErrorTypes(event.target.value)}
+ onChange={setErrorTypes}
+ getLabel={getErrorTypeLabel}
placeholder={t("failureRuleErrorTypesPlaceholder")}
+ selectAllLabel={t("failureRuleErrorTypesSelectAll")}
+ clearAllLabel={t("failureRuleErrorTypesClearAll")}
+ unknownTooltip={unknownTooltip}
+ removeAriaLabel={t("failureRuleErrorTypesRemoveAria")}
/>
!isKnownFailoverErrorType(value));
+ if (unknown.length) {
+ throw new InvalidFailureRuleError(
+ `Unknown error types: ${unknown.join(", ")}. Allowed values: ${FAILOVER_ERROR_TYPES.join(", ")}`
+ );
+ }
+ }
+
if (match.bodyPattern?.trim()) {
try {
new RegExp(match.bodyPattern);
@@ -129,6 +142,24 @@ function testRegex(pattern: string, value: string | null | undefined): boolean {
}
}
+const warnedUnknownErrorTypeRuleIds = new Set();
+
+function warnUnknownErrorTypesOnce(rule: UpstreamFailureRule, unknown: string[]): void {
+ if (!unknown.length || warnedUnknownErrorTypeRuleIds.has(rule.id)) {
+ return;
+ }
+ warnedUnknownErrorTypeRuleIds.add(rule.id);
+ log.warn(
+ {
+ ruleId: rule.id,
+ ruleName: rule.name,
+ unknown,
+ allowed: FAILOVER_ERROR_TYPES,
+ },
+ "upstream failure rule contains unknown error types and will never match those values; edit the rule to remove or correct them"
+ );
+}
+
function ruleMatchesEvidence(rule: UpstreamFailureRule, evidence: FailureEvidence): boolean {
const match = rule.match as UpstreamFailureRuleMatch;
@@ -136,8 +167,12 @@ function ruleMatchesEvidence(rule: UpstreamFailureRule, evidence: FailureEvidenc
return false;
}
- if (match.errorTypes?.length && !match.errorTypes.includes(evidence.errorType ?? "")) {
- return false;
+ if (match.errorTypes?.length) {
+ const unknown = match.errorTypes.filter((value) => !isKnownFailoverErrorType(value));
+ warnUnknownErrorTypesOnce(rule, unknown);
+ if (!match.errorTypes.includes(evidence.errorType ?? "")) {
+ return false;
+ }
}
if (match.bodyPattern) {
diff --git a/src/messages/en.json b/src/messages/en.json
index 6fbdb403..4a9626c8 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -954,7 +954,11 @@
"failureRuleEnabled": "Enabled",
"failureRuleCreateEnabled": "Create as enabled",
"failureRuleStatusCodesPlaceholder": "HTTP statuses, e.g. 400,429",
- "failureRuleErrorTypesPlaceholder": "Error types, e.g. http_4xx,timeout",
+ "failureRuleErrorTypesPlaceholder": "Select error types to match",
+ "failureRuleErrorTypesSelectAll": "Select all",
+ "failureRuleErrorTypesClearAll": "Clear all",
+ "failureRuleErrorTypesRemoveAria": "Remove this error type",
+ "failureRuleErrorTypeUnknownTooltip": "This error type is no longer in the supported enum and will never match any upstream event. Remove or replace it.",
"failureRuleBodyPatternPlaceholder": "Response body regex",
"failureRuleHeaderNamePlaceholder": "Header name",
"failureRuleHeaderPatternPlaceholder": "Header regex",
diff --git a/src/messages/zh-CN.json b/src/messages/zh-CN.json
index 053e652f..c479f015 100644
--- a/src/messages/zh-CN.json
+++ b/src/messages/zh-CN.json
@@ -959,7 +959,11 @@
"failureRuleEnabled": "启用",
"failureRuleCreateEnabled": "创建后立即启用",
"failureRuleStatusCodesPlaceholder": "HTTP 状态码,例如 400,429",
- "failureRuleErrorTypesPlaceholder": "错误类型,例如 http_4xx,timeout",
+ "failureRuleErrorTypesPlaceholder": "选择需要匹配的错误类型",
+ "failureRuleErrorTypesSelectAll": "全选",
+ "failureRuleErrorTypesClearAll": "清空",
+ "failureRuleErrorTypesRemoveAria": "移除该错误类型",
+ "failureRuleErrorTypeUnknownTooltip": "该错误类型不在当前支持的枚举内,已不会被任何上游事件命中,请删除或替换。",
"failureRuleBodyPatternPlaceholder": "响应体正则",
"failureRuleHeaderNamePlaceholder": "响应头名称",
"failureRuleHeaderPatternPlaceholder": "响应头正则",
diff --git a/tests/components/upstream-failure-rules-editor.test.tsx b/tests/components/upstream-failure-rules-editor.test.tsx
index 7600dee6..f1ac3cd8 100644
--- a/tests/components/upstream-failure-rules-editor.test.tsx
+++ b/tests/components/upstream-failure-rules-editor.test.tsx
@@ -3,6 +3,50 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { UpstreamFailureRulesEditor } from "@/components/admin/upstream-failure-rules-editor";
import type { UpstreamFailureRule } from "@/types/api";
+vi.mock("@/components/admin/failover-error-type-multi-select", () => ({
+ FailoverErrorTypeMultiSelect: ({
+ value,
+ onChange,
+ }: {
+ value: string[];
+ onChange: (next: string[]) => void;
+ }) => (
+
+ {[
+ "timeout",
+ "first_byte_timeout",
+ "upstream_no_content_stream",
+ "stream_idle_timeout",
+ "stream_error",
+ "http_5xx",
+ "http_4xx",
+ "http_429",
+ "connection_error",
+ "circuit_open",
+ "concurrency_full",
+ ].map((type) => (
+
+ ))}
+
+ {value.map((entry) => (
+ -
+ {entry}
+
+ ))}
+
+
+ ),
+}));
+
const { mockToastError, mockToastSuccess } = vi.hoisted(() => ({
mockToastError: vi.fn(),
mockToastSuccess: vi.fn(),
@@ -259,9 +303,8 @@ describe("UpstreamFailureRulesEditor", () => {
fireEvent.change(screen.getByPlaceholderText("failureRuleStatusCodesPlaceholder"), {
target: { value: " 429, 200, 99, abc " },
});
- fireEvent.change(screen.getByPlaceholderText("failureRuleErrorTypesPlaceholder"), {
- target: { value: "timeout, upstream_error" },
- });
+ fireEvent.click(screen.getByLabelText("add-timeout"));
+ fireEvent.click(screen.getByLabelText("add-http_5xx"));
fireEvent.change(screen.getByPlaceholderText("failureRuleBodyPatternPlaceholder"), {
target: { value: "rate limit" },
});
@@ -281,7 +324,7 @@ describe("UpstreamFailureRulesEditor", () => {
enabled: true,
match: {
status_codes: [429, 200],
- error_types: ["timeout", "upstream_error"],
+ error_types: ["timeout", "http_5xx"],
body_pattern: "rate limit",
header_name: "retry-after",
header_pattern: "\\d+",
@@ -314,7 +357,7 @@ describe("UpstreamFailureRulesEditor", () => {
enabled: true,
match: {
status_codes: [500, 503],
- error_types: [],
+ error_types: null,
body_pattern: null,
header_name: null,
header_pattern: null,
diff --git a/tests/unit/services/upstream-failure-rules.test.ts b/tests/unit/services/upstream-failure-rules.test.ts
index 5561f899..b2d39f43 100644
--- a/tests/unit/services/upstream-failure-rules.test.ts
+++ b/tests/unit/services/upstream-failure-rules.test.ts
@@ -424,4 +424,45 @@ describe("upstream-failure-rules", () => {
})
).rejects.toBeInstanceOf(InvalidFailureRuleError);
});
+
+ it("rejects rules containing unknown FailoverErrorType values", async () => {
+ await expect(
+ createFailureRule({
+ name: "Unknown error type",
+ match: { errorTypes: ["http_500", "timeout"] },
+ })
+ ).rejects.toThrow(/Unknown error types: http_500/);
+
+ await expect(
+ updateFailureRule("rule-1", {
+ match: { errorTypes: ["definitely_not_a_real_type"] },
+ })
+ ).rejects.toBeInstanceOf(InvalidFailureRuleError);
+ });
+
+ it("tolerates legacy rules with unknown errorTypes when matching evidence", async () => {
+ mockFindFirst.mockResolvedValueOnce({
+ id: "upstream-1",
+ failureRuleConfig: { useGlobalRules: true },
+ });
+ mockFindMany.mockResolvedValueOnce([
+ makeRule({
+ id: "legacy-rule",
+ match: {
+ statusCodes: null,
+ errorTypes: ["http_500", "timeout"],
+ bodyPattern: null,
+ headerName: null,
+ headerPattern: null,
+ },
+ }),
+ ]);
+
+ const result = await matchFailureRule({
+ upstreamId: "upstream-1",
+ errorType: "timeout",
+ });
+
+ expect(result?.id).toBe("legacy-rule");
+ });
});