Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cloud/src/account/org-api-key-revoke.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const stubUsers = Layer.succeed(UserStoreService)({

const stubAutumn = Layer.succeed(AutumnService)({
use: () => Effect.die("revoke does not touch billing"),
ensureCustomer: () => Effect.die("revoke does not touch billing"),
checkExecutionBalance: () => Effect.die("revoke does not touch billing"),
trackExecution: () => Effect.void,
});
Expand Down
27 changes: 25 additions & 2 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { env } from "cloudflare:workers";
import { WorkOSError } from "./errors";
import { WorkOSClient } from "./workos";
import { AutumnService } from "../extensions/billing/service";
import { captureCauseEffect } from "../observability";
import {
hasPaidOrganizationSubscription,
isOverFreeOrganizationLimit,
Expand Down Expand Up @@ -415,7 +416,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
),
{ concurrency: 3 },
).pipe(
Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())),
// Any Autumn failure here (outage or missing customer) leaves the
// paid/free split unknown, and the limit must fail closed.
Effect.mapError(() => new WorkOSError()),
Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))),
);

Expand All @@ -431,6 +434,24 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
s.upsertOrganization({ id: org.id, name: org.name }),
);

// Provision the org's billing customer while we're the ones creating
// the org. Without this the first billing call an org ever makes is a
// non-creating one (balance check / usage track), which 404s and keeps
// 404ing — unlimited unbilled executions. Non-fatal: a billing blip
// must not block signup, and the billing seam heals a customer that
// is still missing later.
yield* autumn.ensureCustomer(org.id).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
yield* Effect.logWarning(
"createOrganization: could not provision the Autumn customer",
{ organizationId: org.id, error },
);
yield* captureCauseEffect(error);
}),
),
);

