Skip to content

Commit e79ed74

Browse files
waleedlatif1claude
andcommitted
fix(chat): drop the env knobs and put the ceiling under the plan bucket
Two corrections to the execution throttle. The per-deployment ceiling was 300/min, at or above the workspace `sync` counter it debits on every plan but enterprise — 50 free, 150 pro, 300 team. A flood therefore drained that shared counter, which the owner's API, webhook and scheduled runs draw from too, before the ceiling ever refused: the availability half of the report went unmitigated on exactly the plans most workspaces are on. It is now 60/min sustained, under even the cheapest paid plan, with a test that pins it there against `RATE_LIMITS`. The per-IP bucket drops to 30/min so one host cannot take a deployment's whole allowance, and both gain the 2x burst allowance the plan buckets already use. Both limits go back to plain constants. Every sibling deployment throttle — password, OTP, SSO, on chat and on public file shares — is a hardcoded `TokenBucketConfig`, so the two env vars were the only configurable ones of their kind and bought speculative tuning for a control with sane defaults. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QWh9WFMYNZ6uTFQFUzF8Bj
1 parent 4d9eee8 commit e79ed74

4 files changed

Lines changed: 40 additions & 17 deletions

File tree

apps/docs/content/docs/platform/self-hosting/environment-variables.mdx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,6 @@ Without a remote provider, user code runs in an in-process V8 isolate inside the
159159
| `API_MAX_JSON_BODY_BYTES` | 50 MB | Max JSON body on contract-validated API routes |
160160
| `CHAT_MAX_REQUEST_BYTES` | 220 MB | Max body on the public deployed-chat endpoint |
161161
| `WEBHOOK_MAX_REQUEST_BYTES` | 10 MB | Max body on public webhook receiver endpoints |
162-
| `DEPLOYMENT_IP_EXECUTIONS_PER_MINUTE` | `60` | Executions one client IP may drive against a single deployed chat |
163-
| `DEPLOYMENT_EXECUTIONS_PER_MINUTE` | `300` | Executions one deployed chat may serve per minute across all callers |
164162
| `WORKFLOW_EXECUTION_CONCURRENCY_LIMIT` | `75` | Workflow executions in parallel |
165163
| `WEBHOOK_EXECUTION_CONCURRENCY_LIMIT` | `75` | Webhook-triggered executions in parallel |
166164
| `SCHEDULE_EXECUTION_CONCURRENCY_LIMIT` | `30` | Scheduled executions in parallel |

apps/sim/app/api/chat/[identifier]/route.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ vi.mock('@/lib/core/rate-limiter', () => ({
131131
enforceResourceRateLimit: mockEnforceResourceRateLimit,
132132
}))
133133

134+
import { RATE_LIMITS } from '@/lib/core/rate-limiter/types'
134135
import { preprocessExecution } from '@/lib/execution/preprocessing'
135136
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
136137
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
@@ -377,12 +378,12 @@ describe('Chat Identifier API Route', () => {
377378
expect(mockEnforceIpRateLimit).toHaveBeenCalledWith(
378379
'chat-execute:chat-id',
379380
req,
380-
expect.objectContaining({ refillIntervalMs: 60_000 })
381+
expect.objectContaining({ refillRate: 30, refillIntervalMs: 60_000 })
381382
)
382383
expect(mockEnforceResourceRateLimit).toHaveBeenCalledWith(
383384
'chat-execute',
384385
'chat-id',
385-
expect.objectContaining({ refillIntervalMs: 60_000 })
386+
expect.objectContaining({ refillRate: 60, refillIntervalMs: 60_000 })
386387
)
387388
})
388389

@@ -395,6 +396,23 @@ describe('Chat Identifier API Route', () => {
395396
expect(mockEnforceResourceRateLimit).not.toHaveBeenCalled()
396397
})
397398

