Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Plans and limits

Rustybin can enforce plan limits itself, for hosted offerings: a shared free demo instance (public mode, limits per session) and one dedicated instance per paying workspace (limits for the whole instance). The limits are off by default: with RUSTYBIN_PLAN=none and no RUSTYBIN_LIMIT_* variable the limiter is not even in the middleware stack and responses carry no extra header.

Every rejection says clearly that it comes from Rustybin, so a 429 in a gateway demo is never mistaken for the gateway's own rate limiting.

For one instance serving many API keys, each with its own limits and daily quotas, see Shared mode and API keys.

Presets

RUSTYBIN_PLAN selects a preset:

freeproteamenterprise
Scopeper sessioninstanceinstanceinstance
Requests per second (burst)5 (20)50 (200)250 (1000)unlimited
Requests in flight10100500unlimited
Open streams325200unlimited
Stream lifetime5 min1 h4 hunlimited
Request quota10,000 per day1,000,000 per month10,000,000 per monthunlimited
Egress quota1 GB per day10 GB per month100 GB per monthunlimited

enterprise enforces nothing but reports its name (X-Rustybin-Plan, /_rustybin/usage). none is the default and changes nothing at all.

Overrides

Each dimension can be overridden on its own; 0 or unlimited turns it off:

VariableDimension
RUSTYBIN_LIMIT_RPS, RUSTYBIN_LIMIT_BURSTToken bucket: refill rate per second and capacity (burst 0 = the rps value)
RUSTYBIN_LIMIT_CONCURRENCYRequests in flight; a response counts until its body is completely sent
RUSTYBIN_LIMIT_STREAMSOpen SSE responses (text/event-stream, including MCP and A2A streams and streamed mock LLM replies) and WebSocket connections
RUSTYBIN_LIMIT_STREAM_SECSStream lifetime in seconds
RUSTYBIN_LIMIT_REQUESTSRequests per period
RUSTYBIN_LIMIT_EGRESS_MBResponse body megabytes per period (1 MB = 1,000,000 bytes, 0.5 works)
RUSTYBIN_LIMIT_PERIODday or month, calendar periods in UTC
RUSTYBIN_LIMIT_SCOPEinstance or session

Setting an override without a plan turns the limiter on with the plan name custom. See Configuration for the full variable list.

Scope. instance keeps one set of counters. session keeps one per session key: the X-Rustybin-Session header, else the client IP (with RUSTYBIN_TRUST_FORWARD=true behind a proxy). Session scope is a fairness tool for a shared demo instance, not abuse protection: a client can pick a new session header. At most 10,000 session counter sets are kept; when full, idle sets are dropped first.

Responses

A successful response carries the plan and, when a request quota is configured, the requests left in the current period:

GET {{plan_url}}/echo
X-Rustybin-Session: docs-headers
HTTP 200
[Asserts]
header "X-Rustybin-Plan" == "free"
header "X-Rustybin-Quota-Remaining" == "6"

A rejected request gets 429 Too Many Requests with:

  • Retry-After in seconds (1 for rate, concurrency and streams; the time until the period ends for a used up quota);
  • the IETF RateLimit fields of draft-ietf-httpapi-ratelimit-headers: RateLimit-Policy lists every configured policy ("rps", "concurrency", "streams", "requests", "egress", with q, w and qu parameters) and RateLimit names the exhausted one with r=0;
  • X-Rustybin-Limit with the dimension: rps, concurrency, streams, requests or egress, and X-Rustybin-Plan;
  • a JSON body with "error": "rustybin_plan_limit", the dimension, the plan and a message that starts with "This 429 comes from the Rustybin plan limit, not from your gateway."
# The bucket holds 5 tokens and refills 1 per second: the 6th quick request is rejected.
GET {{plan_url}}/echo
X-Rustybin-Session: docs-rps
[Options]
repeat: 5
HTTP 200

GET {{plan_url}}/echo
X-Rustybin-Session: docs-rps
HTTP 429
[Asserts]
header "X-Rustybin-Limit" == "rps"
header "X-Rustybin-Plan" == "free"
header "Retry-After" == "1"
header "RateLimit" == "\"rps\";r=0;t=1"
header "RateLimit-Policy" contains "\"rps\";q=1;w=1;rustybin-burst=5"
header "RateLimit-Policy" contains "\"requests\";q=7;w=86400"
jsonpath "$.error" == "rustybin_plan_limit"
jsonpath "$.limit" == "rps"
jsonpath "$.plan" == "free"
jsonpath "$.message" startsWith "This 429 comes from the Rustybin plan limit, not from your gateway."

Rejections happen before the request inspector and fault injection, so a rejected request is cheap and is not captured. When the CORS layer is on, these headers are exposed to browser clients.

Quotas

The request quota counts every admitted request (rejected ones are free). The egress quota counts response body bytes as they are sent. An exhausted quota rejects new requests until the period ends; a response already in flight is never cut, so usage can end slightly above the quota.

# 7 requests per day: after the burst, wait for two tokens, use them, then the
# daily quota is exhausted (Retry-After counts down to midnight UTC).
GET {{plan_url}}/echo
X-Rustybin-Session: docs-quota
[Options]
repeat: 5
HTTP 200

GET {{plan_url}}/echo
X-Rustybin-Session: docs-quota
[Options]
delay: 2200
HTTP 200

GET {{plan_url}}/echo
X-Rustybin-Session: docs-quota
HTTP 200
[Asserts]
header "X-Rustybin-Quota-Remaining" == "0"

GET {{plan_url}}/echo
X-Rustybin-Session: docs-quota
HTTP 429
[Asserts]
header "X-Rustybin-Limit" == "requests"
header "Retry-After" toInt > 0
header "RateLimit" startsWith "\"requests\";r=0;t="
jsonpath "$.limit" == "requests"
# 0.15 MB per day: the second 100 KB response is still sent in full, the third
# request is rejected.
GET {{plan_url}}/bytes/102400
X-Rustybin-Session: docs-egress
[Options]
repeat: 2
HTTP 200
[Asserts]
bytes count == 102400

GET {{plan_url}}/bytes/102400
X-Rustybin-Session: docs-egress
HTTP 429
[Asserts]
header "X-Rustybin-Limit" == "egress"
header "RateLimit-Policy" contains "\"egress\";q=150000;qu=\"content-bytes\";w=86400"

GET {{plan_url}}/_rustybin/usage
X-Rustybin-Session: docs-egress
HTTP 200
[Asserts]
jsonpath "$.egress.used_bytes" >= 204800
jsonpath "$.egress.remaining_bytes" == 0

WebSocket frames are not counted as egress (only HTTP response bodies are).

Streams

An SSE response or a WebSocket connection takes a stream slot instead of a request slot while it is open. At the lifetime limit an SSE response ends cleanly with a final comment line (: rustybin plan stream lifetime reached), and a WebSocket connection is closed with code 1008 and the reason rustybin plan stream lifetime reached.

# Streams end cleanly after RUSTYBIN_LIMIT_STREAM_SECS (2 s here): a final SSE
# comment, then the end of the body.
GET {{plan_url}}/sse?count=100&interval_ms=500
X-Rustybin-Session: docs-stream
HTTP 200
[Asserts]
header "Content-Type" startsWith "text/event-stream"
body contains "data:"
body endsWith ": rustybin plan stream lifetime reached\n\n"
duration < 6000

Usage

GET /_rustybin/usage reports the plan, the scope, the configured limits, the current period (start, end, seconds until the reset), requests and egress used and remaining, the available rate tokens, requests in flight and open streams. With session scope it only reports the caller's own counters (like the request inspector in public mode, nobody sees other sessions). The web console shows the same data in a "Plan and usage" card on the overview when a plan is active.

