Skip to content

Commit 706ae6b

Browse files
committed
fix(byok): do not cache a billing outage as an unentitled organization
resolveOrganizationPlan maps a failed billing read to false, which is indistinguishable from a real plan lapse. The entitlement cache stored that, so one transient outage held the gate shut for the full TTL and every inheriting run silently fell back to a metered hosted key — and the cache's rejection path, which exists to prevent exactly this, was unreachable. Give the resolver the onError option its neighbours already have and let the cached read ask for 'throw', so a failure stays out of the cache and the next resolution retries. Behavior for the call that saw the error is unchanged: getBYOKKey still fails closed. Reported by Cursor Bugbot.
1 parent 126fbab commit 706ae6b

3 files changed

Lines changed: 41 additions & 2 deletions

File tree

apps/sim/lib/api-key/byok-entitlement.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,20 @@ describe('organization BYOK entitlement', () => {
9292
expect(mockResolveOrganizationPlan).toHaveBeenCalledTimes(2)
9393
})
9494

95+
/**
96+
* The rejection path only exists in production because the cached read asks
97+
* for it. `resolveOrganizationPlan` otherwise maps a billing outage to
98+
* `false` — indistinguishable from a real lapse — and caching that would hold
99+
* the gate shut for the full TTL, silently metering every inheriting run.
100+
*/
101+
it('asks billing to throw rather than report an outage as unentitled', async () => {
102+
await isOrganizationBYOKEntitledCached(ORGANIZATION_ID)
103+
104+
expect(mockResolveOrganizationPlan).toHaveBeenCalledWith(ORGANIZATION_ID, {
105+
onError: 'throw',
106+
})
107+
})
108+
95109
it('does not cache a rejection, so a transient failure cannot pin the gate shut', async () => {
96110
mockResolveOrganizationPlan.mockRejectedValueOnce(new Error('billing read failed'))
97111

apps/sim/lib/api-key/byok-entitlement.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,15 @@ export async function isOrganizationBYOKEntitledCached(organizationId: string):
6565
if (cached.value !== null && cached.expiresAt > Date.now()) return cached.value
6666
}
6767

68-
const inflight = resolveOrganizationPlan(organizationId).then(
68+
/**
69+
* `onError: 'throw'` is load-bearing. Without it a billing-read outage
70+
* resolves to `false` exactly like a real plan lapse, and the entry below
71+
* would pin the gate shut for the whole TTL — every inheriting run silently
72+
* falling back to a metered hosted key. Throwing keeps the failure out of the
73+
* cache so the next resolution retries; `getBYOKKey` still fails closed for
74+
* the one call that saw it.
75+
*/
76+
const inflight = resolveOrganizationPlan(organizationId, { onError: 'throw' }).then(
6977
(entitled) => {
7078
entitlementCache.set(organizationId, {
7179
value: entitled,

apps/sim/lib/billing/core/subscription.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,21 @@ async function resolveOrganizationEnterprisePlan(organizationId: string): Promis
462462
* {@link resolveOrganizationEnterprisePlan}, which gates the Enterprise-only
463463
* tier. A billing-blocked organization resolves false either way.
464464
*/
465-
export async function resolveOrganizationPlan(organizationId: string): Promise<boolean> {
465+
interface ResolveOrganizationPlanOptions {
466+
/**
467+
* What a billing-read failure resolves to. `'return-false'` (default) fails
468+
* closed, which is what a one-shot gate wants. A caller that *caches* the
469+
* answer must pass `'throw'`: a swallowed failure is indistinguishable from a
470+
* real plan lapse, so caching it would hold the gate shut for the whole TTL
471+
* over what may be a momentary outage.
472+
*/
473+
onError?: 'return-false' | 'throw'
474+
}
475+
476+
export async function resolveOrganizationPlan(
477+
organizationId: string,
478+
options: ResolveOrganizationPlanOptions = {}
479+
): Promise<boolean> {
466480
try {
467481
if (!isBillingEnabled) {
468482
return true
@@ -486,6 +500,9 @@ export async function resolveOrganizationPlan(organizationId: string): Promise<b
486500
return !!orgSub && checkOrgPlan(orgSub)
487501
} catch (error) {
488502
logger.error('Error checking organization plan status', { error, organizationId })
503+
if (options.onError === 'throw') {
504+
throw error
505+
}
489506
return false
490507
}
491508
}

0 commit comments

Comments
 (0)