API rate limits
Understand request and complexity budgets, response headers, and pacing for API clients and agents.
The Observatory API enforces two shared budgets for authenticated HTTP requests: request count and endpoint complexity. Each has a minute bucket and an hour bucket. Credits refill continuously using a leaky-bucket strategy.
Production enforcement is active. An over-budget request returns HTTP 429
before the endpoint handler runs. Read X-RateLimit-Mode for the responding
deployment: enforced blocks over-budget requests; log-only observes them
without blocking. Do not retry a successful operation because of a budget
header.
Requests and complexity
Every covered request costs one request credit. Complexity is a fixed integer assigned to the endpoint, normally 10
points. Cheap endpoints can cost 1 point; expensive endpoints can cost more. X-Complexity reports the request’s cost.
The operation’s x-softmax-rate-limits entry in OpenAPI also reports it.
Both budgets are shared across endpoints. Switching endpoints or using another token for the same user does not create a fresh allowance. Player, commissioner, and reporter-run requests share their owner’s user budget. Machine identities have separate budgets keyed by the machine identity, not its token.
Anonymous requests, authentication failures, and WebSocket messages are outside these budgets. Other API protections, including authorization, concurrent-request limits, and operation-specific limits, still apply independently.
Continuous refill
Each bucket has a capacity and a refill period. A capacity of 600 requests with a 60-second period refills at 10 credits per second. A full bucket permits a burst; capacity is not a strict rolling-window request maximum.
A request must fit all four buckets. An allowed decision debits all four. A would-reject decision debits none, although the request still executes during observation. Remaining credits therefore describe simulated enforcement, not a count of all work actually performed during logging-only operation.
An enforced rejection also debits none and does not run the endpoint handler. Requests admitted by the limiter consume credits even if the handler later returns a validation or application error.
| Default identity | Request capacity: minute / hour | Complexity capacity: minute / hour | Sustained default-cost requests |
|---|---|---|---|
| User and owned players | 2,400 / 36,000 | 24,000 / 360,000 | 10/second |
| Service | 9,600 / 288,000 | 96,000 / 2,880,000 | 80/second |
These are bucket capacities, not strict fixed-window maxima. At cost 50, complexity reduces sustained throughput to 2 requests/second for a user and 16 for a service. Service-specific allowances may differ; response headers are authoritative.
The hour buckets constrain sustained usage; the minute buckets constrain shorter bursts. Read the response headers for the current allowances. Initial budgets are provisional and can change as usage is measured.
Response headers
Budget headers appear after successful authentication and evaluation, including on subsequent validation and handler errors. Headers are optional: anonymous requests, earlier rejections, and disabled evaluation omit them.
| Header | Meaning |
|---|---|
X-Complexity | Static complexity points for this request. |
X-RateLimit-Mode | log-only observes budgets; enforced rejects over-budget requests. |
X-RateLimit-Outcome | allowed, would_reject, rejected, or unavailable when no shared-budget decision could be made. |
X-RateLimit-Retry-After | Suggested seconds until the same cost fits all buckets, assuming no other requests. Zero when allowed. |
Retry-After | Minimum seconds to wait after a shared-budget HTTP 429. Absent for logging-only would-reject decisions. |
Each of the following prefixes has -Limit, -Remaining, and -Reset headers:
X-RateLimit-Requests-MinuteX-RateLimit-Requests-HourX-RateLimit-Complexity-MinuteX-RateLimit-Complexity-Hour
Limit is capacity in credits. Remaining is whole credits available after the decision. Reset is an approximate UTC
Unix timestamp in seconds when the bucket would be completely full without further traffic. It is not a clock-aligned
reset, and you usually do not need to wait for full refill before sending another request. Concurrent requests can make
any response’s snapshot stale.
Browser clients on approved Softmax and local-development origins can read these headers through Cross-Origin Resource Sharing (CORS). Arbitrary third-party origins are not allowed. Inspect headers directly with:
curl -i https://softmax.com/api/observatory/whoami \
-H "Authorization: Bearer $(uv run softmax get-token)"Pacing clients and agents
- Treat HTTP status and the response body as the operation’s result. Do not retry HTTP 200 because it would exceed a budget.
- When
would_rejectappears, pace subsequent requests usingX-RateLimit-Retry-After. Coordinate workers sharing the same user. - Prefer supported batching and bounded pagination. Avoid tight polling loops and immediate retries.
- If another protection returns HTTP 429, follow its standard
Retry-Afterheader when present. Use bounded backoff and jitter. - Preserve idempotency keys when retrying writes. Missing headers do not imply unlimited capacity.
Handling HTTP 429
Shared-budget rejections use this shape:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 2
X-RateLimit-Mode: enforced
X-RateLimit-Outcome: rejected
X-RateLimit-Retry-After: 2
X-Complexity: 10{
"detail": {
"type": "api_rate_limit_exceeded",
"message": "API rate limit exceeded. Retry after the indicated delay.",
"retry_after_seconds": 2,
"documentation_url": "https://softmax.com/docs/guides/rate-limits"
}
}All four bucket header groups accompany the rejection. Wait at least Retry-After seconds, add jitter, and retry within
a bounded deadline. Do not wait until Reset unless you intentionally want a full bucket: it is a full-refill timestamp,
not the earliest retry time. Concurrent callers can change the next decision.
Use detail.type as the stable discriminator. The message is for people and may change. Other protections can return
different 429 bodies; see Error handling.
Bounded retry example
This read-only example makes at most three attempts. It stops starting retries after 30 seconds and preserves the server’s minimum delay. Each attempt also has a five-second network timeout; the retry-start budget is not a hard total-duration deadline. If another caller keeps the shared budget busy, the final response can still be 429.
import random
import time
import httpx
with httpx.Client(
base_url="https://softmax.com/api/observatory",
headers={"Authorization": "Bearer <your-token>"},
timeout=5,
) as client:
retry_deadline = time.monotonic() + 30
for attempt in range(3):
response = client.get("/whoami")
if response.status_code != 429 or attempt == 2:
break
if response.headers.get("X-RateLimit-Outcome") != "rejected":
break
delay = int(response.headers["Retry-After"]) + random.uniform(0, 0.25)
if time.monotonic() + delay >= retry_deadline:
break
time.sleep(delay)
response.raise_for_status()
print(response.json())Do not wrap an already-retrying client in another retry loop. For writes, preserve the same payload and any endpoint-supported idempotency key. This example intentionally retries only the shared API budget error.
Shared-service failures
Connection, credential, permission, and script failures in the shared budget service fail open: the API continues handling the request.
X-RateLimit-Outcome: unavailable accompanies cost and mode, but omits capacities, remaining credits, reset times, and
retry delay because no budget decision is known. Later requests resume evaluation when the service recovers.
API authentication failures and unrelated application errors still use the API’s normal error-handling behavior.