perf: bound Autumn retry window + cache customer existence off the hot path (#365)

This commit is contained in:
Ben Senescu 2026-07-06 16:53:28 -04:00 committed by Ben Senescu
parent ffa6ec5e70
commit a337f0ac08
3 changed files with 53 additions and 8 deletions

View File

@ -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,
},

View File

@ -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();
});
});

View File

@ -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) {