Handle billing KV failures and preserve Lighthouse start-page matching (#375)

This commit is contained in:
Ben Senescu 2026-07-09 22:23:03 -04:00 committed by GitHub
parent dae0067233
commit c11517908d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 121 additions and 10 deletions

View File

@ -40,6 +40,7 @@ describe("subscription billing", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
kvGetMock.mockResolvedValue(null); kvGetMock.mockResolvedValue(null);
kvPutMock.mockResolvedValue(undefined);
}); });
it("checks the paid plan entitlement", async () => { it("checks the paid plan entitlement", async () => {
@ -87,4 +88,48 @@ describe("subscription billing", () => {
expect(result).toEqual({ id: "org_123" }); expect(result).toEqual({ id: "org_123" });
expect(getOrCreateMock).not.toHaveBeenCalled(); expect(getOrCreateMock).not.toHaveBeenCalled();
}); });
it("falls back to Autumn when the customer cache read fails", async () => {
const cacheError = new Error("KV read unavailable");
vi.spyOn(console, "warn").mockImplementation(() => undefined);
kvGetMock.mockRejectedValue(cacheError);
getOrCreateMock.mockResolvedValue({ id: "cust_123" });
await expect(
getOrCreateOrganizationCustomer({
organizationId: "org_123",
userId: "user_123",
userEmail: "alice@example.com",
}),
).resolves.toEqual({ id: "cust_123" });
expect(getOrCreateMock).toHaveBeenCalledWith({
customerId: "org_123",
email: "alice@example.com",
});
expect(console.warn).toHaveBeenCalledWith(
"billing.customer-cache-read failed:",
cacheError,
);
});
it("returns the resolved customer when the customer cache write fails", async () => {
const cacheError = new Error("KV write unavailable");
vi.spyOn(console, "warn").mockImplementation(() => undefined);
getOrCreateMock.mockResolvedValue({ id: "cust_123" });
kvPutMock.mockRejectedValue(cacheError);
await expect(
getOrCreateOrganizationCustomer({
organizationId: "org_123",
userId: "user_123",
userEmail: "alice@example.com",
}),
).resolves.toEqual({ id: "cust_123" });
expect(console.warn).toHaveBeenCalledWith(
"billing.customer-cache-write failed:",
cacheError,
);
});
}); });

View File

@ -35,9 +35,13 @@ export async function getOrCreateOrganizationCustomer(
context: BillingCustomerContext, context: BillingCustomerContext,
): Promise<{ id: string }> { ): Promise<{ id: string }> {
const cacheKey = customerEnsuredKey(context.organizationId); const cacheKey = customerEnsuredKey(context.organizationId);
try {
if (await env.KV.get(cacheKey)) { if (await env.KV.get(cacheKey)) {
return { id: context.organizationId }; return { id: context.organizationId };
} }
} catch (error) {
console.warn("billing.customer-cache-read failed:", error);
}
const customer = await autumn.customers.getOrCreate({ const customer = await autumn.customers.getOrCreate({
customerId: context.organizationId, customerId: context.organizationId,
@ -48,9 +52,13 @@ export async function getOrCreateOrganizationCustomer(
throw new AppError("INTERNAL_ERROR", "Failed to resolve billing customer"); throw new AppError("INTERNAL_ERROR", "Failed to resolve billing customer");
} }
try {
await env.KV.put(cacheKey, "1", { await env.KV.put(cacheKey, "1", {
expirationTtl: CUSTOMER_ENSURED_TTL_SECONDS, expirationTtl: CUSTOMER_ENSURED_TTL_SECONDS,
}); });
} catch (error) {
console.warn("billing.customer-cache-write failed:", error);
}
return { id: customer.id }; return { id: customer.id };
} }

View File

@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/server/lib/dataforseo", () => ({
createDataforseoClient: vi.fn(),
}));
vi.mock("@/server/lib/r2", () => ({
putTextToR2: vi.fn(),
}));
import { selectLighthouseSample } from "./lighthouse";
describe("selectLighthouseSample", () => {
it("includes a start page reached through a trailing-slash redirect", () => {
const pages = [
...Array.from({ length: 10 }, (_, index) => ({
url: `https://example.com/section${index}`,
statusCode: 200,
})),
{ url: "https://example.com/services/", statusCode: 200 },
];
const selected = selectLighthouseSample(
pages,
"https://example.com/services",
"auto",
);
expect(selected).toHaveLength(10);
expect(selected[0]).toBe("https://example.com/services/");
});
it("prefers an exact start page when both slash forms return 2xx", () => {
const selected = selectLighthouseSample(
[
{ url: "https://example.com/services/", statusCode: 200 },
{ url: "https://example.com/services", statusCode: 200 },
],
"https://example.com/services",
"auto",
);
expect(selected[0]).toBe("https://example.com/services");
});
});

View File

@ -9,6 +9,14 @@ interface LighthouseSamplePage {
statusCode: number; statusCode: number;
} }
function canonicalUrlKeyWithoutTrailingSlash(url: string): string {
const parsed = new URL(canonicalUrlKey(url));
if (parsed.pathname !== "/") {
parsed.pathname = parsed.pathname.replace(/\/$/, "");
}
return parsed.toString();
}
type LighthouseFetchResult = { type LighthouseFetchResult = {
result: LighthouseResult; result: LighthouseResult;
payloadJson: string | null; payloadJson: string | null;
@ -130,12 +138,17 @@ export function selectLighthouseSample(
// strategy === "auto": homepage + 1 per URL pattern, capped at 10 // strategy === "auto": homepage + 1 per URL pattern, capped at 10
const selected = new Set<string>(); const selected = new Set<string>();
// Always include the start URL / homepage. Compare with canonicalUrlKey on // Always include the start URL / homepage. Prefer an exact canonical match
// both sides so the match survives the redirects a site uses to reach its // so distinct 2xx `/path` and `/path/` pages stay distinct, then tolerate a
// canonical homepage: trailing-slash (/ -> // no, e.g. example.com -> // trailing-slash redirect when the exact start URL was not crawled as 2xx.
// example.com/), www <-> non-www, and http -> https.
const startKey = canonicalUrlKey(startUrl); const startKey = canonicalUrlKey(startUrl);
const startPage = validPages.find((p) => canonicalUrlKey(p.url) === startKey); const startPage =
validPages.find((p) => canonicalUrlKey(p.url) === startKey) ??
validPages.find(
(p) =>
canonicalUrlKeyWithoutTrailingSlash(p.url) ===
canonicalUrlKeyWithoutTrailingSlash(startUrl),
);
if (startPage) selected.add(startPage.url); if (startPage) selected.add(startPage.url);
// Group by URL template pattern // Group by URL template pattern