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
48 changes: 39 additions & 9 deletions src/lib/services/billing-cost-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,41 @@ const log = createLogger("billing-cost-service");
type BillingSnapshotInsertValues = typeof requestBillingSnapshots.$inferInsert;
type BillingSnapshotUpdateSet = Partial<BillingSnapshotInsertValues>;

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(
Expand Down Expand Up @@ -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 };
Expand All @@ -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"
);

Expand Down
46 changes: 40 additions & 6 deletions tests/unit/services/billing-cost-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading