perf: bound Autumn retry window + cache customer existence off the hot path (#365)
This commit is contained in:
parent
ffa6ec5e70
commit
a337f0ac08
@ -6,13 +6,20 @@ export const autumn = new Autumn({
|
||||
// Retries 429/500/502/503/504 (per-operation retryCodes) plus connection
|
||||
// errors. Cloudflare 52x statuses are not in the SDK's retry list, so those
|
||||
// still surface immediately.
|
||||
//
|
||||
// These reads/gates (customers.getOrCreate, check) sit on the request hot
|
||||
// path, so keep the total retry window short: when Autumn is rate-limiting or
|
||||
// slow, an 8s backoff window held the isolate open for seconds on every
|
||||
// request and cascaded into region-wide congestion (incident 2026-07-06).
|
||||
// Fail fast instead — a caller that can't gate surfaces an error rather than
|
||||
// hanging.
|
||||
retryConfig: {
|
||||
strategy: "backoff",
|
||||
backoff: {
|
||||
initialInterval: 250,
|
||||
maxInterval: 2000,
|
||||
maxInterval: 1000,
|
||||
exponent: 1.5,
|
||||
maxElapsedTime: 8000,
|
||||
maxElapsedTime: 2500,
|
||||
},
|
||||
retryConnectionErrors: true,
|
||||
},
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AUTUMN_PAID_PLAN_FEATURE_ID } from "@/shared/billing";
|
||||
|
||||
const { checkMock, getOrCreateMock } = vi.hoisted(() => ({
|
||||
const { checkMock, getOrCreateMock, kvGetMock, kvPutMock } = vi.hoisted(() => ({
|
||||
checkMock: vi.fn(),
|
||||
getOrCreateMock: vi.fn(),
|
||||
kvGetMock: vi.fn(),
|
||||
kvPutMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("cloudflare:workers", () => ({
|
||||
env: { KV: { get: kvGetMock, put: kvPutMock } },
|
||||
}));
|
||||
|
||||
vi.mock("@/server/billing/autumn", () => ({
|
||||
@ -33,6 +39,7 @@ import {
|
||||
describe("subscription billing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
kvGetMock.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("checks the paid plan entitlement", async () => {
|
||||
@ -65,5 +72,19 @@ describe("subscription billing", () => {
|
||||
customerId: "org_123",
|
||||
email: "alice@example.com",
|
||||
});
|
||||
expect(kvPutMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the Autumn round trip when the customer was recently ensured", async () => {
|
||||
kvGetMock.mockResolvedValue("1");
|
||||
|
||||
const result = await getOrCreateOrganizationCustomer({
|
||||
organizationId: "org_123",
|
||||
userId: "user_123",
|
||||
userEmail: "alice@example.com",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "org_123" });
|
||||
expect(getOrCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
|
||||
import {
|
||||
AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
||||
@ -20,9 +21,24 @@ export type BillingCustomerContext = Pick<
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
// Existence is monotonic and the Autumn customer id is always the org id we
|
||||
// pass, so once we've confirmed a customer exists we can skip the round trip
|
||||
// and reuse the org id. Callers only need `.id` (they read balances via
|
||||
// `check`), and a degraded Autumn API otherwise added seconds to every hot-path
|
||||
// request that ensured the customer (incident 2026-07-06). Long TTL is safe:
|
||||
// we only ever cache confirmed existence, never absence.
|
||||
const CUSTOMER_ENSURED_TTL_SECONDS = 24 * 60 * 60;
|
||||
const customerEnsuredKey = (organizationId: string) =>
|
||||
`autumn:customer-ensured:${organizationId}`;
|
||||
|
||||
export async function getOrCreateOrganizationCustomer(
|
||||
context: BillingCustomerContext,
|
||||
) {
|
||||
): Promise<{ id: string }> {
|
||||
const cacheKey = customerEnsuredKey(context.organizationId);
|
||||
if (await env.KV.get(cacheKey)) {
|
||||
return { id: context.organizationId };
|
||||
}
|
||||
|
||||
const customer = await autumn.customers.getOrCreate({
|
||||
customerId: context.organizationId,
|
||||
email: context.userEmail,
|
||||
@ -32,10 +48,11 @@ export async function getOrCreateOrganizationCustomer(
|
||||
throw new AppError("INTERNAL_ERROR", "Failed to resolve billing customer");
|
||||
}
|
||||
|
||||
return {
|
||||
...customer,
|
||||
id: customer.id,
|
||||
};
|
||||
await env.KV.put(cacheKey, "1", {
|
||||
expirationTtl: CUSTOMER_ENSURED_TTL_SECONDS,
|
||||
});
|
||||
|
||||
return { id: customer.id };
|
||||
}
|
||||
|
||||
export async function customerHasPaidPlan(customerId: string) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user