History

Exempt every non-free plan from the execution rate limit (#1934)

Rhys Sullivan committed 2 months ago

Commit 5e4da2a1 parent e9d9c6f4 files · +75 −15Browse files

Unified · Split

4 files
  1. apps/cloud/src/account/organization-limits.node.test.ts +32 −0
  2. apps/cloud/src/engine/execution-rate-limit.ts +7 −5
  3. apps/cloud/src/engine/execution-stack-metered.ts +11 −10
  4. apps/cloud/src/extensions/billing/plans.ts +25 −0
apps/cloud/src/account/organization-limits.node.test.ts+32−0
@@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line
2 2
3import {3import {
4 FREE_ORGANIZATIONS_PER_USER_LIMIT,4 FREE_ORGANIZATIONS_PER_USER_LIMIT,
5 hasNonFreeOrganizationSubscription,
5 hasPaidOrganizationSubscription,6 hasPaidOrganizationSubscription,
6 isOverFreeOrganizationLimit,7 isOverFreeOrganizationLimit,
7 shouldApplyFreeOrganizationLimit,8 shouldApplyFreeOrganizationLimit,
@@ -22,6 +23,37 @@ describe("organization limits", () => { · 14 unmodified lines
22 expect(hasPaidOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false);23 expect(hasPaidOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false);
23 });24 });
24 25
26 it("exempts any active non-free subscription from the execution rate limit", () => {
27 expect(hasNonFreeOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true);
28 expect(hasNonFreeOrganizationSubscription([{ planId: "enterprise", status: "trialing" }])).toBe(
29 true,
30 );
31 // Grandfathered and pay-as-you-go plans are not in the paid set, but they
32 // are not Free either — the backstop must never cap them.
33 expect(hasNonFreeOrganizationSubscription([{ planId: "professional", status: "active" }])).toBe(
34 true,
35 );
36 expect(hasNonFreeOrganizationSubscription([{ planId: "hobby", status: "active" }])).toBe(true);
37 expect(
38 hasNonFreeOrganizationSubscription([{ planId: "free-pay-as-you-go", status: "active" }]),
39 ).toBe(true);
40 expect(
41 hasNonFreeOrganizationSubscription([
42 { planId: "free", status: "active" },
43 { planId: "team", status: "active" },
44 ]),
45 ).toBe(true);
46 });
47
48 it("rate-limits orgs that hold nothing but Free or inactive subscriptions", () => {
49 expect(hasNonFreeOrganizationSubscription([])).toBe(false);
50 expect(hasNonFreeOrganizationSubscription([{ planId: "free", status: "active" }])).toBe(false);
51 expect(hasNonFreeOrganizationSubscription([{ planId: "team", status: "canceled" }])).toBe(
52 false,
53 );
54 expect(hasNonFreeOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false);
55 });
56
25 it("applies the free org limit only when none of the user's active orgs are paid", () => {57 it("applies the free org limit only when none of the user's active orgs are paid", () => {
26 const activeMemberships = [58 const activeMemberships = [
27 { organizationId: "org_free_1", status: "active" },59 { organizationId: "org_free_1", status: "active" },
apps/cloud/src/engine/execution-rate-limit.ts+7−5
@@ -8,11 +8,13 @@ · 7 unmodified lines
8// each MCP session lives in its own DO instance, so an in-memory counter8// each MCP session lives in its own DO instance, so an in-memory counter
9// would be per-session and trivially bypassed by opening more sessions).9// would be per-session and trivially bypassed by opening more sessions).
10//10//
11// Paid organizations are EXEMPT. The cap was sized for free-tier abuse but11// Every organization on a plan other than Free is EXEMPT. The cap was sized
12// applied to everyone, and on 2026-08-18 it blocked a paying customer12// for free-tier abuse but applied to everyone, and on 2026-08-18 it blocked a
13// mid-workload — their agent gave up on Executor and routed around it. Paid13// paying customer mid-workload — their agent gave up on Executor and routed
14// usage is what the balance gate and metered overage are for; this backstop14// around it. The first fix exempted only the plans sold today, and on
15// has no business capping it.15// 2026-09-03 that capped an org on a grandfathered plan. Non-free usage is
16// what the balance gate and metered overage are for; this backstop has no
17// business capping it.
16//18//
17// The exemption is resolved ONLY once the counter reports an org over the cap,19// The exemption is resolved ONLY once the counter reports an org over the cap,
18// so the common path (under the cap) costs the counter increment and nothing20// so the common path (under the cap) costs the counter increment and nothing
apps/cloud/src/engine/execution-stack-metered.ts+11−10
@@ -26,7 +26,7 @@ import { · 25 unmodified lines
26} from "@executor-js/api/server";26} from "@executor-js/api/server";
27 27
28import { AutumnService } from "../extensions/billing/service";28import { AutumnService } from "../extensions/billing/service";
29import { hasPaidOrganizationSubscription } from "../extensions/billing/plans";29import { hasNonFreeOrganizationSubscription } from "../extensions/billing/plans";
30import type { DbService } from "../db/db";30import type { DbService } from "../db/db";
31import { CloudExecutionSeamsLayer } from "../engine/execution-stack";31import { CloudExecutionSeamsLayer } from "../engine/execution-stack";
32import { makeExecutionLimitGate } from "./execution-gate";32import { makeExecutionLimitGate } from "./execution-gate";
@@ -35,8 +35,8 @@ import { withExecutionUsageTracking } from "./execution-usage"; · 2 unmodified lines
35 35
36// Usage-metering decorator bound to the billing service, plus the two36// Usage-metering decorator bound to the billing service, plus the two
37// pre-execution guards this layer owns, ordered cheapest first:37// pre-execution guards this layer owns, ordered cheapest first:
38// 1. rate-limit backstop (counter DO; free-tier abuse only — paid orgs are38// 1. rate-limit backstop (counter DO; free-tier abuse only — any org on a
39// exempt via the subscription lookup below)39// plan other than Free is exempt via the subscription lookup below)
40// 2. execution balance gate (Autumn check, cached 60s, fails open)40// 2. execution balance gate (Autumn check, cached 60s, fails open)
41// 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing41// 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing
42// call can't stall a user-facing execution.42// call can't stall a user-facing execution.
@@ -50,16 +50,17 @@ export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, A · 7 unmodified lines
50 const balanceGate = makeExecutionLimitGate((organizationId) =>50 const balanceGate = makeExecutionLimitGate((organizationId) =>
51 autumn.checkExecutionBalance(organizationId),51 autumn.checkExecutionBalance(organizationId),
52 );52 );
53 // The limiter's paid-org exemption. This is the billing coupling the53 // The limiter's exemption. This is the billing coupling the limiter
54 // limiter module deliberately avoids owning, and it reads the same54 // module deliberately avoids owning. Any active subscription other than
55 // `PAID_AUTUMN_PLAN_IDS` config as the org-creation and seat gates so55 // Free exempts the org: the backstop is for free-tier abuse, and the
56 // "paid" means one thing across the app. The limiter calls this only56 // narrower "is on a plan we sell today" predicate the org-creation gate
57 // for orgs already over the cap and caches the answer, so the extra57 // uses capped grandfathered and pay-as-you-go customers. The limiter
58 // Autumn round trip stays off the hot path.58 // calls this only for orgs already over the cap and caches the answer,
59 // so the extra Autumn round trip stays off the hot path.
59 const rateLimiter = makeCloudExecutionRateLimiter((organizationId) =>60 const rateLimiter = makeCloudExecutionRateLimiter((organizationId) =>
60 Effect.map(61 Effect.map(
61 autumn.use((client) => client.customers.getOrCreate({ customerId: organizationId })),62 autumn.use((client) => client.customers.getOrCreate({ customerId: organizationId })),
62 (customer) => hasPaidOrganizationSubscription(customer.subscriptions),63 (customer) => hasNonFreeOrganizationSubscription(customer.subscriptions),
63 ),64 ),
64 );65 );
65 return {66 return {
apps/cloud/src/extensions/billing/plans.ts+25−0
@@ -38,6 +38,31 @@ export const hasPaidOrganizationSubscription = ( · 37 unmodified lines
38 subscriptions: ReadonlyArray<OrganizationLimitSubscriptionSummary>,38 subscriptions: ReadonlyArray<OrganizationLimitSubscriptionSummary>,
39): boolean => subscriptions.some(isPaidOrganizationSubscription);39): boolean => subscriptions.some(isPaidOrganizationSubscription);
40 40
41// ---------------------------------------------------------------------------
42// Execution rate-limit exemption.
43//
44// The hourly execution backstop exists for FREE-tier abuse, so the only orgs
45// it may cap are those with nothing but the Free plan. This is deliberately
46// NOT `hasPaidOrganizationSubscription`: that set names only the plans sold
47// today, and every other plan an org can legitimately hold — grandfathered
48// (`hobby`, `professional`), pay-as-you-go, or one added later and not yet
49// listed here — fell through to the cap. A paying customer blocked by an
50// abuse backstop is the worse failure, so the exemption is "anything but
51// Free" rather than "one of the plans we remembered to list".
52// ---------------------------------------------------------------------------
53
54export const FREE_AUTUMN_PLAN_ID = "free";
55
56export const hasNonFreeOrganizationSubscription = (
57 subscriptions: ReadonlyArray<OrganizationLimitSubscriptionSummary>,
58): boolean =>
59 subscriptions.some(
60 (subscription) =>
61 subscription.planId != null &&
62 subscription.planId !== FREE_AUTUMN_PLAN_ID &&
63 ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""),
64 );
65
41export const shouldApplyFreeOrganizationLimit = (66export const shouldApplyFreeOrganizationLimit = (
42 activeMemberships: ReadonlyArray<OrganizationLimitMembershipSummary>,67 activeMemberships: ReadonlyArray<OrganizationLimitMembershipSummary>,
43 paidOrganizationIds: ReadonlySet<string>,68 paidOrganizationIds: ReadonlySet<string>,