diff --git a/src/server/billing/autumn.ts b/src/server/billing/autumn.ts index 3785f85..be9dbe6 100644 --- a/src/server/billing/autumn.ts +++ b/src/server/billing/autumn.ts @@ -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, }, diff --git a/src/server/billing/subscription.test.ts b/src/server/billing/subscription.test.ts index e06059e..cad82a8 100644 --- a/src/server/billing/subscription.test.ts +++ b/src/server/billing/subscription.test.ts @@ -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(); }); }); diff --git a/src/server/billing/subscription.ts b/src/server/billing/subscription.ts index 440e165..918de8b 100644 --- a/src/server/billing/subscription.ts +++ b/src/server/billing/subscription.ts @@ -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) {