// Try to attach the new org to the current session. This can fail
// (or silently return a session still scoped to the old org) when
// the caller's current session is stale — most commonly after the
Expand Down Expand Up @@ -515,7 +536,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
yield* autumn
.use((client) => client.customers.delete({ customerId: organizationId }))
.pipe(
Effect.catchTag("AutumnError", (error) =>
// Includes the "customer never existed" answer: nothing to cancel
// is a fine outcome for a deleted org, and it is still worth a line.
Effect.catch((error) =>
Effect.logWarning("deleteOrganization: failed to delete Autumn customer", {
organizationId,
error,
Expand Down
105 changes: 95 additions & 10 deletions apps/cloud/src/extensions/billing/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,61 @@ export class AutumnError extends Data.TaggedError("AutumnError")<{
cause?: unknown;
}> {}

/**
* Autumn has no customer record for the organization. Split out from
* `AutumnError` because it is not an outage and must not be treated like one:
* an outage is transient and the right answer is to fail open and page, while
* a missing customer is a PERMANENT provisioning gap — every subsequent
* balance call 404s the same way, so the org runs unbilled and unmetered
* forever. Callers that own an organization id repair it (see
* `withProvisionedCustomer`); everything else still surfaces as `AutumnError`.
*/
export class AutumnCustomerNotFoundError extends Data.TaggedError("AutumnCustomerNotFoundError")<{
message: string;
cause?: unknown;
}> {}

export type AutumnFailure = AutumnError | AutumnCustomerNotFoundError;

// Autumn's own error code for "no such customer", carried in the JSON body of
// a 404 (`{"message":"Customer <id> not found","code":"customer_not_found"}`).
const CUSTOMER_NOT_FOUND_CODE = "customer_not_found";

/**
* True when `cause` is Autumn's "no such customer" answer. The autumn-js SDK
* throws an `AutumnError` carrying the raw HTTP `statusCode` and `body`; match
* on the code rather than the status alone so an unrelated 404 (a removed
* endpoint, a proxy) is still reported as a genuine failure.
*/
const isCustomerNotFoundCause = (cause: unknown): boolean => {
if (typeof cause !== "object" || cause === null) return false;
const { statusCode, body } = cause as { readonly statusCode?: unknown; readonly body?: unknown };
if (statusCode !== 404 || typeof body !== "string") return false;
// 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
try {
// oxlint-disable-next-line executor/no-json-parse -- boundary: the autumn-js SDK hands back the response body as an unvalidated string
const parsed: unknown = JSON.parse(body);
if (typeof parsed !== "object" || parsed === null) return false;
return (parsed as { readonly code?: unknown }).code === CUSTOMER_NOT_FOUND_CODE;
} catch {
return false;
}
};

// ---------------------------------------------------------------------------
// Service interface
// ---------------------------------------------------------------------------

export type IAutumnService = Readonly<{
use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnError, never>;
use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnFailure, never>;
/**
* Provision the organization's Autumn customer, creating it if Autumn has
* never seen it. Idempotent — safe to call on every org creation.
*/
ensureCustomer: (organizationId: string) => Effect.Effect<void, AutumnFailure, never>;
checkExecutionBalance: (
organizationId: string,
) => Effect.Effect<{ readonly allowed: boolean }, AutumnError, never>;
) => Effect.Effect<{ readonly allowed: boolean }, AutumnFailure, never>;
/**
* Fire-and-forget-safe execution usage tracker. Errors are caught and
* logged; the returned Effect never fails. Callers typically
Expand All @@ -48,6 +94,7 @@ const make = Effect.sync(() => {
);
return {
use: () => notConfigured,
ensureCustomer: () => notConfigured,
checkExecutionBalance: () => notConfigured,
trackExecution: () => Effect.void,
} satisfies IAutumnService;
Expand All @@ -62,16 +109,53 @@ const make = Effect.sync(() => {
const use = <A>(fn: (client: Autumn) => Promise<A>) =>
Effect.tryPromise({
try: () => fn(client),
catch: (cause) => new AutumnError({ message: "Autumn SDK request failed", cause }),
}).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`));
catch: (cause): AutumnFailure =>
isCustomerNotFoundCause(cause)
? new AutumnCustomerNotFoundError({
message: "Autumn has no customer for this organization",
cause,
})
: new AutumnError({ message: "Autumn SDK request failed", cause }),
// An inline arrow's `name` is "" — not nullish — so `??` left every
// Autumn call tracing as the bare span `autumn.`.
}).pipe(Effect.withSpan(`autumn.${fn.name || "use"}`));

const ensureCustomer = (organizationId: string) =>
Effect.asVoid(use((c) => c.customers.getOrCreate({ customerId: organizationId })));

/**
* Run `operation`; if Autumn answers "no such customer", provision the
* organization's customer and run it ONCE more.
*
* This is the seam that closes the provisioning hole. Both billing paths use
* non-creating endpoints, so an organization Autumn never learned about
* 404s here forever: the balance gate fails open (correct for an outage,
* catastrophic as a steady state) and every usage track is lost. Repairing
* the customer makes the retry land the call — and a genuine Autumn outage
* still fails with `AutumnError` and still pages, unretried.
*/
const withProvisionedCustomer = <A>(
organizationId: string,
operation: Effect.Effect<A, AutumnFailure>,
) =>
operation.pipe(
Effect.catchTag("AutumnCustomerNotFoundError", () =>
Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ "autumn.customer.provisioned": true });
yield* ensureCustomer(organizationId);
return yield* operation;
}),
),
);

const trackExecution = (organizationId: string) =>
Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });
yield* use((c) =>
c.track({ customerId: organizationId, featureId: "executions", value: 1 }),
yield* withProvisionedCustomer(
organizationId,
use((c) => c.track({ customerId: organizationId, featureId: "executions", value: 1 })),
).pipe(
Effect.catchTag("AutumnError", (error) =>
Effect.catch((error) =>
Effect.gen(function* () {
// Silent billing data loss is worth paging on: autumn.trackExecution
// is fire-and-forget so the caller doesn't handle it themselves.
Expand All @@ -88,13 +172,14 @@ const make = Effect.sync(() => {
const checkExecutionBalance = (organizationId: string) =>
Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });
const check = yield* use((c) =>
c.check({ customerId: organizationId, featureId: "executions" }),
const check = yield* withProvisionedCustomer(
organizationId,
use((c) => c.check({ customerId: organizationId, featureId: "executions" })),
);
return { allowed: check.allowed };
}).pipe(Effect.withSpan("autumn.checkExecutionBalance"));

return { use, checkExecutionBalance, trackExecution } satisfies IAutumnService;
return { use, ensureCustomer, checkExecutionBalance, trackExecution } satisfies IAutumnService;
});

export class AutumnService extends Context.Service<AutumnService, IAutumnService>()(
Expand Down
Loading
Loading