Unified · Split
| @@ -21,7 +21,7 @@ · 20 unmodified lines | ||
| 21 | 21 | // 404 `customer_not_found` per billing endpoint — because the emulator, like |
| 22 | 22 | // real Autumn's SDK flow, otherwise auto-creates customers on contact. |
| 23 | 23 | import { expect } from "@effect/vitest"; |
| 24 | −import { Effect } from "effect"; | |
| 24 | +import { Effect, Schedule } from "effect"; | |
| 25 | 25 | |
| 26 | 26 | import { scenario } from "../src/scenario"; |
| 27 | 27 | import { Autumn, Billing, Mcp, Target } from "../src/services"; |
| @@ -119,13 +119,19 @@ scenario( · 91 unmodified lines | ||
| 119 | 119 | expect(result.text, "it returns its value").toContain("42"); |
| 120 | 120 | |
| 121 | 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. | |
| 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 | + ); | |
| 124 | 128 | expect( |
| 125 | 129 | checks.some((entry) => entry.faulted), |
| 126 | 130 | "the balance check reached Autumn and was answered customer_not_found", |
| 127 | 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 | 135 | expect( |
| 130 | 136 | tracks.some((entry) => entry.faulted), |
| 131 | 137 | "the usage track reached Autumn and was answered customer_not_found", |
| @@ -203,3 +209,73 @@ scenario( · 71 unmodified lines | ||
| 203 | 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 | +); | |