chore: finalize lock fail open

This commit is contained in:
Charlie Lamb
2026-04-27 12:59:40 +01:00
parent a9a03d353f
commit 8ff0ac3d4b
2 changed files with 156 additions and 5 deletions

View File

@@ -1,21 +1,34 @@
import { type FinalizeLockParamsV0, notNullish } from "@autumn/shared";
import { Decimal } from "decimal.js";
import { withRedisFailOpen } from "@/external/redis/utils/withRedisFailOpen.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { cancelLockExpiry } from "@/internal/balances/utils/lock/cancelLockExpiry.js";
import { claimLockReceipt } from "@/internal/balances/utils/lock/claimLockReceipt.js";
import { deleteLockReceipt } from "@/internal/balances/utils/lock/deleteLockReceipt.js";
import { fetchLockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt.js";
import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
import { buildFinalizeLockContext } from "./buildFinalizeLockContext.js";
import { runFinalizeLockV2 } from "./runFinalizeLockV2.js";
import { runRedisFinalizeLock } from "./runRedisFinalizeLock.js";
export const runFinalizeLock = async ({
ctx,
params,
}: {
type RunFinalizeLockArgs = {
ctx: AutumnContext;
params: FinalizeLockParamsV0;
}) => {
};
export const runFinalizeLock = async (args: RunFinalizeLockArgs) => {
if (!isFullSubjectRolloutEnabled({ ctx: args.ctx })) {
return runFinalizeLockInner(args);
}
return withRedisFailOpen({
source: "runFinalizeLock",
run: () => runFinalizeLockInner(args),
fallback: () => ({ success: true }),
});
};
const runFinalizeLockInner = async ({ ctx, params }: RunFinalizeLockArgs) => {
const fetchedReceipt = await fetchLockReceipt({
ctx,
lockId: params.lock_id,

View File

@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
const mockState = {
shouldUseRedis: true,
fetchError: null as unknown,
finalizeV2Error: null as unknown,
fetchCalls: [] as Record<string, unknown>[],
finalizeV2Calls: [] as Record<string, unknown>[],
};
mock.module("@/external/redis/initUtils/redisV2Availability.js", () => ({
shouldUseRedisV2: () => mockState.shouldUseRedis,
}));
mock.module("@/internal/balances/utils/lock/fetchLockReceipt.js", () => ({
fetchLockReceipt: async (args: Record<string, unknown>) => {
mockState.fetchCalls.push(args);
if (mockState.fetchError) throw mockState.fetchError;
return {
source: "redis_v2",
receipt: { customer_id: "cus_123", feature_id: "messages", items: [] },
lockReceiptKey: "lock:receipt",
claimed: true,
};
},
}));
mock.module("@/internal/balances/finalizeLock/runFinalizeLockV2.js", () => ({
runFinalizeLockV2: async (args: Record<string, unknown>) => {
mockState.finalizeV2Calls.push(args);
if (mockState.finalizeV2Error) throw mockState.finalizeV2Error;
return { success: true };
},
}));
import { RedisUnavailableError } from "@/external/redis/utils/errors.js";
import { runFinalizeLock } from "@/internal/balances/finalizeLock/runFinalizeLock.js";
const resetMockState = () => {
mockState.shouldUseRedis = true;
mockState.fetchError = null;
mockState.finalizeV2Error = null;
mockState.fetchCalls = [];
mockState.finalizeV2Calls = [];
};
beforeEach(resetMockState);
afterEach(resetMockState);
const rolloutCtx = {
org: { id: "org_123" },
env: "sandbox",
rolloutSnapshot: {
rolloutId: "v2-cache",
enabled: true,
percent: 100,
previousPercent: 0,
changedAt: 1,
customerBucket: 10,
},
} as never;
const nonRolloutCtx = {
org: { id: "org_123" },
env: "sandbox",
rolloutSnapshot: undefined,
} as never;
const params = {
lock_id: "lock_123",
action: "capture",
} as never;
describe("runFinalizeLock", () => {
test("runs finalize v2 when the rollout is enabled", async () => {
const result = await runFinalizeLock({ ctx: rolloutCtx, params });
expect(result).toEqual({ success: true });
expect(mockState.fetchCalls).toHaveLength(1);
expect(mockState.finalizeV2Calls).toHaveLength(1);
});
test("fails open when Redis is unavailable before fetching the receipt", async () => {
mockState.shouldUseRedis = false;
const result = await runFinalizeLock({ ctx: rolloutCtx, params });
expect(result).toEqual({ success: true });
expect(mockState.fetchCalls).toHaveLength(0);
expect(mockState.finalizeV2Calls).toHaveLength(0);
});
test("fails open when fetching the receipt hits a transient Redis error", async () => {
mockState.fetchError = new RedisUnavailableError({
source: "unit-test",
reason: "timeout",
});
const result = await runFinalizeLock({ ctx: rolloutCtx, params });
expect(result).toEqual({ success: true });
expect(mockState.fetchCalls).toHaveLength(1);
expect(mockState.finalizeV2Calls).toHaveLength(0);
});
test("fails open when finalize v2 hits a transient Redis error", async () => {
mockState.finalizeV2Error = new Error("Command timed out");
const result = await runFinalizeLock({ ctx: rolloutCtx, params });
expect(result).toEqual({ success: true });
expect(mockState.fetchCalls).toHaveLength(1);
expect(mockState.finalizeV2Calls).toHaveLength(1);
});
test("does not fail open when the rollout is disabled", async () => {
mockState.fetchError = new RedisUnavailableError({
source: "unit-test",
reason: "timeout",
});
await expect(runFinalizeLock({ ctx: nonRolloutCtx, params })).rejects.toBe(
mockState.fetchError,
);
expect(mockState.fetchCalls).toHaveLength(1);
});
test("throws non-transient errors", async () => {
const error = new Error("application bug");
mockState.finalizeV2Error = error;
await expect(runFinalizeLock({ ctx: rolloutCtx, params })).rejects.toBe(
error,
);
});
});