diff --git a/src/lib/services/billing-cost-service.ts b/src/lib/services/billing-cost-service.ts index 2a3db445..bdc0a46a 100644 --- a/src/lib/services/billing-cost-service.ts +++ b/src/lib/services/billing-cost-service.ts @@ -15,12 +15,41 @@ const log = createLogger("billing-cost-service"); type BillingSnapshotInsertValues = typeof requestBillingSnapshots.$inferInsert; type BillingSnapshotUpdateSet = Partial; -function isPgForeignKeyViolation( - error: unknown -): error is { code: string; constraint_name?: string } { - return ( - typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23503" - ); +interface PgForeignKeyViolation { + code: "23503"; + constraint_name?: string; +} + +/** + * 提取 Postgres FK 违例信息。drizzle-orm 0.45 会把驱动抛出的 PostgresError 包成 + * DrizzleQueryError 并将原错误塞到 `.cause` 上,因此外层对象本身没有 `code` 字段。 + * 这里同时检查顶层与 `.cause` 一层,覆盖两种形状;非 FK 违例返回 null。 + */ +function extractPgForeignKeyViolation(error: unknown): PgForeignKeyViolation | null { + if (typeof error !== "object" || error === null) return null; + + const direct = error as { code?: unknown; constraint_name?: unknown }; + if (direct.code === "23503") { + return { + code: "23503", + constraint_name: + typeof direct.constraint_name === "string" ? direct.constraint_name : undefined, + }; + } + + const cause = (error as { cause?: unknown }).cause; + if (typeof cause === "object" && cause !== null) { + const inner = cause as { code?: unknown; constraint_name?: unknown }; + if (inner.code === "23503") { + return { + code: "23503", + constraint_name: + typeof inner.constraint_name === "string" ? inner.constraint_name : undefined, + }; + } + } + + return null; } function resolveViolatedFkColumn( @@ -52,8 +81,9 @@ async function upsertBillingSnapshotWithFkRetry( .onConflictDoUpdate({ target: requestBillingSnapshots.requestLogId, set: updateSet }); return { apiKeyId: values.apiKeyId ?? null, upstreamId: values.upstreamId ?? null }; } catch (error) { - if (!isPgForeignKeyViolation(error)) throw error; - const column = resolveViolatedFkColumn(error.constraint_name); + const violation = extractPgForeignKeyViolation(error); + if (!violation) throw error; + const column = resolveViolatedFkColumn(violation.constraint_name); if (!column) throw error; const patchedValues: BillingSnapshotInsertValues = { ...values, [column]: null }; @@ -65,7 +95,7 @@ async function upsertBillingSnapshotWithFkRetry( }); log.warn( - { requestLogId, nulledColumn: column, constraint: error.constraint_name }, + { requestLogId, nulledColumn: column, constraint: violation.constraint_name }, "billing snapshot FK violation retried with NULL" ); diff --git a/tests/unit/services/billing-cost-service.test.ts b/tests/unit/services/billing-cost-service.test.ts index 059b9250..bde8a956 100644 --- a/tests/unit/services/billing-cost-service.test.ts +++ b/tests/unit/services/billing-cost-service.test.ts @@ -519,18 +519,26 @@ describe("billing-cost-service", () => { ); }); - it("retries with null api_key_id when INSERT hits api_keys FK violation", async () => { + it("retries with null api_key_id when INSERT hits api_keys FK violation (drizzle-wrapped)", async () => { // 真实生产 race:reconcile 读到的 api_key_id 在 reconcile→INSERT 之间被并发删除, // INSERT 撞 FK;helper 应当捕获 PG 23503 + 对应约束名后单次重试,把 apiKeyId 置 NULL。 // reconcile 此时还能读到旧 id(cascade 尚未触发),下一刻 INSERT 才撞 FK。 + // + // 关键:drizzle-orm 0.45 抛出的是 DrizzleQueryError,原始 PostgresError 挂在 `.cause`, + // 外层没有 `code` 字段。本测试用这种形状复现生产真实链路;下一个测试覆盖 + // 平铺形状(postgres-js 在少数路径上可能不经 drizzle 包装直接抛出)。 requestLogsFindFirstMock.mockResolvedValueOnce({ apiKeyId: "doomed-key-id", upstreamId: "still-valid-upstream", }); - const fkError = Object.assign(new Error("FK violation"), { - code: "23503", - constraint_name: "request_billing_snapshots_api_key_id_api_keys_id_fk", - table_name: "request_billing_snapshots", + const fkError = Object.assign(new Error("Failed query: insert into ..."), { + query: "insert into request_billing_snapshots ...", + params: ["doomed-key-id"], + cause: { + code: "23503", + constraint_name: "request_billing_snapshots_api_key_id_api_keys_id_fk", + table_name: "request_billing_snapshots", + }, }); onConflictDoUpdateMock.mockRejectedValueOnce(fkError).mockResolvedValueOnce(undefined); @@ -562,7 +570,9 @@ describe("billing-cost-service", () => { ); }); - it("retries with null upstream_id when INSERT hits upstreams FK violation", async () => { + it("retries with null upstream_id when INSERT hits upstreams FK violation (flat shape)", async () => { + // 兼容形状:PostgresError 直接冒出来(未经 DrizzleQueryError 包装), + // 探测函数也需识别。覆盖 postgres-js 在连接级 / 异常退出路径上的情况。 requestLogsFindFirstMock.mockResolvedValueOnce({ apiKeyId: "still-valid-key", upstreamId: "doomed-upstream-id", @@ -662,6 +672,30 @@ describe("billing-cost-service", () => { expect(onConflictDoUpdateMock).toHaveBeenCalledTimes(1); }); + it("rethrows wrapped non-FK errors without retry", async () => { + // 防回归:wrapped 形状里的 cause.code 若不是 23503(例如 23505 唯一约束、08006 连接断开), + // 探测函数必须返回 null,绝不能把所有带 cause 的 DrizzleQueryError 都误判为 FK 违例。 + const wrappedUniqueErr = Object.assign(new Error("Failed query: insert into ..."), { + cause: { code: "23505", constraint_name: "some_unique_constraint" }, + }); + onConflictDoUpdateMock.mockRejectedValueOnce(wrappedUniqueErr); + + const { calculateAndPersistRequestBillingSnapshot } = + await import("../../../src/lib/services/billing-cost-service"); + + await expect( + calculateAndPersistRequestBillingSnapshot({ + requestLogId: "log-wrapped-non-fk", + apiKeyId: "key-1", + upstreamId: "up-1", + model: null, + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + }) + ).rejects.toBe(wrappedUniqueErr); + + expect(onConflictDoUpdateMock).toHaveBeenCalledTimes(1); + }); + it("rethrows FK violation when constraint name is not recognized", async () => { const fkError = Object.assign(new Error("FK violation"), { code: "23503",