API Reference
Rate Limits
Rate limiting and quotas, including the anonymous free-tier limits
Solvela applies rate limiting at multiple layers to protect gateway stability and ensure fair usage across all clients.
Middleware stack
Requests pass through the following limiting layers in order:
| Layer | Scope | Default | Description |
|---|---|---|---|
| Per-client rate limiting | Payer wallet, falling back to client IP | 60 requests / 60 s (SOLVELA_RATE_LIMIT_MAX) | Primary limit. The identity is the payer wallet derived from the signed transaction in the payment-signature header; a request without a decodable payment falls back to its source IP. The fallback never trusts X-Forwarded-For (trivially spoofed) — set real client IPs at your proxy layer. |
| Free-tier limits | Per IP + global aggregate | See Free tier vs paid | Stricter limits enforced inside the chat handler on zero-cost requests only. |
| Global concurrency limiting | Gateway-wide | 256 in-flight (SOLVELA_MAX_CONCURRENT_REQUESTS) | Caps total in-flight requests across all clients to prevent overload. |
| Request timeout | Per request | 120 s (SOLVELA_REQUEST_TIMEOUT_SECS) | Hard timeout applied globally; expired requests return 408. |
Operational endpoints — /health, /v1/models, and /metrics — are exempt from rate limiting so health checks and load balancers keep working even when a client is over its limit.
Free tier vs paid limits
A request is free if and only if its quoted cost is exactly 0 atomic USDC — i.e. the resolved model is zero-priced in the registry. The free / oss / open routing profile targets the NVIDIA NIM Nemotron free-tier models (tiered by complexity), and google/gemini-3.1-flash-lite and openai/gpt-oss-120b are also zero-priced (Gemini 3.1 Flash-Lite is the fallback when NVIDIA NIM is down). Free requests are served at $0 with no payment-signature header and never touch the payment path. A payment header sent with a free model is ignored — the quoted amount is 0, so there is nothing to verify or claim.
Because the free path is anonymous (no payer wallet), its rate-limit identity is the actual TCP peer IP — never a client-supplied header such as X-Forwarded-For. Two gates run, in order, before any provider call:
| Limit | Bucket | Default | Window | Env override |
|---|---|---|---|---|
| Paid per-client | Payer wallet (IP fallback) | 60 requests | 60 s | SOLVELA_RATE_LIMIT_MAX |
| Free per-IP | TCP peer IP | 5 requests | 60 s | SOLVELA_FREE_TIER_RATE_LIMIT |
| Free aggregate (global) | All free clients combined | 12 requests | 60 s | SOLVELA_FREE_TIER_GLOBAL_RPM |
Per-IP free limit
An in-memory fixed-window counter keyed on the peer IP, intentionally much stricter than the paid 60/min limit. When the peer address is unavailable, all unidentified clients share a single stricter "unknown" bucket: 10/min on the paid limiter, 2/min on the free limiter. The unknown-bucket limits are fixed and not env-overridable — SOLVELA_FREE_TIER_RATE_LIMIT tunes only the per-IP free limit.
Aggregate (global) free cap
A single shared counter across all free clients per 1-minute window — distinct from the per-IP limiter, which cannot protect a shared ceiling (many distinct IPs each under their per-IP cap can still collectively exceed it). The free models are served on a shared upstream free-tier key, so the aggregate cap keeps total free throughput below the upstream provider's ceiling and the gateway returns its own clean 429 before the upstream's leaks through.
The counter is backed by Redis when configured (one shared key across gateway instances). When Redis is absent or errors at runtime, it degrades to an in-memory per-instance counter — bounded, never unbounded.
Free-tier env var behavior
- Setting
SOLVELA_FREE_TIER_RATE_LIMIT=0orSOLVELA_FREE_TIER_GLOBAL_RPM=0does not disable the limit — it would disable all free-tier access, so the gateway logs a warning and uses the default instead. - Legacy
RCR_-prefixed forms of all three rate-limit env vars are accepted as deprecated fallbacks.
Rate limit headers
Responses on rate-limited paths include headers indicating the limit state for your bucket (wallet or IP):
| Header | Description |
|---|---|
x-ratelimit-limit | Maximum requests allowed in the current window |
x-ratelimit-remaining | Requests remaining in the current window (0 on a 429) |
x-ratelimit-reset | Window length in seconds — not a Unix timestamp. The limiter is fixed-window; the window starts at your first request in it. |
retry-after | 429 responses only — seconds to wait before retrying (same value as x-ratelimit-reset) |
When a request is rejected, the gateway returns 429 Too Many Requests with an error of type rate_limit_exceeded:
{
"error": {
"type": "rate_limit_exceeded",
"message": "Too many requests. Please slow down."
}
}# Successful paid request
HTTP/2 200
x-ratelimit-limit: 60
x-ratelimit-remaining: 42
x-ratelimit-reset: 60# Rejected free-tier request (per-IP limit)
HTTP/2 429
x-ratelimit-limit: 5
x-ratelimit-remaining: 0
x-ratelimit-reset: 60
retry-after: 60On a 429, the headers describe the limiter that rejected you: a free-tier rejection carries the free limit (e.g. 5) and an aggregate-cap rejection carries the global cap (e.g. 12) — the outer 60/min middleware never overwrites a 429's headers with its looser values.
Note
Rate limit windows and exact thresholds are subject to change. Always read the headers from live responses rather than hardcoding expected values.
Spending controls (enterprise)
Separate from rate limiting, enterprise organizations can cap spend per team or per wallet. These limits are enforced at the gateway before a request is proxied to the provider, and are independent of the request-rate limits above.
Budgets
Set hourly, daily, and/or monthly USDC spend limits:
- Team budget —
PUT /v1/orgs/{id}/teams/{tid}/budget. Accepts a global admin token or an org-scoped API key with admin access to that org. - Wallet budget —
PUT /v1/wallets/{wallet}/budget. Admin token only — the path is not org-scoped, so org API keys cannot be verified against an org and are rejected.
curl -X PUT https://api.solvela.ai/v1/orgs/<org_id>/teams/<team_id>/budget \
-H "Authorization: Bearer solvela_k_your_key_here" \
-H "Content-Type: application/json" \
-d '{"hourly": 10.0, "daily": 100.0, "monthly": 2000.0}'All three fields are optional bare USDC numbers — omit one to leave that window uncapped. The matching GET returns the configured limits alongside current spend (hourly_spend, daily_spend, monthly_spend).
When an estimated request cost would push a wallet over its limit, the gateway rejects the request with 400 Bad Request (error type bad_request, message budget exceeded for wallet ...) before calling the provider. This is distinct from the 429 rate-limit response — a budget block is about cost, not request rate.
See Budgets for the full reference.
Handling rate limit errors
When you receive a 429, read retry-after (a duration in seconds) and wait that long before retrying:
function retryDelayMs(res: Response): number {
const retryAfterSecs = parseInt(res.headers.get("retry-after") ?? "60", 10);
return retryAfterSecs * 1000;
}
// On a 429 from any HTTP call to the gateway:
// const waitMs = retryDelayMs(response);
// await new Promise((r) => setTimeout(r, waitMs));
// // ...then retry the request once.Warning
Do not retry on 429 immediately in a tight loop. The window is fixed, so retries before it elapses will not succeed and will count against your limit once it resets. retry-after and x-ratelimit-reset are durations in seconds, not timestamps — wait that many seconds before retrying.