Skip to content

Rate Limits & Token Budgets

Applies tov1.4.0DifficultyIntermediateImpactNo restart

Two different problems share this page because they are enforced by the same machinery:

  • Rate limits protect capacity. They stop one client from consuming the GPU tier.
  • Budgets protect money. They stop a runaway loop from producing a five-figure invoice.

You almost certainly want both. Rate limits alone will not stop a slow, steady, expensive client.

The first control: how many requests, how fast.

oculis-config.yaml
oculis:
rate_limits:
# Applies to any tenant without a more specific rule.
default:
requests_per_minute: 120
burst: 30 # tokens in the bucket above the steady rate
concurrent_requests: 16
tenants:
- tenant: acme
requests_per_minute: 600
burst: 150
concurrent_requests: 64
- tenant: internal-batch
requests_per_minute: 3000
burst: 500
concurrent_requests: 256
priority: low # yields to interactive traffic under contention

requests_per_minute is a token bucket, not a fixed window. burst is how far above the steady rate a client may spike before being throttled — set it to roughly 25% of the per-minute rate so normal bursty traffic is not punished.

Exceeding a limit returns ERR_OCULIS_RATE_LIMIT_LOCAL with a Retry-After header.

The second control: how much consumption, over what period.

oculis-config.yaml
oculis:
budgets:
- tenant: acme
tokens_per_day: 5_000_000
spend_per_month_usd: 2000
on_exceeded: downgrade # or `reject`
downgrade_route: cheap-tier
alert_thresholds: [0.5, 0.8, 0.95]
- tenant: trial
tokens_per_day: 100_000
spend_per_month_usd: 25
on_exceeded: reject
Behavior What the client sees Use when
reject 402 ERR_OCULIS_BUDGET_EXCEEDED A hard cap is the point — trials, untrusted tenants.
downgrade A response from a cheaper model Continuity matters more than consistency — paying customers.

downgrade is usually the right default for production tenants: the tenant keeps working in a degraded mode instead of experiencing an outage on the last day of the month.

Downgrade and spend enforcement need to know what things cost:

oculis-config.yaml
oculis:
pricing:
# USD per million tokens
- model: gpt-4o-mini
input: 0.15
output: 0.60
- model: meta-llama/Llama-3.1-70B-Instruct
input: 0.0 # self-hosted; amortize separately
output: 0.0
infra_cost_per_hour_usd: 9.80 # optional, for cost attribution

By default every replica keeps its own counters. Two replicas with a 600 rpm limit will admit 1200 rpm in aggregate — which is usually not what you meant.

oculis-config.yaml
oculis:
rate_limits:
backend: redis
redis:
url: ${env:OCULIS_REDIS_URL} # redis://:pass@host:6379/0
key_prefix: 'oculis:rl:'
timeout_ms: 50
# If Redis is unreachable, fall back to per-replica local counters
# rather than failing requests.
fail_open: true

Every response carries the current state so clients can self-regulate rather than discover limits by hitting them:

Terminal window
X-Oculis-RateLimit-Limit: 600
X-Oculis-RateLimit-Remaining: 412
X-Oculis-RateLimit-Reset: 27
X-Oculis-Budget-Tokens-Remaining: 3891204
X-Oculis-Budget-Spend-Remaining-Usd: 431.18
Retry-After: 27

Client libraries should read Retry-After and back off with jitter. See rate limit cascades for what happens when they do not.

  1. Validate and apply.

    Terminal window
    oculis config validate --file oculis-config.yaml --explain
    oculis apply --file oculis-config.yaml --reload

    Rate limits and budgets apply on --reload without a restart.

  2. Confirm the effective limit for a tenant.

    Terminal window
    oculis limits show --tenant acme

Verification

Confirms the limit is actually enforced, the correct status is returned, and budget counters are being tracked. A limit you have not tripped on purpose is not a limit you can rely on.

1. Trip the rate limit deliberately.

Terminal window
oculis limits test --tenant acme --rate 900 --duration 10s
Expected output
[INFO] Target 900 rpm against limit 600 rpm (burst 150)
[INFO] Sent 150 requests in 1.0s — all admitted (burst absorbed)
[INFO] Steady state: 600 admitted/min, 300 rejected/min
[INFO] Rejections returned 429 with Retry-After (min 1s, max 12s)
[PASS] Rate limit enforced at 600 rpm (±2%).

2. Confirm the counters are exported.

Terminal window
curl -s http://localhost:9090/metrics | grep -E 'oculis_(ratelimit|budget)_'
Expected output
oculis_ratelimit_rejected_total{tenant="acme",reason="rpm"} 50
oculis_ratelimit_admitted_total{tenant="acme"} 100
oculis_budget_tokens_used{tenant="acme",window="day"} 1108796
oculis_budget_spend_usd{tenant="acme",window="month"} 1568.82

3. Confirm distributed enforcement, if configured. Run the same test against two replicas and confirm the aggregate stays at 600 rpm rather than 1200:

Terminal window
oculis limits test --tenant acme --rate 1500 --duration 10s --via-load-balancer
  • Streaming requests count once, at admission. A stream that runs for two minutes occupies a concurrency slot for that whole time but consumes one request from the rate bucket.
  • Token budgets are checked before generation, using an estimate. Actual output tokens are reconciled after the response, so a tenant can overshoot a daily cap by roughly one request.
  • Budget windows are UTC. tokens_per_day resets at 00:00 UTC, not in the tenant’s timezone.
  • Priority is advisory. priority: low yields under contention but does not guarantee starvation avoidance for high-priority traffic during sustained overload.
  • A downgrade route must exist and be reachable. If downgrade_route names a route that is unhealthy, budget-exceeded traffic fails rather than downgrading.