GET {{plan_url}}/_rustybin/usage
X-Rustybin-Session: docs-headers
HTTP 200
[Asserts]
jsonpath "$.plan" == "free"
jsonpath "$.scope" == "session"
jsonpath "$.session" == "docs-headers"
jsonpath "$.limits.rps" == 1
jsonpath "$.limits.burst" == 5
jsonpath "$.period.kind" == "day"
jsonpath "$.requests.used" == 1
jsonpath "$.requests.remaining" == 6
jsonpath "$.egress.used_bytes" > 0
jsonpath "$.open_streams.limit" == 1

Exemptions and the admin token

Never limited, counted or decorated: / (the platform health check), the web console (/ui/*), GET /_rustybin/usage, GET /_rustybin/status (which the console polls every few seconds), GET /_rustybin/ready and GET /_rustybin/metrics (so monitoring scrapes never use up a quota or show up as traffic in the usage counters). Other control plane routes count like any other request, but a request with the admin token is never rejected on them, so an administrator can always reach a busy instance.

# The platform health check, the console and the usage endpoint are never
# limited nor counted (5 echoes and the admin call so far, rejections are free).
GET {{plan_url}}/
X-Rustybin-Session: docs-rps
[Options]
repeat: 10
HTTP 200
[Asserts]
header "X-Rustybin-Plan" not exists

GET {{plan_url}}/_rustybin/usage
X-Rustybin-Session: docs-rps
HTTP 200
[Asserts]
jsonpath "$.requests.used" == 6
# Control plane requests with the admin token are never rejected...
GET {{plan_url}}/_rustybin/config
X-Rustybin-Session: docs-rps
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.plan" == "free"
jsonpath "$.limits.scope" == "session"

# ...but without it they are limited like any other route.
GET {{plan_url}}/_rustybin/config
X-Rustybin-Session: docs-rps
HTTP 429

Persistence

With RUSTYBIN_USAGE_FILE=/data/usage.json the instance quota counters (requests, egress bytes, period start) are written to that JSON file every 30 seconds when they changed, and once more on graceful shutdown after the connections drained. At startup the file is read back when it belongs to the current period, so a machine that scales to zero and restarts mid-month keeps its monthly quota. A file from an earlier period is ignored (the quota starts from zero). Rate tokens, in-flight requests and streams are not persisted, and neither are session counters.

RUSTYBIN_PLAN=pro RUSTYBIN_USAGE_FILE=/data/usage.json rustybin

gRPC

On the gRPC listener the rate and request quota of the plan apply to rustybin.echo.v1.EchoService calls (the session key comes from the x-rustybin-session metadata, else the peer address). A rejected call fails with RESOURCE_EXHAUSTED, the same explanation as the HTTP body and retry-after, x-rustybin-limit and x-rustybin-plan metadata. Health and reflection are exempt; concurrency, streams and egress are enforced on the HTTP listeners only.

Metrics

Every rejection, HTTP 429 or gRPC RESOURCE_EXHAUSTED, increments rustybin_limit_rejections_total{dimension} in the Prometheus metrics, with dimension one of rps, concurrency, streams, requests and egress (the X-Rustybin-Limit value). All five series are exported from the start, at 0, so an alert on rate(rustybin_limit_rejections_total[5m]) > 0 needs no absent() guard.

# Every 429 above is counted by dimension in the Prometheus metrics (the admin
# token lets the scrape through: counted, never rejected).
GET {{plan_url}}/_rustybin/metrics
X-Rustybin-Session: docs-metrics
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
body matches /rustybin_limit_rejections_total\{dimension="rps"\} [1-9]/
body matches /rustybin_limit_rejections_total\{dimension="requests"\} [1-9]/
body matches /rustybin_limit_rejections_total\{dimension="egress"\} [1-9]/
body contains "rustybin_limit_rejections_total{dimension=\"concurrency\"} 0"