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
|
// 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
|
// errors. Cloudflare 52x statuses are not in the SDK's retry list, so those
|
||||||
// still surface immediately.
|
// 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: {
|
retryConfig: {
|
||||||
strategy: "backoff",
|
strategy: "backoff",
|
||||||
backoff: {
|
backoff: {
|
||||||
initialInterval: 250,
|
initialInterval: 250,
|
||||||
maxInterval: 2000,
|
maxInterval: 1000,
|
||||||
exponent: 1.5,
|
exponent: 1.5,
|
||||||
maxElapsedTime: 8000,
|
maxElapsedTime: 2500,
|
||||||
},
|
},
|
||||||
retryConnectionErrors: true,
|
retryConnectionErrors: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { AUTUMN_PAID_PLAN_FEATURE_ID } from "@/shared/billing";
|
import { AUTUMN_PAID_PLAN_FEATURE_ID } from "@/shared/billing";
|
||||||
|
|
||||||
const { checkMock, getOrCreateMock } = vi.hoisted(() => ({
|
const { checkMock, getOrCreateMock, kvGetMock, kvPutMock } = vi.hoisted(() => ({
|
||||||
checkMock: vi.fn(),
|
checkMock: vi.fn(),
|
||||||
getOrCreateMock: 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", () => ({
|
vi.mock("@/server/billing/autumn", () => ({
|
||||||
@ -33,6 +39,7 @@ import {
|
|||||||
describe("subscription billing", () => {
|
describe("subscription billing", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
kvGetMock.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks the paid plan entitlement", async () => {
|
it("checks the paid plan entitlement", async () => {
|
||||||
@ -65,5 +72,19 @@ describe("subscription billing", () => {
|
|||||||
customerId: "org_123",
|
customerId: "org_123",
|
||||||
email: "alice@example.com",
|
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 type { EnsuredUserContext } from "@/middleware/ensure-user/types";
|
||||||
import {
|
import {
|
||||||
AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
||||||
@ -20,9 +21,24 @@ export type BillingCustomerContext = Pick<
|
|||||||
projectId?: string;
|
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(
|
export async function getOrCreateOrganizationCustomer(
|
||||||
context: BillingCustomerContext,
|
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({
|
const customer = await autumn.customers.getOrCreate({
|
||||||
customerId: context.organizationId,
|
customerId: context.organizationId,
|
||||||
email: context.userEmail,
|
email: context.userEmail,
|
||||||
@ -32,10 +48,11 @@ export async function getOrCreateOrganizationCustomer(
|
|||||||
throw new AppError("INTERNAL_ERROR", "Failed to resolve billing customer");
|
throw new AppError("INTERNAL_ERROR", "Failed to resolve billing customer");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
await env.KV.put(cacheKey, "1", {
|
||||||
...customer,
|
expirationTtl: CUSTOMER_ENSURED_TTL_SECONDS,
|
||||||
id: customer.id,
|
});
|
||||||
};
|
|
||||||
|
return { id: customer.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function customerHasPaidPlan(customerId: string) {
|
export async function customerHasPaidPlan(customerId: string) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user