diff --git a/src/lib/utils/upstream-error-detection.ts b/src/lib/utils/upstream-error-detection.ts index f8231a4ae..49e2be3d0 100644 --- a/src/lib/utils/upstream-error-detection.ts +++ b/src/lib/utils/upstream-error-detection.ts @@ -95,77 +95,77 @@ const ERROR_STATUS_MATCHERS: Array<{ statusCode: number; matcherId: string; re: { statusCode: 429, matcherId: "rate_limit", - re: /(?:\bHTTP\/\d(?:\.\d)?\s+429\b|\b429\s+too\s+many\s+requests\b|\btoo\s+many\s+requests\b|\brate\s*limit(?:ed|ing)?\b|\bthrottl(?:e|ed|ing)\b|\bretry-after\b|\bRESOURCE_EXHAUSTED\b|\bRequestLimitExceeded\b|\bThrottling(?:Exception)?\b|\bError\s*1015\b|超出频率|请求过于频繁|限流|稍后重试)/iu, + re: /(?:\bHTTP\/\d(?:\.\d)?\s+429(?![\p{L}\p{N}_]|\.\d)|(? { + it.each(httpStatusCases)("keeps matching a standalone HTTP $statusCode status token", ({ + statusCode, + matcherId, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}`)).toEqual({ + statusCode, + matcherId, + }); + }); + + it.each( + httpStatusCases + )("does not treat HTTP $statusCode followed by a decimal fraction as a status token", ({ + statusCode, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}.12`)).toBeNull(); + }); + + it.each( + httpStatusCases + )("does not treat HTTP $statusCode embedded in a longer number as a status token", ({ + statusCode, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}12`)).toBeNull(); + }); + + it.each( + httpStatusCases + )("does not treat HTTP $statusCode followed by a letter as a status token", ({ statusCode }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}abc`)).toBeNull(); + }); + + it.each(httpStatusCases)("keeps matching HTTP $statusCode followed by sentence punctuation", ({ + statusCode, + matcherId, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`HTTP/1.1 ${statusCode}.`)).toEqual({ + statusCode, + matcherId, + }); + }); + + it.each(cloudflareErrorCases)("keeps matching a standalone Cloudflare Error $code token", ({ + code, + statusCode, + matcherId, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}`)).toEqual({ + statusCode, + matcherId, + }); + }); + + it.each( + cloudflareErrorCases + )("does not treat Cloudflare Error $code followed by a decimal fraction as a code token", ({ + code, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}.7`)).toBeNull(); + }); + + it.each( + cloudflareErrorCases + )("does not treat Cloudflare Error $code embedded in a longer number as a code token", ({ + code, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}7`)).toBeNull(); + }); + + it.each( + cloudflareErrorCases + )("does not treat Cloudflare Error $code followed by a letter as a code token", ({ code }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}x`)).toBeNull(); + }); + + it.each( + cloudflareErrorCases + )("keeps matching Cloudflare Error $code followed by sentence punctuation", ({ + code, + statusCode, + matcherId, + }) => { + expect(inferUpstreamErrorStatusCodeFromText(`Error ${code}.`)).toEqual({ + statusCode, + matcherId, + }); + }); + + it("does not infer service_unavailable from an AWS request id containing 503", () => { + const text = "request id: 202604250550399959"; + + expect(inferUpstreamErrorStatusCodeFromText(text)).toBeNull(); + }); + + it("does not infer any status from a decimal price sample", () => { + const text = "需要预扣费额度:¥0.352942"; + + expect(inferUpstreamErrorStatusCodeFromText(text)).toBeNull(); + }); +}); diff --git a/tests/unit/repository/error-rules-default-numeric-boundaries.test.ts b/tests/unit/repository/error-rules-default-numeric-boundaries.test.ts new file mode 100644 index 000000000..9a7eb4201 --- /dev/null +++ b/tests/unit/repository/error-rules-default-numeric-boundaries.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test, vi } from "vitest"; + +process.env.DSN = ""; +process.env.AUTO_CLEANUP_TEST_DATA = "false"; + +type CapturedDefaultRule = { + pattern: string; + matchType: "contains" | "exact" | "regex"; + category: string; +}; + +type MockTransaction = { + query: { + errorRules: { + findMany: () => Promise; + }; + }; + delete: () => { + where: () => Promise; + }; + insert: () => { + values: (rule: CapturedDefaultRule) => { + onConflictDoNothing: () => { + returning: () => Promise>; + }; + }; + }; + update: () => { + set: () => { + where: () => Promise; + }; + }; +}; + +const capturedInsertedRules: CapturedDefaultRule[] = []; + +vi.mock("drizzle-orm", () => ({ + desc: vi.fn((...args: unknown[]) => ({ args, op: "desc" })), + eq: vi.fn((...args: unknown[]) => ({ args, op: "eq" })), + inArray: vi.fn((...args: unknown[]) => ({ args, op: "inArray" })), +})); + +vi.mock("@/drizzle/schema", () => ({ + errorRules: { + id: "error_rules.id", + pattern: "error_rules.pattern", + isDefault: "error_rules.is_default", + }, +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + transaction: vi.fn(async (fn: (tx: MockTransaction) => Promise) => { + const tx: MockTransaction = { + query: { + errorRules: { + findMany: vi.fn(async () => []), + }, + }, + delete: vi.fn(() => ({ + where: vi.fn(async () => []), + })), + insert: vi.fn(() => ({ + values: (rule: CapturedDefaultRule) => { + capturedInsertedRules.push(rule); + return { + onConflictDoNothing: () => ({ + returning: vi.fn(async () => [{ id: 1 }]), + }), + }; + }, + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(async () => []), + })), + })), + }; + + await fn(tx); + }), + }, +})); + +vi.mock("@/lib/emit-event", () => ({ + emitErrorRulesUpdated: vi.fn(async () => {}), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + }, +})); + +async function loadDefaultRules(): Promise { + capturedInsertedRules.length = 0; + vi.resetModules(); + + const { syncDefaultErrorRules } = await import("@/repository/error-rules"); + await syncDefaultErrorRules(); + + return [...capturedInsertedRules]; +} + +function matchesRule(rule: CapturedDefaultRule, sample: string): boolean { + if (rule.matchType === "exact") return sample === rule.pattern; + if (rule.matchType === "contains") + return sample.toLowerCase().includes(rule.pattern.toLowerCase()); + + return new RegExp(rule.pattern, "i").test(sample); +} + +describe("syncDefaultErrorRules numeric boundaries", () => { + test("default rules do not match numeric substrings in request ids or prices", async () => { + const defaultRules = await loadDefaultRules(); + const samples = ["request id: 202604250550399959", "需要预扣费额度:¥0.352942"]; + + expect(defaultRules.length).toBeGreaterThan(0); + + const accidentalMatches = defaultRules.flatMap((rule) => { + return samples + .filter((sample) => matchesRule(rule, sample)) + .map((sample) => ({ category: rule.category, pattern: rule.pattern, sample })); + }); + + expect(accidentalMatches).toEqual([]); + }); +});