Quotas and limits
Concurrency ceilings, spend caps, how they differ, and how to keep an agent loop from producing a surprising bill.
Two mechanisms, with different enforcement properties. Use both.
| Quota | Spend cap | |
|---|---|---|
| Limits | Concurrency and rate | Money |
| Enforcement | Synchronous, at admission | Eventually consistent, from rated usage |
| Lag | None | Up to 60 s |
| Error | 429 quota_exceeded | 402 spend_cap_reached |
| Good at | Hard ceilings | Budget discipline |
A spend cap alone cannot stop a runaway loop in the first minute. A quota can. The correct configuration is a quota sized to what the workload actually needs, plus a cap as the budget backstop.
Quotas
curl -sS "$WB_BASE_URL/quotas" -H "Authorization: Bearer $WB_API_KEY"
{
"scope": "organization",
"limits": {
"concurrent_sessions": { "tier1": 500, "tier2": 100, "tier3": 50, "tier4": 10 },
"session_creates_per_minute": 600,
"concurrent_runs_per_session": 4,
"storage_gib": 10000,
"egress_gb_per_day": 2000,
"snapshots": 5000,
"volumes": 500
},
"current": {
"concurrent_sessions": { "tier1": 42, "tier2": 18, "tier3": 3, "tier4": 1 },
"storage_gib": 1842,
"egress_gb_today": 118
}
}
Scoping
Quotas nest. The effective limit is the minimum of every applicable scope.
A reseller that sets only an organization quota has one customer able to consume everything.
# project
curl -sS -X PATCH "$WB_BASE_URL/projects/proj_7fTn21" \
-H "Authorization: Bearer $WB_API_KEY" -H "Content-Type: application/json" \
-d '{ "quotas": { "concurrent_sessions": { "tier1": 200, "tier2": 50 } } }'
# end user
curl -sS -X PATCH "$WB_BASE_URL/end-users/eu_customer_4821" \
-H "Authorization: Bearer $WB_API_KEY" -H "Content-Type: application/json" \
-d '{ "quotas": { "concurrent_sessions": { "tier2": 5, "tier4": 1 } } }'
Behaviour at the limit
{
"code": "quota_exceeded",
"status": 429,
"detail": "Project proj_7fTn21 is at its tier 2 concurrency limit of 50.",
"retry_after_s": 12,
"meta": { "scope": "project", "tier": 2, "limit": 50, "current": 50 }
}
Running sessions are never killed by a quota. Only new creates are refused — failing to start is recoverable, failing mid-run usually is not.
Wait on a session.stopped webhook rather than a timer. The slot frees the moment a session ends.
Spend caps
curl -sS "$WB_BASE_URL/spend-caps" \
-H "Authorization: Bearer $WB_API_KEY" -H "Content-Type: application/json" \
-d '{
"scope": "organization",
"period": "monthly",
"amount_minor": 500000,
"currency": "USD",
"action": "block_and_stop",
"alerts": [ { "threshold_pct": 50 }, { "threshold_pct": 80 }, { "threshold_pct": 95 } ]
}'
action | At the cap |
|---|---|
alert_only | Webhook; nothing stops |
block_new | New sessions refused; running sessions continue |
block_and_stop | New refused, running stopped within 60 s |
Scopes: organization, project, end_user. Periods: daily, monthly.
Alerts fire as quota.threshold_reached webhooks. Act on 80%, not on the cap itself — by the cap it
is already an incident.
The overshoot window
Caps are checked against rated usage, which lags by up to 60 seconds. A burst can overshoot by about one minute of maximum burn.
worst-case overshoot ≈ concurrency quota × highest tier rate × 60 s
With a tier 4 quota of 10 and a rate of $0.000375/s, that is roughly $0.22. With a tier 4 quota of 1,000 it would be $22.50. Keeping concurrency quotas tight is what keeps the overshoot small, which is the second reason to set them.
Spend protection for agent loops
Agent loops fail in characteristic ways. These are the ones that produce a bill.
The forgotten session
// wrong — a throw leaks the session until the idle timeout
const session = await wb.sessions.create({ tier: 2 })
await doWork(session)
await wb.sessions.delete(session.id)
// right
const session = await wb.sessions.create({ tier: 2, idleTimeoutS: 120 })
try { await doWork(session) } finally { await wb.sessions.delete(session.id) }
Also set a short idle_timeout_s. It is the backstop for the case where the process holding the
finally dies.
The infinite loop
const MAX_STEPS = 50
for (let i = 0; i < MAX_STEPS; i++) {
const step = await model.next()
if (step.done) break
await wb.sessions.exec(session.id, { command: step.command })
}
A step ceiling in the loop, and max_lifetime_s on the session, catch the model that never decides
it is finished.
The runaway fan-out
import pLimit from 'p-limit'
const limit = pLimit(20) // not candidates.length
await Promise.all(candidates.map((c) => limit(() => wb.runs.create({ tier: 1, code: c.code }))))
A fan-out sized by input length is a fan-out sized by whatever an attacker can put in the input.
The wrong tier
The largest single lever. See choosing a tier.
The idle volume
Volumes bill whether attached or not. A nightly job listing volumes with no attachment in 30 days pays for itself quickly.
curl -sS "$WB_BASE_URL/volumes?state=available&unattached_since=30d" \ -H "Authorization: Bearer $WB_API_KEY"
Platform limits
Not configurable; they exist for fleet correctness.
| Limit | Value |
|---|---|
| Maximum session lifetime | 24 h (tiers 1–3), 12 h (tier 4) |
| Maximum idle timeout | 2 h |
| Maximum pause duration | 1 h |
| Maximum vCPU per session | 16 (32 on request) |
| Maximum memory per session | 64 GiB (128 on request) |
| Maximum volume size | 1 TiB |
| Maximum snapshot size | 500 GiB |
| Maximum exposed ports per session | 10 |
| Maximum concurrent runs per session | 16 |
| Maximum request body | 100 MiB |
| Maximum multipart upload | 100 GiB |
Maximum end_user_id length | 128 |
| Maximum labels / metadata keys | 16 each |
Monitoring
curl -sS "$WB_BASE_URL/usage/timeseries?period=today&bucket=5m&group_by=tier" \ -H "Authorization: Bearer $WB_API_KEY"
Alert on the derivative, not the total. A monthly spend alert at 80% fires when four-fifths of the budget is already gone; an alert on hourly spend exceeding 3× the trailing 7-day average fires while there is still something to do about it.
The customer console graphs both with limits drawn in. See customer console.