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:
free | pro | team | enterprise | |
|---|---|---|---|---|
| Scope | per session | instance | instance | instance |
| Requests per second (burst) | 5 (20) | 50 (200) | 250 (1000) | unlimited |
| Requests in flight | 10 | 100 | 500 | unlimited |
| Open streams | 3 | 25 | 200 | unlimited |
| Stream lifetime | 5 min | 1 h | 4 h | unlimited |
| Request quota | 10,000 per day | 1,000,000 per month | 10,000,000 per month | unlimited |
| Egress quota | 1 GB per day | 10 GB per month | 100 GB per month | unlimited |
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:
| Variable | Dimension |
|---|---|
RUSTYBIN_LIMIT_RPS, RUSTYBIN_LIMIT_BURST | Token bucket: refill rate per second and capacity (burst 0 = the rps value) |
RUSTYBIN_LIMIT_CONCURRENCY | Requests in flight; a response counts until its body is completely sent |
RUSTYBIN_LIMIT_STREAMS | Open SSE responses (text/event-stream, including MCP and A2A streams and streamed mock LLM replies) and WebSocket connections |
RUSTYBIN_LIMIT_STREAM_SECS | Stream lifetime in seconds |
RUSTYBIN_LIMIT_REQUESTS | Requests per period |
RUSTYBIN_LIMIT_EGRESS_MB | Response body megabytes per period (1 MB = 1,000,000 bytes, 0.5 works) |
RUSTYBIN_LIMIT_PERIOD | day or month, calendar periods in UTC |
RUSTYBIN_LIMIT_SCOPE | instance 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-Afterin 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-Policylists every configured policy ("rps","concurrency","streams","requests","egress", withq,wandquparameters) andRateLimitnames the exhausted one withr=0; X-Rustybin-Limitwith the dimension:rps,concurrency,streams,requestsoregress, andX-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"