399+
/**
400+
* A chat execution debits the workspace `sync` counter that the owner's
401+
* API, webhook and scheduled runs share. A per-deployment ceiling at or
402+
* above the plan's own rate would never refuse before that shared counter
403+
* was drained, which is the availability half of the attack.
404+
*/
405+
it('stays below the cheapest paid plan sync rate', async () => {
406+
const req = createMockNextRequest('POST', { input: 'hello' })
407+
408+
await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) })
409+
410+
const [, , config] = mockEnforceResourceRateLimit.mock.calls[0]
411+
expect(config.refillRate).toBeLessThan(RATE_LIMITS.pro.sync.refillRate)
412+
expect(config.refillRate).toBeLessThan(RATE_LIMITS.team.sync.refillRate)
413+
expect(config.refillRate).toBeLessThan(RATE_LIMITS.enterprise.sync.refillRate)
414+
})
415+
398416
it('leaves the gate-configuration fetch unmetered', async () => {
399417
const passwordDeployment = {
400418
...mockChatResult[0],

apps/sim/app/api/chat/[identifier]/route.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { deployedChatPostContract } from '@/lib/api/contracts/chats'
88
import { parseRequest } from '@/lib/api/server'
99
import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
1010
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
11-
import { env, envNumber } from '@/lib/core/config/env'
11+
import { env } from '@/lib/core/config/env'
1212
import {
1313
enforceIpRateLimitWithIndependentBackstop,
1414
enforceResourceRateLimit,
@@ -54,10 +54,9 @@ export const runtime = 'nodejs'
5454

5555
const CHAT_MAX_REQUEST_BYTES = Number.parseInt(env.CHAT_MAX_REQUEST_BYTES, 10) || 220 * 1024 * 1024
5656

57-
/** A per-minute ceiling, as a bucket that refills its whole allowance each minute. */
58-
function executionsPerMinute(value: string | undefined, fallback: number): TokenBucketConfig {
59-
const perMinute = envNumber(value, fallback, { min: 1, integer: true })
60-
return { maxTokens: perMinute, refillRate: perMinute, refillIntervalMs: 60_000 }
57+
/** A sustained per-minute rate, with the 2x burst allowance the plan buckets use. */
58+
function executionsPerMinute(perMinute: number): TokenBucketConfig {
59+
return { maxTokens: perMinute * 2, refillRate: perMinute, refillIntervalMs: 60_000 }
6160
}
6261

6362
/**
@@ -66,16 +65,26 @@ function executionsPerMinute(value: string | undefined, fallback: number): Token
6665
* A deployed chat runs the owner's workflow on the owner's plan bucket, credit
6766
* balance and concurrency reservation for whoever holds the link, so every
6867
* ceiling on that path belongs to the payer and none of them bound the caller.
69-
* Sized well above a human conversation and above shared-NAT aggregation, so it
70-
* costs a flooder a botnet rather than costing a real audience its session.
68+
* Half the per-deployment rate, so one host cannot monopolize the deployment's
69+
* whole allowance, and still far above human chat cadence — a burst of 60 then
70+
* one message every two seconds — so shared NAT does not cost a real audience
71+
* its session.
7172
*/
72-
const CHAT_EXECUTION_IP_LIMIT = executionsPerMinute(env.DEPLOYMENT_IP_EXECUTIONS_PER_MINUTE, 60)
73+
const CHAT_EXECUTION_IP_LIMIT = executionsPerMinute(30)
7374

7475
/**
75-
* What bounds the owner's exposure when attempts are spread across addresses,
76-
* and the only bound left when the proxy chain resolves to no client IP.
76+
* What one deployed chat may spend of its owner's workspace allowance.
77+
*
78+
* This has to sit *below* the owner's plan bucket to do its job. A chat
79+
* execution debits the workspace `sync` counter — 50/min on free, 150 on pro,
80+
* 300 on team, 600 on enterprise — which is the same counter the owner's API,
81+
* webhook and scheduled runs draw from. A ceiling above it would let a flood
82+
* empty that shared counter before this bucket ever refused, which is how a
83+
* billing attack becomes an availability attack on unrelated production
84+
* workloads. At 60/min a public chat can spend at most a fraction of even the
85+
* cheapest paid plan and the owner's other triggers keep their headroom.
7786
*/
78-
const CHAT_EXECUTION_LIMIT = executionsPerMinute(env.DEPLOYMENT_EXECUTIONS_PER_MINUTE, 300)
87+
const CHAT_EXECUTION_LIMIT = executionsPerMinute(60)
7988

8089
export const POST = withRouteHandler(
8190
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {

apps/sim/lib/core/config/env.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,8 +403,6 @@ export const env = createEnv({
403403

404404
// Rate Limiting Configuration
405405
RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute)
406-
DEPLOYMENT_IP_EXECUTIONS_PER_MINUTE: z.string().optional().default('60'), // Executions one client IP may drive against a single deployed chat, billed to that chat's owner
407-
DEPLOYMENT_EXECUTIONS_PER_MINUTE: z.string().optional().default('300'), // Executions one deployed chat may serve per minute across all callers (owner-spend backstop)
408406
MANUAL_EXECUTION_LIMIT: z.string().optional().default('999999'),// Manual execution bypass value (effectively unlimited)
409407
RATE_LIMIT_FREE_SYNC: z.string().optional(), // Free tier sync API executions per minute (default 50). With billing disabled, setting it explicitly opts into rate limiting
410408
RATE_LIMIT_FREE_ASYNC: z.string().optional(), // Free tier async API executions per minute (default 200). With billing disabled, setting it explicitly opts into rate limiting

0 commit comments

Comments
 (0)