History

Pin the customer repair as bounded and attribute billing attempts per org

Rhys Sullivan committed 2 months ago

Commit 6813d371 parent 8f3317b1 file · +80 −4Browse files

Unified · Split

e2e/cloud/billing-customer-provisioning.test.ts+80−4
@@ -21,7 +21,7 @@ · 20 unmodified lines
2121 // 404 `customer_not_found` per billing endpoint — because the emulator, like
2222 // real Autumn's SDK flow, otherwise auto-creates customers on contact.
2323 import { expect } from "@effect/vitest";
24−import { Effect } from "effect";
24+import { Effect, Schedule } from "effect";
2525
2626 import { scenario } from "../src/scenario";
2727 import { Autumn, Billing, Mcp, Target } from "../src/services";
@@ -119,13 +119,19 @@ scenario( · 91 unmodified lines
119119 expect(result.text, "it returns its value").toContain("42");
120120
121121 // Both billing calls really did reach Autumn and really were rejected —
122− // without this the scenario could pass on a fault that never armed.
123− const checks = yield* autumn.ledgerFor("balances.check");
122+ // without this the scenario could pass on a fault that never armed. The
123+ // ledger is shared by the whole run, so attribute to THIS org: another
124+ // scenario's faulted call must not stand in for this one's.
125+ const checks = (yield* autumn.ledgerFor("balances.check")).filter(
126+ (entry) => entry.customerId === customerId,
127+ );
124128 expect(
125129 checks.some((entry) => entry.faulted),
126130 "the balance check reached Autumn and was answered customer_not_found",
127131 ).toBe(true);
128− const tracks = yield* autumn.ledgerFor("balances.track");
132+ const tracks = (yield* autumn.ledgerFor("balances.track")).filter(
133+ (entry) => entry.customerId === customerId,
134+ );
129135 expect(
130136 tracks.some((entry) => entry.faulted),
131137 "the usage track reached Autumn and was answered customer_not_found",
@@ -203,3 +209,73 @@ scenario( · 71 unmodified lines
203209 }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore)));
204210 }),
205211 );
212+
213+scenario(
214+ "Billing · a customer that cannot be provisioned is given up on, not retried in a loop",
215+ { timeout: 180_000 },
216+ Effect.gen(function* () {
217+ yield* Billing;
218+ const autumn = yield* Autumn;
219+ const target = yield* Target;
220+ const mcp = yield* Mcp;
221+
222+ const identity = yield* target.newIdentity();
223+ const bearer = yield* mcp.mintBearer(emailOf(identity));
224+ const customerId = orgIdOf(bearer);
225+
226+ // Attempts against THIS org's meter, polled until the seam has settled on
227+ // `atLeast` of them — the ledger is the only place the retry is visible.
228+ const trackAttempts = (atLeast: number) =>
229+ autumn.ledgerFor("balances.track").pipe(
230+ Effect.map((entries) => entries.filter((entry) => entry.customerId === customerId)),
231+ Effect.filterOrFail(
232+ (entries) => entries.length >= atLeast,
233+ (entries) => `only ${entries.length}/${atLeast} usage-track attempts for ${customerId}`,
234+ ),
235+ Effect.retry(Schedule.both(Schedule.spaced("250 millis"), Schedule.recurs(40))),
236+ );
237+
238+ yield* Effect.gen(function* () {
239+ // "No such customer" that does NOT go away when the customer is created —
240+ // a provisioning gap the repair genuinely cannot close (a rejected id, a
241+ // provider that never materializes the record). The seam must repair,
242+ // retry ONCE, report, and stop: a repair loop here runs in a forked,
243+ // untimed fibre, so an unbounded one hammers the provider forever for a
244+ // single execution.
245+ yield* autumn.armFault({
246+ match: { operationId: "balances.track" },
247+ response: CUSTOMER_NOT_FOUND,
248+ times: 20,
249+ });
250+
251+ const session = mcp.session(identity);
252+ const stuck = yield* session.call("execute", { code: "return 7 * 6;" });
253+ expect(stuck.ok, "the execution runs — billing never blocks a customer").toBe(true);
254+ expect(stuck.text, "it returns its value").toContain("42");
255+
256+ // The original attempt plus the one post-repair retry.
257+ yield* trackAttempts(2);
258+
259+ // Clearing the fault re-opens the meter, and the next execution's usage
260+ // landing is the barrier: whatever the first execution was going to do to
261+ // the ledger is finished by then, so the count below is settled without
262+ // waiting on a clock.
263+ yield* autumn.clearFaults();
264+ const clean = yield* session.call("execute", { code: "return 2 + 2;" });
265+ expect(clean.ok, "the follow-up execution runs too").toBe(true);
266+ yield* autumn.expectUsage({ customerId, featureId: "executions", count: 1 });
267+
268+ const tracks = (yield* autumn.ledgerFor("balances.track")).filter(
269+ (entry) => entry.customerId === customerId,
270+ );
271+ expect(
272+ tracks.filter((entry) => entry.faulted).length,
273+ "the unrepairable customer is attempted exactly twice: once, then once after the repair",
274+ ).toBe(2);
275+ expect(tracks.length, "and no further attempt beyond the next execution's own").toBe(3);
276+
277+ const metered = yield* autumn.usageEvents({ customerId, featureId: "executions" });
278+ expect(metered.length, "only the execution Autumn accepted reaches the meter").toBe(1);
279+ }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore)));
280+ }),
281+);