Unified · Split
| @@ -108,6 +108,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 107 unmodified lines | |||
| 108 | 108 | ||
| 109 | const stubAutumn = Layer.succeed(AutumnService)({ | 109 | const stubAutumn = Layer.succeed(AutumnService)({ |
| 110 | use: () => Effect.die("revoke does not touch billing"), | 110 | use: () => Effect.die("revoke does not touch billing"), |
| 111 | ensureCustomer: () => Effect.die("revoke does not touch billing"), | ||
| 111 | checkExecutionBalance: () => Effect.die("revoke does not touch billing"), | 112 | checkExecutionBalance: () => Effect.die("revoke does not touch billing"), |
| 112 | trackExecution: () => Effect.void, | 113 | trackExecution: () => Effect.void, |
| 113 | }); | 114 | }); |
| @@ -22,6 +22,7 @@ import { env } from "cloudflare:workers"; · 21 unmodified lines | |||
| 22 | import { WorkOSError } from "./errors"; | 22 | import { WorkOSError } from "./errors"; |
| 23 | import { WorkOSClient } from "./workos"; | 23 | import { WorkOSClient } from "./workos"; |
| 24 | import { AutumnService } from "../extensions/billing/service"; | 24 | import { AutumnService } from "../extensions/billing/service"; |
| 25 | import { captureCauseEffect } from "../observability"; | ||
| 25 | import { | 26 | import { |
| 26 | hasPaidOrganizationSubscription, | 27 | hasPaidOrganizationSubscription, |
| 27 | isOverFreeOrganizationLimit, | 28 | isOverFreeOrganizationLimit, |
| @@ -415,7 +416,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 387 unmodified lines | |||
| 415 | ), | 416 | ), |
| 416 | { concurrency: 3 }, | 417 | { concurrency: 3 }, |
| 417 | ).pipe( | 418 | ).pipe( |
| 418 | Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())), | 419 | // Any Autumn failure here (outage or missing customer) leaves the |
| 420 | // paid/free split unknown, and the limit must fail closed. | ||
| 421 | Effect.mapError(() => new WorkOSError()), | ||
| 419 | Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), | 422 | Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), |
| 420 | ); | 423 | ); |
| 421 | 424 | ||
| @@ -431,6 +434,24 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 9 unmodified lines | |||
| 431 | s.upsertOrganization({ id: org.id, name: org.name }), | 434 | s.upsertOrganization({ id: org.id, name: org.name }), |
| 432 | ); | 435 | ); |
| 433 | 436 | ||
| 437 | // Provision the org's billing customer while we're the ones creating | ||
| 438 | // the org. Without this the first billing call an org ever makes is a | ||
| 439 | // non-creating one (balance check / usage track), which 404s and keeps | ||
| 440 | // 404ing — unlimited unbilled executions. Non-fatal: a billing blip | ||
| 441 | // must not block signup, and the billing seam heals a customer that | ||
| 442 | // is still missing later. | ||
| 443 | yield* autumn.ensureCustomer(org.id).pipe( | ||
| 444 | Effect.catch((error) => | ||
| 445 | Effect.gen(function* () { | ||
| 446 | yield* Effect.logWarning( | ||
| 447 | "createOrganization: could not provision the Autumn customer", | ||
| 448 | { organizationId: org.id, error }, | ||
| 449 | ); | ||
| 450 | yield* captureCauseEffect(error); | ||
| 451 | }), | ||
| 452 | ), | ||
| 453 | ); | ||
| 454 | |||
| 434 | // Try to attach the new org to the current session. This can fail | 455 | // Try to attach the new org to the current session. This can fail |
| 435 | // (or silently return a session still scoped to the old org) when | 456 | // (or silently return a session still scoped to the old org) when |
| 436 | // the caller's current session is stale — most commonly after the | 457 | // the caller's current session is stale — most commonly after the |
| @@ -515,7 +536,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 78 unmodified lines | |||
| 515 | yield* autumn | 536 | yield* autumn |
| 516 | .use((client) => client.customers.delete({ customerId: organizationId })) | 537 | .use((client) => client.customers.delete({ customerId: organizationId })) |
| 517 | .pipe( | 538 | .pipe( |
| 518 | Effect.catchTag("AutumnError", (error) => | 539 | // Includes the "customer never existed" answer: nothing to cancel |
| 540 | // is a fine outcome for a deleted org, and it is still worth a line. | ||
| 541 | Effect.catch((error) => | ||
| 519 | Effect.logWarning("deleteOrganization: failed to delete Autumn customer", { | 542 | Effect.logWarning("deleteOrganization: failed to delete Autumn customer", { |
| 520 | organizationId, | 543 | organizationId, |
| 521 | error, | 544 | error, |
| @@ -17,15 +17,61 @@ export class AutumnError extends Data.TaggedError("AutumnError")<{ · 16 unmodified lines | |||
| 17 | cause?: unknown; | 17 | cause?: unknown; |
| 18 | }> {} | 18 | }> {} |
| 19 | 19 | ||
| 20 | /** | ||
| 21 | * Autumn has no customer record for the organization. Split out from | ||
| 22 | * `AutumnError` because it is not an outage and must not be treated like one: | ||
| 23 | * an outage is transient and the right answer is to fail open and page, while | ||
| 24 | * a missing customer is a PERMANENT provisioning gap — every subsequent | ||
| 25 | * balance call 404s the same way, so the org runs unbilled and unmetered | ||
| 26 | * forever. Callers that own an organization id repair it (see | ||
| 27 | * `withProvisionedCustomer`); everything else still surfaces as `AutumnError`. | ||
| 28 | */ | ||
| 29 | export class AutumnCustomerNotFoundError extends Data.TaggedError("AutumnCustomerNotFoundError")<{ | ||
| 30 | message: string; | ||
| 31 | cause?: unknown; | ||
| 32 | }> {} | ||
| 33 | |||
| 34 | export type AutumnFailure = AutumnError | AutumnCustomerNotFoundError; | ||
| 35 | |||
| 36 | // Autumn's own error code for "no such customer", carried in the JSON body of | ||
| 37 | // a 404 (`{"message":"Customer <id> not found","code":"customer_not_found"}`). | ||
| 38 | const CUSTOMER_NOT_FOUND_CODE = "customer_not_found"; | ||
| 39 | |||
| 40 | /** | ||
| 41 | * True when `cause` is Autumn's "no such customer" answer. The autumn-js SDK | ||
| 42 | * throws an `AutumnError` carrying the raw HTTP `statusCode` and `body`; match | ||
| 43 | * on the code rather than the status alone so an unrelated 404 (a removed | ||
| 44 | * endpoint, a proxy) is still reported as a genuine failure. | ||
| 45 | */ | ||
| 46 | const isCustomerNotFoundCause = (cause: unknown): boolean => { | ||
| 47 | if (typeof cause !== "object" || cause === null) return false; | ||
| 48 | const { statusCode, body } = cause as { readonly statusCode?: unknown; readonly body?: unknown }; | ||
| 49 | if (statusCode !== 404 || typeof body !== "string") return false; | ||
| 50 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: classifying a third-party SDK's raw response body; a body that isn't JSON simply isn't this error | ||
| 51 | try { | ||
| 52 | // oxlint-disable-next-line executor/no-json-parse -- boundary: the autumn-js SDK hands back the response body as an unvalidated string | ||
| 53 | const parsed: unknown = JSON.parse(body); | ||
| 54 | if (typeof parsed !== "object" || parsed === null) return false; | ||
| 55 | return (parsed as { readonly code?: unknown }).code === CUSTOMER_NOT_FOUND_CODE; | ||
| 56 | } catch { | ||
| 57 | return false; | ||
| 58 | } | ||
| 59 | }; | ||
| 60 | |||
| 20 | // --------------------------------------------------------------------------- | 61 | // --------------------------------------------------------------------------- |
| 21 | // Service interface | 62 | // Service interface |
| 22 | // --------------------------------------------------------------------------- | 63 | // --------------------------------------------------------------------------- |
| 23 | 64 | ||
| 24 | export type IAutumnService = Readonly<{ | 65 | export type IAutumnService = Readonly<{ |
| 25 | use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnError, never>; | 66 | use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnFailure, never>; |
| 67 | /** | ||
| 68 | * Provision the organization's Autumn customer, creating it if Autumn has | ||
| 69 | * never seen it. Idempotent — safe to call on every org creation. | ||
| 70 | */ | ||
| 71 | ensureCustomer: (organizationId: string) => Effect.Effect<void, AutumnFailure, never>; | ||
| 26 | checkExecutionBalance: ( | 72 | checkExecutionBalance: ( |
| 27 | organizationId: string, | 73 | organizationId: string, |
| 28 | ) => Effect.Effect<{ readonly allowed: boolean }, AutumnError, never>; | 74 | ) => Effect.Effect<{ readonly allowed: boolean }, AutumnFailure, never>; |
| 29 | /** | 75 | /** |
| 30 | * Fire-and-forget-safe execution usage tracker. Errors are caught and | 76 | * Fire-and-forget-safe execution usage tracker. Errors are caught and |
| 31 | * logged; the returned Effect never fails. Callers typically | 77 | * logged; the returned Effect never fails. Callers typically |
| @@ -48,6 +94,7 @@ const make = Effect.sync(() => { · 16 unmodified lines | |||
| 48 | ); | 94 | ); |
| 49 | return { | 95 | return { |
| 50 | use: () => notConfigured, | 96 | use: () => notConfigured, |
| 97 | ensureCustomer: () => notConfigured, | ||
| 51 | checkExecutionBalance: () => notConfigured, | 98 | checkExecutionBalance: () => notConfigured, |
| 52 | trackExecution: () => Effect.void, | 99 | trackExecution: () => Effect.void, |
| 53 | } satisfies IAutumnService; | 100 | } satisfies IAutumnService; |
| @@ -62,16 +109,53 @@ const make = Effect.sync(() => { · 8 unmodified lines | |||
| 62 | const use = <A>(fn: (client: Autumn) => Promise<A>) => | 109 | const use = <A>(fn: (client: Autumn) => Promise<A>) => |
| 63 | Effect.tryPromise({ | 110 | Effect.tryPromise({ |
| 64 | try: () => fn(client), | 111 | try: () => fn(client), |
| 65 | catch: (cause) => new AutumnError({ message: "Autumn SDK request failed", cause }), | 112 | catch: (cause): AutumnFailure => |
| 66 | }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`)); | 113 | isCustomerNotFoundCause(cause) |
| 114 | ? new AutumnCustomerNotFoundError({ | ||
| 115 | message: "Autumn has no customer for this organization", | ||
| 116 | cause, | ||
| 117 | }) | ||
| 118 | : new AutumnError({ message: "Autumn SDK request failed", cause }), | ||
| 119 | // An inline arrow's `name` is "" — not nullish — so `??` left every | ||
| 120 | // Autumn call tracing as the bare span `autumn.`. | ||
| 121 | }).pipe(Effect.withSpan(`autumn.${fn.name || "use"}`)); | ||
| 122 | |||
| 123 | const ensureCustomer = (organizationId: string) => | ||
| 124 | Effect.asVoid(use((c) => c.customers.getOrCreate({ customerId: organizationId }))); | ||
| 125 | |||
| 126 | /** | ||
| 127 | * Run `operation`; if Autumn answers "no such customer", provision the | ||
| 128 | * organization's customer and run it ONCE more. | ||
| 129 | * | ||
| 130 | * This is the seam that closes the provisioning hole. Both billing paths use | ||
| 131 | * non-creating endpoints, so an organization Autumn never learned about | ||
| 132 | * 404s here forever: the balance gate fails open (correct for an outage, | ||
| 133 | * catastrophic as a steady state) and every usage track is lost. Repairing | ||
| 134 | * the customer makes the retry land the call — and a genuine Autumn outage | ||
| 135 | * still fails with `AutumnError` and still pages, unretried. | ||
| 136 | */ | ||
| 137 | const withProvisionedCustomer = <A>( | ||
| 138 | organizationId: string, | ||
| 139 | operation: Effect.Effect<A, AutumnFailure>, | ||
| 140 | ) => | ||
| 141 | operation.pipe( | ||
| 142 | Effect.catchTag("AutumnCustomerNotFoundError", () => | ||
| 143 | Effect.gen(function* () { | ||
| 144 | yield* Effect.annotateCurrentSpan({ "autumn.customer.provisioned": true }); | ||
| 145 | yield* ensureCustomer(organizationId); | ||
| 146 | return yield* operation; | ||
| 147 | }), | ||
| 148 | ), | ||
| 149 | ); | ||
| 67 | 150 | ||
| 68 | const trackExecution = (organizationId: string) => | 151 | const trackExecution = (organizationId: string) => |
| 69 | Effect.gen(function* () { | 152 | Effect.gen(function* () { |
| 70 | yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); | 153 | yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); |
| 71 | yield* use((c) => | 154 | yield* withProvisionedCustomer( |
| 72 | c.track({ customerId: organizationId, featureId: "executions", value: 1 }), | 155 | organizationId, |
| 156 | use((c) => c.track({ customerId: organizationId, featureId: "executions", value: 1 })), | ||
| 73 | ).pipe( | 157 | ).pipe( |
| 74 | Effect.catchTag("AutumnError", (error) => | 158 | Effect.catch((error) => |
| 75 | Effect.gen(function* () { | 159 | Effect.gen(function* () { |
| 76 | // Silent billing data loss is worth paging on: autumn.trackExecution | 160 | // Silent billing data loss is worth paging on: autumn.trackExecution |
| 77 | // is fire-and-forget so the caller doesn't handle it themselves. | 161 | // is fire-and-forget so the caller doesn't handle it themselves. |
| @@ -88,13 +172,14 @@ const make = Effect.sync(() => { · 10 unmodified lines | |||
| 88 | const checkExecutionBalance = (organizationId: string) => | 172 | const checkExecutionBalance = (organizationId: string) => |
| 89 | Effect.gen(function* () { | 173 | Effect.gen(function* () { |
| 90 | yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); | 174 | yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); |
| 91 | const check = yield* use((c) => | 175 | const check = yield* withProvisionedCustomer( |
| 92 | c.check({ customerId: organizationId, featureId: "executions" }), | 176 | organizationId, |
| 177 | use((c) => c.check({ customerId: organizationId, featureId: "executions" })), | ||
| 93 | ); | 178 | ); |
| 94 | return { allowed: check.allowed }; | 179 | return { allowed: check.allowed }; |
| 95 | }).pipe(Effect.withSpan("autumn.checkExecutionBalance")); | 180 | }).pipe(Effect.withSpan("autumn.checkExecutionBalance")); |
| 96 | 181 | ||
| 97 | return { use, checkExecutionBalance, trackExecution } satisfies IAutumnService; | 182 | return { use, ensureCustomer, checkExecutionBalance, trackExecution } satisfies IAutumnService; |
| 98 | }); | 183 | }); |
| 99 | 184 | ||
| 100 | export class AutumnService extends Context.Service<AutumnService, IAutumnService>()( | 185 | export class AutumnService extends Context.Service<AutumnService, IAutumnService>()( |
| @@ -0,0 +1,205 @@ | |||
| 1 | // Cloud-only (billing): an organization must EXIST as a customer at the billing | ||
| 2 | // provider, or every billing call for it answers `customer_not_found` forever. | ||
| 3 | // | ||
| 4 | // That state is invisible from the product side and catastrophic underneath it: | ||
| 5 | // the balance gate fails open (by design — a billing outage must not stop | ||
| 6 | // executions), and usage tracking is fire-and-forget, so the org runs unlimited | ||
| 7 | // executions and NONE of them reach the meter. The product looks perfectly | ||
| 8 | // healthy while every execution is unbilled and unmetered. | ||
| 9 | // | ||
| 10 | // Three guarantees are pinned here, all read from Autumn's own state rather than | ||
| 11 | // from the execution's response (which can never show any of them): | ||
| 12 | // | ||
| 13 | // 1. creating an organization provisions its billing customer up front, | ||
| 14 | // 2. if a customer is missing anyway, the billing seam heals it in place and | ||
| 15 | // the execution still lands on the meter, and | ||
| 16 | // 3. any OTHER billing failure is left alone — the repair is scoped to the | ||
| 17 | // "no such customer" answer, so a real provider failure is still reported | ||
| 18 | // rather than quietly retried away. | ||
| 19 | // | ||
| 20 | // The missing customer is produced with the emulator's fault injector — one | ||
| 21 | // 404 `customer_not_found` per billing endpoint — because the emulator, like | ||
| 22 | // real Autumn's SDK flow, otherwise auto-creates customers on contact. | ||
| 23 | import { expect } from "@effect/vitest"; | ||
| 24 | import { Effect } from "effect"; | ||
| 25 | |||
| 26 | import { scenario } from "../src/scenario"; | ||
| 27 | import { Autumn, Billing, Mcp, Target } from "../src/services"; | ||
| 28 | import type { Identity } from "../src/target"; | ||
| 29 | |||
| 30 | const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; | ||
| 31 | |||
| 32 | /** The org the bearer is scoped to — the Autumn customer id every billing call | ||
| 33 | * is made against — read from the JWT's public claims. */ | ||
| 34 | const orgIdOf = (bearer: string): string => { | ||
| 35 | const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as { | ||
| 36 | readonly org_id?: string; | ||
| 37 | }; | ||
| 38 | if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim"); | ||
| 39 | return claims.org_id; | ||
| 40 | }; | ||
| 41 | |||
| 42 | /** A 404 `customer_not_found`, byte-for-byte the shape Autumn answers with for | ||
| 43 | * an organization it has no customer record for. */ | ||
| 44 | const CUSTOMER_NOT_FOUND = { | ||
| 45 | status: 404, | ||
| 46 | body: { message: "Customer not found", code: "customer_not_found" }, | ||
| 47 | } as const; | ||
| 48 | |||
| 49 | /** A 404 that is NOT a missing customer — a moved route, a proxy, a gateway. | ||
| 50 | * Autumn carries a different code, and that code is the entire safety margin | ||
| 51 | * between "provision and retry" and "quietly retry away a real failure". */ | ||
| 52 | const UNRELATED_NOT_FOUND = { | ||
| 53 | status: 404, | ||
| 54 | body: { message: "Not Found", code: "not_found" }, | ||
| 55 | } as const; | ||
| 56 | |||
| 57 | scenario( | ||
| 58 | "Billing · creating an organization provisions it as a billing customer", | ||
| 59 | { timeout: 180_000 }, | ||
| 60 | Effect.gen(function* () { | ||
| 61 | yield* Billing; | ||
| 62 | const autumn = yield* Autumn; | ||
| 63 | const target = yield* Target; | ||
| 64 | const mcp = yield* Mcp; | ||
| 65 | |||
| 66 | // A brand-new user creating their first organization: none of the | ||
| 67 | // opportunistic billing lookups (the over-the-free-limit check, the members | ||
| 68 | // page's seat count, the rate limiter's paid-plan exemption) are reached on | ||
| 69 | // this journey, so org creation is the only chance to provision. | ||
| 70 | const identity = yield* target.newIdentity(); | ||
| 71 | const bearer = yield* mcp.mintBearer(emailOf(identity)); | ||
| 72 | const organizationId = orgIdOf(bearer); | ||
| 73 | |||
| 74 | const customerIds = yield* autumn.customerIds(); | ||
| 75 | expect( | ||
| 76 | customerIds, | ||
| 77 | "the new organization exists as a customer at the billing provider", | ||
| 78 | ).toContain(organizationId); | ||
| 79 | }), | ||
| 80 | ); | ||
| 81 | |||
| 82 | scenario( | ||
| 83 | "Billing · a missing billing customer is healed in place and the execution is still metered", | ||
| 84 | { timeout: 180_000 }, | ||
| 85 | Effect.gen(function* () { | ||
| 86 | yield* Billing; | ||
| 87 | const autumn = yield* Autumn; | ||
| 88 | const target = yield* Target; | ||
| 89 | const mcp = yield* Mcp; | ||
| 90 | |||
| 91 | const identity = yield* target.newIdentity(); | ||
| 92 | const bearer = yield* mcp.mintBearer(emailOf(identity)); | ||
| 93 | const customerId = orgIdOf(bearer); | ||
| 94 | |||
| 95 | const before = yield* autumn.usageEvents({ customerId, featureId: "executions" }); | ||
| 96 | expect(before.length, "a brand-new org starts with zero metered executions").toBe(0); | ||
| 97 | |||
| 98 | yield* Effect.gen(function* () { | ||
| 99 | // One 404 each: the next balance check and the next usage track both | ||
| 100 | // answer "this customer does not exist". A single `times: 1` fault means | ||
| 101 | // a seam that heals and retries gets through, while one that gives up | ||
| 102 | // loses the usage permanently — exactly the production failure. | ||
| 103 | yield* autumn.armFault({ | ||
| 104 | match: { operationId: "balances.check" }, | ||
| 105 | response: CUSTOMER_NOT_FOUND, | ||
| 106 | times: 1, | ||
| 107 | }); | ||
| 108 | yield* autumn.armFault({ | ||
| 109 | match: { operationId: "balances.track" }, | ||
| 110 | response: CUSTOMER_NOT_FOUND, | ||
| 111 | times: 1, | ||
| 112 | }); | ||
| 113 | |||
| 114 | const session = mcp.session(identity); | ||
| 115 | const result = yield* session.call("execute", { code: "return 6 * 7;" }); | ||
| 116 | |||
| 117 | // The gate still fails open: a billing problem never blocks a customer. | ||
| 118 | expect(result.ok, "the execution runs despite the missing billing customer").toBe(true); | ||
| 119 | expect(result.text, "it returns its value").toContain("42"); | ||
| 120 | |||
| 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"); | ||
| 124 | expect( | ||
| 125 | checks.some((entry) => entry.faulted), | ||
| 126 | "the balance check reached Autumn and was answered customer_not_found", | ||
| 127 | ).toBe(true); | ||
| 128 | const tracks = yield* autumn.ledgerFor("balances.track"); | ||
| 129 | expect( | ||
| 130 | tracks.some((entry) => entry.faulted), | ||
| 131 | "the usage track reached Autumn and was answered customer_not_found", | ||
| 132 | ).toBe(true); | ||
| 133 | |||
| 134 | // The guarantee: the seam provisions the customer and retries, so the | ||
| 135 | // execution lands on the meter. Before the fix this ledger stays empty | ||
| 136 | // forever — the execution ran, and nobody was ever billed for it. | ||
| 137 | const metered = yield* autumn.expectUsage({ | ||
| 138 | customerId, | ||
| 139 | featureId: "executions", | ||
| 140 | count: 1, | ||
| 141 | }); | ||
| 142 | expect(metered.length, "the execution is metered exactly once").toBe(1); | ||
| 143 | expect(metered[0]?.value, "it meters a single unit").toBe(1); | ||
| 144 | |||
| 145 | // Healing the customer is what made the retry possible, so the customer | ||
| 146 | // must exist at the provider afterwards. | ||
| 147 | const customerIds = yield* autumn.customerIds(); | ||
| 148 | expect(customerIds, "the organization has a billing customer afterwards").toContain( | ||
| 149 | customerId, | ||
| 150 | ); | ||
| 151 | }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); | ||
| 152 | }), | ||
| 153 | ); | ||
| 154 | |||
| 155 | scenario( | ||
| 156 | "Billing · an unrelated billing failure is reported, not retried away", | ||
| 157 | { timeout: 180_000 }, | ||
| 158 | Effect.gen(function* () { | ||
| 159 | yield* Billing; | ||
| 160 | const autumn = yield* Autumn; | ||
| 161 | const target = yield* Target; | ||
| 162 | const mcp = yield* Mcp; | ||
| 163 | |||
| 164 | const identity = yield* target.newIdentity(); | ||
| 165 | const bearer = yield* mcp.mintBearer(emailOf(identity)); | ||
| 166 | const customerId = orgIdOf(bearer); | ||
| 167 | |||
| 168 | yield* Effect.gen(function* () { | ||
| 169 | // A 404 that is not "no such customer". If the seam treated every 404 as | ||
| 170 | // a provisioning gap it would create a customer and retry, turning a real | ||
| 171 | // provider failure into a silent success — and the alert never fires. | ||
| 172 | yield* autumn.armFault({ | ||
| 173 | match: { operationId: "balances.track" }, | ||
| 174 | response: UNRELATED_NOT_FOUND, | ||
| 175 | times: 1, | ||
| 176 | }); | ||
| 177 | |||
| 178 | const session = mcp.session(identity); | ||
| 179 | const faulted = yield* session.call("execute", { code: "return 1 + 1;" }); | ||
| 180 | expect(faulted.ok, "the execution runs — billing never blocks a customer").toBe(true); | ||
| 181 | |||
| 182 | // A second, unfaulted execution. Its usage landing is the barrier: the | ||
| 183 | // first execution's track (and any retry of it) is already done by the | ||
| 184 | // time this one is on the meter, so the counts below are settled without | ||
| 185 | // waiting on a clock. | ||
| 186 | const clean = yield* session.call("execute", { code: "return 2 + 2;" }); | ||
| 187 | expect(clean.ok, "the follow-up execution runs too").toBe(true); | ||
| 188 | yield* autumn.expectUsage({ customerId, featureId: "executions", count: 1 }); | ||
| 189 | |||
| 190 | const tracks = (yield* autumn.ledgerFor("balances.track")).filter( | ||
| 191 | (entry) => entry.customerId === customerId, | ||
| 192 | ); | ||
| 193 | expect( | ||
| 194 | tracks.filter((entry) => entry.faulted).length, | ||
| 195 | "the first usage track was answered with the unrelated 404", | ||
| 196 | ).toBe(1); | ||
| 197 | // Two executions, two attempts: the failed one was reported and dropped, | ||
| 198 | // not repaired and replayed. | ||
| 199 | expect(tracks.length, "the rejected usage track is not retried").toBe(2); | ||
| 200 | |||
| 201 | const metered = yield* autumn.usageEvents({ customerId, featureId: "executions" }); | ||
| 202 | expect(metered.length, "only the unfaulted execution reaches the meter").toBe(1); | ||
| 203 | }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); | ||
| 204 | }), | ||
| 205 | ); | ||
| @@ -51,11 +51,19 @@ export interface LedgerEntry { · 50 unmodified lines | |||
| 51 | readonly method: string; | 51 | readonly method: string; |
| 52 | readonly path: string; | 52 | readonly path: string; |
| 53 | readonly faulted: boolean; | 53 | readonly faulted: boolean; |
| 54 | /** The customer the request was made against, read from its body. Autumn's | ||
| 55 | * ledger is shared by every scenario in the run, so attempts have to be | ||
| 56 | * attributed to one organization before they can be counted. */ | ||
| 57 | readonly customerId?: string; | ||
| 54 | } | 58 | } |
| 55 | 59 | ||
| 56 | export interface AutumnSurface { | 60 | export interface AutumnSurface { |
| 57 | /** One-shot read of the matching usage events from the ledger. */ | 61 | /** One-shot read of the matching usage events from the ledger. */ |
| 58 | readonly usageEvents: (query: UsageQuery) => Effect.Effect<readonly UsageEvent[], unknown>; | 62 | readonly usageEvents: (query: UsageQuery) => Effect.Effect<readonly UsageEvent[], unknown>; |
| 63 | /** Every customer id Autumn currently holds (`customers.list`). An org that | ||
| 64 | * was never provisioned as a billing customer is simply absent — the state | ||
| 65 | * in which every balance call 404s `customer_not_found` forever. */ | ||
| 66 | readonly customerIds: () => Effect.Effect<readonly string[], unknown>; | ||
| 59 | /** Poll until at least `count` matching events have landed. The track is | 67 | /** Poll until at least `count` matching events have landed. The track is |
| 60 | * forked and the worker drains it on `waitUntil` shortly after the execution | 68 | * forked and the worker drains it on `waitUntil` shortly after the execution |
| 61 | * returns, so arrival is eventually-consistent — polling IS the contract: | 69 | * returns, so arrival is eventually-consistent — polling IS the contract: |
| @@ -129,6 +137,26 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { · 67 unmodified lines | |||
| 129 | })); | 137 | })); |
| 130 | }); | 138 | }); |
| 131 | 139 | ||
| 140 | const customerIds = () => | ||
| 141 | Effect.gen(function* () { | ||
| 142 | const response = yield* Effect.promise(() => | ||
| 143 | fetch(`${autumnUrl}/v1/customers.list`, { | ||
| 144 | method: "POST", | ||
| 145 | headers: { "content-type": "application/json" }, | ||
| 146 | body: "{}", | ||
| 147 | }), | ||
| 148 | ); | ||
| 149 | if (!response.ok) { | ||
| 150 | return yield* Effect.fail( | ||
| 151 | `autumn customers.list responded ${response.status}: ${yield* Effect.promise(() => response.text())}`, | ||
| 152 | ); | ||
| 153 | } | ||
| 154 | const body = (yield* Effect.promise(() => response.json())) as { | ||
| 155 | readonly list?: ReadonlyArray<{ readonly id?: string }>; | ||
| 156 | }; | ||
| 157 | return (body.list ?? []).map((customer) => customer.id ?? ""); | ||
| 158 | }); | ||
| 159 | |||
| 132 | const settleCheckout = (sessionId: string) => | 160 | const settleCheckout = (sessionId: string) => |
| 133 | Effect.gen(function* () { | 161 | Effect.gen(function* () { |
| 134 | const response = yield* Effect.promise(() => | 162 | const response = yield* Effect.promise(() => |
| @@ -232,20 +260,30 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { · 97 unmodified lines | |||
| 232 | readonly method?: string; | 260 | readonly method?: string; |
| 233 | readonly path?: string; | 261 | readonly path?: string; |
| 234 | readonly faulted?: boolean; | 262 | readonly faulted?: boolean; |
| 263 | readonly request?: { readonly body?: unknown }; | ||
| 235 | }>; | 264 | }>; |
| 236 | }; | 265 | }; |
| 237 | return (body.entries ?? []) | 266 | return (body.entries ?? []) |
| 238 | .filter((entry) => entry.operationId === operationId) | 267 | .filter((entry) => entry.operationId === operationId) |
| 239 | .map((entry) => ({ | 268 | .map((entry) => { |
| 240 | operationId: entry.operationId, | 269 | const requestBody = entry.request?.body; |
| 241 | method: entry.method ?? "", | 270 | const customerId = |
| 242 | path: entry.path ?? "", | 271 | typeof requestBody === "object" && requestBody !== null |
| 243 | faulted: entry.faulted === true, | 272 | ? (requestBody as { readonly customer_id?: unknown }).customer_id |
| 244 | })); | 273 | : undefined; |
| 274 | return { | ||
| 275 | operationId: entry.operationId, | ||
| 276 | method: entry.method ?? "", | ||
| 277 | path: entry.path ?? "", | ||
| 278 | faulted: entry.faulted === true, | ||
| 279 | customerId: typeof customerId === "string" ? customerId : undefined, | ||
| 280 | }; | ||
| 281 | }); | ||
| 245 | }); | 282 | }); |
| 246 | 283 | ||
| 247 | return { | 284 | return { |
| 248 | usageEvents, | 285 | usageEvents, |
| 286 | customerIds, | ||
| 249 | settleCheckout, | 287 | settleCheckout, |
| 250 | exhaustExecutions, | 288 | exhaustExecutions, |
| 251 | attachPlan, | 289 | attachPlan, |