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
22
33 import {
44 FREE_ORGANIZATIONS_PER_USER_LIMIT,
5+ hasNonFreeOrganizationSubscription,
56 hasPaidOrganizationSubscription,
67 isOverFreeOrganizationLimit,
78 shouldApplyFreeOrganizationLimit,
@@ -22,6 +23,37 @@ describe("organization limits", () => { · 14 unmodified lines
2223 expect(hasPaidOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false);
2324 });
2425
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+
2557 it("applies the free org limit only when none of the user's active orgs are paid", () => {
2658 const activeMemberships = [
2759 { organizationId: "org_free_1", status: "active" },
apps/cloud/src/engine/execution-rate-limit.ts+7−5
@@ -8,11 +8,13 @@ · 7 unmodified lines
88 // each MCP session lives in its own DO instance, so an in-memory counter
99 // would be per-session and trivially bypassed by opening more sessions).
1010 //
11−// Paid organizations are EXEMPT. The cap was sized for free-tier abuse but
12−// applied to everyone, and on 2026-08-18 it blocked a paying customer
13−// mid-workload — their agent gave up on Executor and routed around it. Paid
14−// usage is what the balance gate and metered overage are for; this backstop
15−// has no business capping it.
11+// Every organization on a plan other than Free is EXEMPT. The cap was sized
12+// for free-tier abuse but applied to everyone, and on 2026-08-18 it blocked a
13+// paying customer mid-workload — their agent gave up on Executor and routed
14+// around it. The first fix exempted only the plans sold today, and on
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.
1618 //
1719 // The exemption is resolved ONLY once the counter reports an org over the cap,
1820 // 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
2626 } from "@executor-js/api/server";
2727
2828 import { AutumnService } from "../extensions/billing/service";
29−import { hasPaidOrganizationSubscription } from "../extensions/billing/plans";
29+import { hasNonFreeOrganizationSubscription } from "../extensions/billing/plans";
3030 import type { DbService } from "../db/db";
3131 import { CloudExecutionSeamsLayer } from "../engine/execution-stack";
3232 import { makeExecutionLimitGate } from "./execution-gate";
@@ -35,8 +35,8 @@ import { withExecutionUsageTracking } from "./execution-usage"; · 2 unmodified lines
3535
3636 // Usage-metering decorator bound to the billing service, plus the two
3737 // pre-execution guards this layer owns, ordered cheapest first:
38−// 1. rate-limit backstop (counter DO; free-tier abuse only — paid orgs are
39−// exempt via the subscription lookup below)
38+// 1. rate-limit backstop (counter DO; free-tier abuse only — any org on a
39+// plan other than Free is exempt via the subscription lookup below)
4040 // 2. execution balance gate (Autumn check, cached 60s, fails open)
4141 // 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing
4242 // call can't stall a user-facing execution.
@@ -50,16 +50,17 @@ export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, A · 7 unmodified lines
5050 const balanceGate = makeExecutionLimitGate((organizationId) =>
5151 autumn.checkExecutionBalance(organizationId),
5252 );
53− // The limiter's paid-org exemption. This is the billing coupling the
54− // limiter module deliberately avoids owning, and it reads the same
55− // `PAID_AUTUMN_PLAN_IDS` config as the org-creation and seat gates so
56− // "paid" means one thing across the app. The limiter calls this only
57− // for orgs already over the cap and caches the answer, so the extra
58− // Autumn round trip stays off the hot path.
53+ // The limiter's exemption. This is the billing coupling the limiter
54+ // module deliberately avoids owning. Any active subscription other than
55+ // Free exempts the org: the backstop is for free-tier abuse, and the
56+ // narrower "is on a plan we sell today" predicate the org-creation gate
57+ // uses capped grandfathered and pay-as-you-go customers. The limiter
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.
5960 const rateLimiter = makeCloudExecutionRateLimiter((organizationId) =>
6061 Effect.map(
6162 autumn.use((client) => client.customers.getOrCreate({ customerId: organizationId })),
62− (customer) => hasPaidOrganizationSubscription(customer.subscriptions),
63+ (customer) => hasNonFreeOrganizationSubscription(customer.subscriptions),
6364 ),
6465 );
6566 return {
apps/cloud/src/extensions/billing/plans.ts+25−0
@@ -38,6 +38,31 @@ export const hasPaidOrganizationSubscription = ( · 37 unmodified lines
3838 subscriptions: ReadonlyArray<OrganizationLimitSubscriptionSummary>,
3939 ): boolean => subscriptions.some(isPaidOrganizationSubscription);
4040
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+
54+export const FREE_AUTUMN_PLAN_ID = "free";
55+
56+export 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+
4166 export const shouldApplyFreeOrganizationLimit = (
4267 activeMemberships: ReadonlyArray<OrganizationLimitMembershipSummary>,
4368 paidOrganizationIds: ReadonlySet<string>,