Skip to content

Commit 45e0fe6

Browse files
committed
fix(execution): run rate-limit gate only after ban/usage pass
The rate-limit gate is not read-only — checkRateLimitWithSubscription consumes a token — so running it in parallel with the read-only gates debited rate-limit quota for requests that the ban (403) or usage (402) gates reject, which the original sequential flow never did. Move the rate-limit gate to run sequentially after the ban and usage gates pass, preserving the read-only gates' parallelism (ban + subscription + usage) and the exact ban -> usage -> rate precedence. Add regression tests asserting the rate limiter is not consumed when an earlier gate rejects, and is consumed once when they pass. Caught by Cursor Bugbot review.
1 parent 17db5bf commit 45e0fe6

2 files changed

Lines changed: 62 additions & 15 deletions

File tree

apps/sim/lib/execution/preprocessing.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,40 @@ describe('preprocessExecution ban gate', () => {
229229
})
230230
})
231231

232+
it('does not debit rate-limit quota when the ban gate rejects', async () => {
233+
// The rate-limit gate consumes a token, so it must not run for a request
234+
// an earlier gate (ban) already rejects.
235+
mockGetActivelyBannedUserIds.mockResolvedValue(['billed-account-1'])
236+
237+
const result = await preprocessExecution({ ...baseOptions, checkRateLimit: true })
238+
239+
expect(result).toMatchObject({ success: false, error: { statusCode: 403 } })
240+
expect(mockCheckRateLimit).not.toHaveBeenCalled()
241+
})
242+
243+
it('does not debit rate-limit quota when the usage gate rejects', async () => {
244+
vi.mocked(checkServerSideUsageLimits).mockResolvedValue({
245+
isExceeded: true,
246+
currentUsage: 20,
247+
limit: 10,
248+
message: 'Usage limit exceeded. Please upgrade your plan to continue.',
249+
} as any)
250+
251+
const result = await preprocessExecution({ ...baseOptions, checkRateLimit: true })
252+
253+
expect(result).toMatchObject({ success: false, error: { statusCode: 402 } })
254+
expect(mockCheckRateLimit).not.toHaveBeenCalled()
255+
})
256+
257+
it('consumes the rate-limit gate exactly once when the ban and usage gates pass', async () => {
258+
mockCheckRateLimit.mockResolvedValue({ allowed: true, remaining: 5, resetAt: new Date() })
259+
260+
const result = await preprocessExecution({ ...baseOptions, checkRateLimit: true })
261+
262+
expect(result.success).toBe(true)
263+
expect(mockCheckRateLimit).toHaveBeenCalledTimes(1)
264+
})
265+
232266
it('checks the billing actor, caller-provided userId, and workflow owner in one call', async () => {
233267
const result = await preprocessExecution(baseOptions)
234268

apps/sim/lib/execution/preprocessing.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -573,11 +573,14 @@ export async function preprocessExecution(
573573
let rateLimitInfo: { allowed: boolean; remaining: number; resetAt: Date } | undefined
574574

575575
/**
576-
* STEP 6: rate-limit gate. Returns the failure outcome, or `null` on
577-
* pass/skip. On a non-error outcome it populates `rateLimitInfo` for the
578-
* success result, matching the sequential version.
576+
* STEP 6: rate-limit gate. Unlike the other gates this one is NOT read-only —
577+
* `checkRateLimitWithSubscription` consumes a token — so it is invoked
578+
* sequentially only after the ban and usage gates pass, matching the original
579+
* order. Running it eagerly or in parallel would debit rate-limit quota for
580+
* requests that ban or usage rejects. Returns the failure outcome, or `null`
581+
* on pass/skip; on a non-error outcome it populates `rateLimitInfo`.
579582
*/
580-
const rateLimitTask = (async (): Promise<GateFailure | null> => {
583+
const runRateLimitGate = async (): Promise<GateFailure | null> => {
581584
if (!checkRateLimit) return null
582585
try {
583586
const rateLimiter = new RateLimiter()
@@ -646,21 +649,31 @@ export async function preprocessExecution(
646649
},
647650
}
648651
}
649-
})()
652+
}
650653

651-
const [usageResult, rateLimitFailure] = await Promise.all([usageCheckTask, rateLimitTask])
652-
const usageFailure = usageResult.failure
654+
const usageResult = await usageCheckTask
653655
const usageSnapshot = usageResult.snapshot
654656

655-
// Evaluate outcomes in fixed precedence order — ban (403) → usage (402) →
656-
// rate limit (429) — so the response matches the sequential version exactly,
657-
// independent of which gate's promise settled first.
658-
const gateFailure = banFailure ?? usageFailure ?? rateLimitFailure
659-
if (gateFailure) {
660-
if (gateFailure.recordError) {
661-
await recordPreprocessingError(gateFailure.recordError)
657+
// The read-only gates (ban, subscription, usage) ran concurrently; evaluate
658+
// them in fixed precedence order — ban (403) → usage (402) — so the response
659+
// matches the sequential version regardless of which settled first.
660+
const readGateFailure = banFailure ?? usageResult.failure
661+
if (readGateFailure) {
662+
if (readGateFailure.recordError) {
663+
await recordPreprocessingError(readGateFailure.recordError)
664+
}
665+
return readGateFailure.response
666+
}
667+
668+
// Rate limiting (429) runs only after ban and usage pass, because it debits a
669+
// token — matching the original sequential order so rejected requests never
670+
// consume quota.
671+
const rateLimitFailure = await runRateLimitGate()
672+
if (rateLimitFailure) {
673+
if (rateLimitFailure.recordError) {
674+
await recordPreprocessingError(rateLimitFailure.recordError)
662675
}
663-
return gateFailure.response
676+
return rateLimitFailure.response
664677
}
665678

666679
/**

0 commit comments

Comments
 (0)