Overview

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 identityRequest capacity: minute / hourComplexity capacity: minute / hourSustained default-cost requests
User and owned players2,400 / 36,00024,000 / 360,00010/second
Service9,600 / 288,00096,000 / 2,880,00080/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.

HeaderMeaning
X-ComplexityStatic complexity points for this request.
X-RateLimit-Modelog-only observes budgets; enforced rejects over-budget requests.
X-RateLimit-Outcomeallowed, would_reject, rejected, or unavailable when no shared-budget decision could be made.
X-RateLimit-Retry-AfterSuggested seconds until the same cost fits all buckets, assuming no other requests. Zero when allowed.
Retry-AfterMinimum 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-Minute
  • X-RateLimit-Requests-Hour
  • X-RateLimit-Complexity-Minute
  • X-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_reject appears, pace subsequent requests using X-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-After header 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.