Unified · Split
| @@ -21,7 +21,7 @@ · 20 unmodified lines | |||
| 21 | // 404 `customer_not_found` per billing endpoint — because the emulator, like | 21 | // 404 `customer_not_found` per billing endpoint — because the emulator, like |
| 22 | // real Autumn's SDK flow, otherwise auto-creates customers on contact. | 22 | // real Autumn's SDK flow, otherwise auto-creates customers on contact. |
| 23 | import { expect } from "@effect/vitest"; | 23 | import { expect } from "@effect/vitest"; |
| 24 | import { Effect } from "effect"; | 24 | import { Effect, Schedule } from "effect"; |
| 25 | 25 | ||
| 26 | import { scenario } from "../src/scenario"; | 26 | import { scenario } from "../src/scenario"; |
| 27 | import { Autumn, Billing, Mcp, Target } from "../src/services"; | 27 | import { Autumn, Billing, Mcp, Target } from "../src/services"; |
| @@ -119,13 +119,19 @@ scenario( · 91 unmodified lines | |||
| 119 | expect(result.text, "it returns its value").toContain("42"); | 119 | expect(result.text, "it returns its value").toContain("42"); |
| 120 | 120 | ||
| 121 | // Both billing calls really did reach Autumn and really were rejected — | 121 | // Both billing calls really did reach Autumn and really were rejected — |
| 122 | // without this the scenario could pass on a fault that never armed. | 122 | // without this the scenario could pass on a fault that never armed. The |
| 123 | const checks = yield* autumn.ledgerFor("balances.check"); | 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 | ); | ||
| 124 | expect( | 128 | expect( |
| 125 | checks.some((entry) => entry.faulted), | 129 | checks.some((entry) => entry.faulted), |
| 126 | "the balance check reached Autumn and was answered customer_not_found", | 130 | "the balance check reached Autumn and was answered customer_not_found", |
| 127 | ).toBe(true); | 131 | ).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 | ); | ||
| 129 | expect( | 135 | expect( |
| 130 | tracks.some((entry) => entry.faulted), | 136 | tracks.some((entry) => entry.faulted), |
| 131 | "the usage track reached Autumn and was answered customer_not_found", | 137 | "the usage track reached Autumn and was answered customer_not_found", |
| @@ -203,3 +209,73 @@ scenario( · 71 unmodified lines | |||
| 203 | }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); | 209 | }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); |
| 204 | }), | 210 | }), |
| 205 | ); | 211 | ); |
| 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 | ); | ||