Rate Limiting
In shortComplete reference for the rate_limit block in 10xgraph.json: the backends, limits, and every option for throttling requests to the 10xGraph API server.
- 6 min read
- 9 sections
- Updated
- v0.10.0
- Markdown
The rate_limit block in 10xgraph.json turns on a sliding-window limiter for the API server. It counts REST requests, WebSocket handshakes and WebSocket graph runs against one bucket per client. The limiter is off unless the block exists; this page lists every option, header and error.
Configuration fields
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Enable the rate limiter when the rate_limit block exists. Set to false to temporarily disable without removing the block. |
backend |
string | "memory" |
Counter storage: "memory", "redis", or "custom". |
requests |
integer | 100 |
Maximum requests allowed per window. Must be positive. |
window |
integer | 60 |
Window size in seconds. Must be positive. |
by |
string | "ip" |
Bucket key: "ip" (client address), "user" (authenticated user ID), or "global" (whole service). See Bucket keys. |
exclude_paths |
array | [] |
Exact request paths to exclude from rate limiting (no wildcards). The /ping health check is always exempt. |
trusted_proxy_headers |
boolean | false |
Honor the X-Forwarded-For header to extract the client IP. Only enable behind a proxy you control. |
trusted_proxy_hops |
integer | 1 |
Number of proxies (counted from the right of X-Forwarded-For) that your infrastructure operates. Requires trusted_proxy_headers: true. Must be >= 1. |
trusted_proxies |
array | [] |
IP addresses or CIDR ranges your proxies connect from. When set, X-Forwarded-For is honored only for requests from these networks. |
redis |
object or string | null |
Redis connection for the "redis" backend. Either {"url": "...", "prefix": "..."} or a bare URL string (uses default prefix). The URL supports $ENV_VAR and ${ENV_VAR} expansion. |
redis.url |
string | null |
Redis connection URL. Required for "redis" backend unless a Redis client is bound in InjectQ. |
redis.prefix |
string | "10xgraph:rate-limit" |
Key prefix for all Redis entries. The default changed from "agentflow:rate-limit" in version 0.7.0, so counters restart once on upgrade. |
fail_open |
boolean | true |
When true, requests are allowed if the Redis backend is unreachable (fail-open). When false, requests are denied (fail-closed). Only applies to "redis" backend. |
Config load validates all values. Invalid by, backend, non-positive requests or window, trusted_proxy_hops < 1, invalid networks in trusted_proxies, or an unresolved $ENV_VAR in the Redis URL raise ValueError and prevent the server from starting. A rate_limit block with enabled: false behaves as if the block were absent.
Bucket keys
The by field determines which bucket counts a request.
| Value | Behavior |
|---|---|
"ip" |
One bucket per client IP address (the default). The IP is resolved from the peer address or, if trusted_proxy_headers is enabled, from X-Forwarded-For. |
"user" |
One bucket per authenticated user (keyed as user:<user_id>). Falls back to the client IP (ip:<address>) for unauthenticated requests, so anonymous traffic is still rate-limited per caller. |
"global" |
One bucket for the entire service (__global__). All clients share the limit. |
Use "user" when authentication is enabled to tie the limit to identity rather than address. A user roaming between addresses is then fairly limited, and a NAT-sharing office is not penalized.
Proxy hops and X-Forwarded-For
The X-Forwarded-For header is a comma-separated list where each proxy appends the client it observed. The caller’s original value is on the left; entries on the right were added by your infrastructure.
trusted_proxy_hops specifies how many proxies of yours sit in front of the app. It counts entries from the right of the list. With the default of 1 (one proxy), the last entry is the true client address as seen by that proxy. If the header has fewer entries than configured hops, it is ignored entirely and the peer address is used instead, with a warning.
trusted_proxy_hops only applies when trusted_proxy_headers is true.
Example: your app is behind a reverse proxy (nginx). Set trusted_proxy_headers: true and trusted_proxy_hops: 1. A request reaching the app with X-Forwarded-For: attacker-ip, nginx-ip uses nginx-ip (the last entry), not attacker-ip.
When trusted_proxies is configured, X-Forwarded-For is honored only for requests whose peer address (the direct connection) is in one of the listed networks. This prevents a client that can reach the app directly (bypassing the proxy) from spoofing the X-Forwarded-For header to get a fresh bucket.
"rate_limit": {
"trusted_proxy_headers": true,
"trusted_proxy_hops": 1,
"trusted_proxies": ["10.0.0.0/8", "172.16.0.0/12"]
}Response headers and errors
Every HTTP response includes these headers:
| Header | Description |
|---|---|
X-RateLimit-Limit |
Configured request limit. |
X-RateLimit-Remaining |
Requests remaining in the current window. |
X-RateLimit-Reset |
Unix timestamp when the window resets. |
X-RateLimit-Reset-After |
Seconds until the window resets. |
Retry-After |
Present on 429 responses only; equals X-RateLimit-Reset-After. |
When a client exceeds the limit, the server responds with status 429 Too Many Requests and the following body:
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests. Limit: 100 per 60s. Retry after 12s.",
"limit": 100,
"window_seconds": 60,
"retry_after_seconds": 12
},
"metadata": {
"request_id": "...",
"status": "error"
}
}WebSocket handshakes and graph runs
WebSocket handshakes bypass HTTP middleware, so the server re-applies the same check when a client connects to /v1/graph/ws or /v1/graph/live. A handshake counts like an HTTP request against the same bucket and backend. If the bucket is full, the handshake is rejected with close code 1013 (Try Again Later) and the reason Rate limit exceeded.
Each graph run sent over an open /v1/graph/ws connection also counts against the bucket. When the bucket is full, the connection stays open, the run is skipped, and the server sends an error stream event whose data holds reason (Rate limit exceeded. Retry after <n>s.) and retry_after_seconds.
The separate websocket.max_connections and websocket.max_connections_per_user caps (in the websocket block of 10xgraph.json) are enforced by the same handshake guard and also reject with close code 1013. They are per process.
Backend comparison
| Backend | Use case |
|---|---|
memory |
Development, tests, single-process deployments. Counters are in-process memory and reset when the worker restarts. With N workers, the effective limit is requests x N. A startup warning is logged. |
redis |
Production multi-worker deployments (Gunicorn/Uvicorn), Docker, Kubernetes. Counters are shared across all workers. Respects fail_open for backend outages. |
custom |
Custom storage backends, external quota services, or non-standard enforcement logic. Implement BaseRateLimitBackend and bind an instance in InjectQ. Startup fails with ValueError if none is bound. |
Custom backend
Subclass BaseRateLimitBackend when neither built-in backend fits, for example to call an external quota service. Both methods are abstract. check must atomically record the request and return a RateLimitDecision with allowed, remaining and reset_after (seconds). close releases resources at shutdown.
import time
from tenxgraph_api.src.app.core.middleware.rate_limit import (
BaseRateLimitBackend,
RateLimitDecision,
)
class FixedWindowBackend(BaseRateLimitBackend):
"""Single-process fixed-window counter, shown as the smallest working example."""
def __init__(self) -> None:
self._windows: dict[str, tuple[float, int]] = {}
async def check(self, key: str, *, limit: int, window: int) -> RateLimitDecision:
now = time.monotonic()
start, count = self._windows.get(key, (now, 0))
if now - start >= window:
start, count = now, 0 # window expired: start a new one
count += 1
self._windows[key] = (start, count)
reset_after = max(1, int(start + window - now))
return RateLimitDecision(
allowed=count <= limit,
remaining=max(0, limit - count),
reset_after=reset_after,
)
async def close(self) -> None:
self._windows.clear()Set "backend": "custom" in 10xgraph.json, then bind an instance under the BaseRateLimitBackend type in the InjectQ container the server uses (see the injectq key in the configuration reference).
from injectq import InjectQ
from my_backend import FixedWindowBackend
from tenxgraph_api.src.app.core.middleware.rate_limit import BaseRateLimitBackend
container = InjectQ.get_instance()
# The server resolves the backend by this type at startup
container.bind_instance(BaseRateLimitBackend, FixedWindowBackend())Examples
Minimal configuration with in-memory backend and default limits:
{
"agent": "graph.react:app",
"rate_limit": {
"enabled": true,
"backend": "memory",
"requests": 100,
"window": 60,
"by": "ip",
"exclude_paths": ["/ping", "/docs", "/redoc", "/openapi.json"]
}
}Production setup with Redis, user-based limiting, trusted proxies:
{
"agent": "graph.react:app",
"rate_limit": {
"enabled": true,
"backend": "redis",
"requests": 1000,
"window": 60,
"by": "user",
"trusted_proxy_headers": true,
"trusted_proxy_hops": 1,
"trusted_proxies": ["10.0.0.0/8"],
"exclude_paths": ["/ping", "/docs", "/redoc"],
"redis": {
"url": "${RATE_LIMIT_REDIS_URL}",
"prefix": "10xgraph:rate-limit"
},
"fail_open": false
}
}With the .env file:
RATE_LIMIT_REDIS_URL=redis://localhost:6379/0The ${RATE_LIMIT_REDIS_URL} value is expanded from the environment when the config loads. To use the Redis backend, install the optional dependency:
pip install "10xgraph-api[redis]"See also
- Rate limiting guide: how to set up and troubleshoot
- Configuration reference: all
10xgraph.jsonkeys - Environment variables: server environment settings