Rate Limiting¶
Rate limiting in SMG is gateway admission control. The gateway caps how many requests it runs at once, keeps a bounded number of extra requests waiting in a first-in, first-out queue, and turns the rest away with a status code and a Retry-After header that tell clients to back off. It is off by default; setting --max-concurrent-requests above 0 turns it on.
Overview¶
Standing-Concurrency Cap¶
At most --max-concurrent-requests requests hold a permit at once, from admission until the last byte of the response.
Bounded FIFO Queue¶
Up to --queue-size requests wait for a permit. Each freed permit goes straight to the oldest waiter.
Back-Off Signals¶
Sheds answer 429 or 503 with Retry-After: 2 and an error code, so clients wait instead of retrying at once.
Observable¶
Gauges for permits in use and queue depth, and counters for every shed.
Why Limit Admission?¶
Without a cap, the gateway forwards every request it receives straight to the workers. During a traffic spike the extra work piles up inside the engines, latency climbs for every request, and clients that time out retry, which adds even more load.
With a cap, the amount of work in the fleet stays bounded. Excess requests wait briefly in the gateway's queue, or are turned away with a clear signal to retry later.
Admission control is gateway-wide: it limits how many requests run at once across all workers, before any worker is chosen. Overload protection works per worker: it stops routing to a worker whose own load signal (queued requests or KV-cache usage) crosses a threshold, and sheds a request with 503 when every candidate worker is over its threshold.
How It Works¶
Admission control covers the inference routes, such as /v1/chat/completions, /v1/completions, /v1/responses, /v1/messages, /generate, /v1/embeddings, and /v1/rerank. Health, model-listing, admin, and worker-management endpoints bypass it.
For each request:
- Take a permit. If a permit is free, the request is admitted at once.
- Wait in the queue. Otherwise, if a queue slot is free, the request waits there for a permit. Its body is not read until it is admitted, unless a WASM
OnRequestmodule is attached, which reads the body first. - Shed. If the queue is full, or queueing is disabled, the request is rejected at once with 429. If it waits longer than
--queue-timeout-secs, it is rejected with 503.
Permit Lifetime¶
A permit is held from admission until the response finishes:
| Case | Permit is released when |
|---|---|
| Streaming response | The last chunk is sent, the stream fails, or the client disconnects |
| Non-streaming response | The response body has been sent |
| Client disconnects before the response starts | The gateway drops the request |
A request that is still waiting in the queue gives up its queue slot once the gateway notices that its client disconnected. With a small request body that is immediate, but a larger HTTP/1.1 request body that the gateway has not read yet can hide the disconnect until the request is admitted or times out.
Because a stream keeps its permit for as long as it streams, --max-concurrent-requests bounds standing concurrency: the number of requests in flight at any moment, long streams included.
Queue¶
- Hard bound. A request that finds no free permit takes one of the
--queue-sizequeue slots, or is shed with 429 at once. The queue never holds more than--queue-sizerequests. - First in, first out. A freed permit is handed directly to the oldest waiting request. A new arrival cannot take a permit while others are waiting.
- Timeout from queue entry.
--queue-timeout-secscounts from the moment the request starts waiting. A request that times out is shed with 503. - No queue.
--queue-size 0disables queueing: a request that finds no free permit is shed with 429 at once.
The FIFO handoff applies in the default mode, with no token refill. A positive --rate-limit-tokens-per-second changes how the queue behaves, as described next.
Token Bucket and Refill¶
Permits come from a token bucket whose capacity is --max-concurrent-requests. Admission takes a token, and releasing the permit puts it back. --rate-limit-tokens-per-second decides whether the bucket also refills over time:
| Behavior | Unset or 0 (default) |
Positive rate N |
|---|---|---|
| Tokens come back when | A response finishes | A response finishes, plus N tokens per second, up to capacity |
--max-concurrent-requests bounds |
Requests in flight | Burst size only: with long-lived responses, requests in flight can grow past the cap by up to N per second |
| Queue order | Strict FIFO | None: waiters poll for tokens, and a new arrival can take a refilled token first |
| Longest queue wait | --queue-timeout-secs |
At most 1/N seconds (or --queue-timeout-secs, if shorter), then 503 |
With the default, the bucket's free tokens always equal --max-concurrent-requests minus the requests in flight. Leave the rate unset unless you want burst-rate behavior.
With the Priority Scheduler¶
When --priority-scheduler-enabled is set and the scheduler starts, it replaces this limiter on the same routes:
- The scheduler's slots and per-class queues handle concurrency and queueing.
--queue-sizeand--queue-timeout-secsare not used. --max-concurrent-requestsno longer caps concurrency. The scheduler only uses it as a fallback capacity while no healthy worker is registered.- A positive
--rate-limit-tokens-per-second(with--max-concurrent-requestsabove0) becomes a plain request-rate check in front of the scheduler. Tokens refill at that rate and are not returned when responses finish; a request that finds the bucket empty is shed with 429scheduler_queue_fullandRetry-After: 2.
If the scheduler fails to start, the gateway logs an error and falls back to the limiter described on this page. See Priority Scheduling.
Configuration¶
smg launch \
--worker-urls http://w1:8000 http://w2:8000 \
--max-concurrent-requests 100 \
--queue-size 200 \
--queue-timeout-secs 30Parameters¶
| Parameter | Default | Description |
|---|---|---|
--max-concurrent-requests |
-1 (disabled) |
Maximum requests holding a permit at once. A permit lasts for the full response, streaming included. Any value <= 0 turns admission control off. |
--queue-size |
100 |
Maximum requests waiting for a permit. 0 disables the queue. |
--queue-timeout-secs |
60 |
Longest time a request waits in the queue before it is shed with 503. Must be > 0 when --queue-size is above 0. |
--rate-limit-tokens-per-second |
unset (no refill) | Tokens added to the bucket per second. Unset or 0 means no refill, so --max-concurrent-requests alone bounds requests in flight. Must be >= 0. |
The limits apply per gateway process. With several gateway replicas in front of the same workers, the fleet-wide cap is the per-replica value times the number of replicas.
--request-timeout-secs (default 1800) is the default timeout for requests the gateway sends to HTTP workers, so it also bounds how long a stalled HTTP request can keep its permit.
What the Client Sees¶
Every admission shed uses the gateway's standard error format: a JSON body, an X-SMG-Error-Code header with the same code, and Retry-After: 2.
| Situation | Status | Error code | Retry-After |
|---|---|---|---|
| No free permit, and queueing is disabled | 429 | admission_queue_full |
2 |
| No free permit, and the queue is full | 429 | admission_queue_full |
2 |
Waited in the queue longer than --queue-timeout-secs |
503 | admission_queue_timeout |
2 |
{
"error": {
"type": "Service Unavailable",
"code": "admission_queue_timeout",
"message": "timed out waiting for an admission slot",
"param": null
}
}Other layers also answer 429 or 503. The error code tells them apart:
| Error code | Status | Source |
|---|---|---|
scheduler_queue_full, scheduler_queue_timeout, scheduler_preempted |
429, 503 | The priority scheduler, when enabled |
worker_overload_protection_shed |
503 | Overload protection shed the request, for example because every candidate worker is overloaded; see that page for its Retry-After |
tenant_rate_limit_exceeded |
429 | Tenant rate limiting |
no_available_workers |
503 | No worker for the model can take the request, for example because each one is unhealthy or has an open circuit breaker |
Monitoring¶
Metrics¶
| Metric | Type | Labels | Description |
|---|---|---|---|
smg_admission_inflight |
Gauge | none | Requests holding a permit. A stream counts until its body finishes. |
smg_admission_queue_depth |
Gauge | none | Requests waiting in the admission queue. Never exceeds --queue-size. |
smg_admission_queue_rejected_total |
Counter | reason: full, timeout |
Queue-full (429) and queue-timeout (503) sheds. |
smg_http_rate_limit_total |
Counter | result: allowed, rejected |
Every admission decision. rejected also counts the 429s returned when queueing is disabled, which have no smg_admission_queue_rejected_total sample. |
These metrics are per gateway process; sum them across replicas. With the priority scheduler enabled, the smg_admission_* metrics are not recorded (the scheduler exports its own smg_scheduler_* metrics), and smg_http_rate_limit_total{result="rejected"} counts only its request-rate check.
Admission sheds also appear in smg_http_responses_total under their error_code label, and smg_http_inflight_request_age_count shows how long in-flight requests, streams included, have been running.
Useful PromQL Queries¶
Permits and Queue¶
# Permits in use (compare with --max-concurrent-requests)
max_over_time(smg_admission_inflight[5m])
# Requests waiting (compare with --queue-size)
max_over_time(smg_admission_queue_depth[5m])Sheds¶
# Sheds per second, by reason
sum by (reason) (rate(smg_admission_queue_rejected_total[5m]))
# Share of admission decisions that were rejected
sum(rate(smg_http_rate_limit_total{result="rejected"}[5m]))
/ sum(rate(smg_http_rate_limit_total[5m]))What to Watch¶
| Signal | Meaning | Action |
|---|---|---|
smg_admission_inflight stays at --max-concurrent-requests |
Every permit is in use, so new requests queue | Add workers, or raise the cap if the workers have headroom |
smg_admission_queue_depth reaches --queue-size |
The queue is full, so new requests get 429 | Add capacity; a deeper queue only helps if waiters can be admitted before they time out |
smg_admission_queue_rejected_total{reason="timeout"} rises |
Requests wait longer than --queue-timeout-secs and get 503 |
Add capacity, or shorten the queue so clients get a quick 429 instead of a long wait |
Sizing Guidelines¶
Concurrent Requests¶
A permit lasts for the whole response, so size the cap to the number of requests your workers can run at once:
max_concurrent_requests ≈ total running capacity of the workers ÷ number of gateway replicas- A worker's running capacity is its engine's limit on requests running at once, for example
--max-num-seqson vLLM or--max-running-requestson SGLang. - The cap applies per gateway process, so divide by the number of replicas that share the workers.
- A higher cap moves queueing into the engines; a lower one leaves engine capacity unused.
Queue Size¶
With no token refill, permits free up as responses finish and waiters are admitted in order. A request at queue position k waits roughly k × average response time ÷ max_concurrent_requests, so waiters deeper than this are likely to time out before they are admitted:
queue_size ≈ max_concurrent_requests × queue_timeout_secs ÷ average_response_secsUse the full response time, streaming included. A longer queue only delays the 503 for requests past this depth.
Queue Timeout¶
Set --queue-timeout-secs below your clients' own request timeout, so a waiting request gets a 503 with Retry-After from SMG before the client gives up on its own.
Token Refill Rate¶
Leave --rate-limit-tokens-per-second unset. Set it only to reproduce the pre-v1.10 burst-rate behavior (for example, to the same value as --max-concurrent-requests), and keep in mind that it lets requests in flight exceed the cap.
Example Configurations¶
Short queue and timeout, so excess traffic gets a quick answer:
smg launch \
--worker-urls http://w1:8000 http://w2:8000 \
--max-concurrent-requests 50 \
--queue-size 25 \
--queue-timeout-secs 5Deeper queue and longer timeout, so requests wait their turn:
smg launch \
--worker-urls http://w1:8000 http://w2:8000 \
--max-concurrent-requests 200 \
--queue-size 500 \
--queue-timeout-secs 60Refill 100 tokens per second on top of finished responses, as an unset rate did before v1.10. Requests in flight can exceed 100:
smg launch \
--worker-urls http://w1:8000 http://w2:8000 \
--max-concurrent-requests 100 \
--rate-limit-tokens-per-second 100The default. Requests are forwarded without an admission cap:
smg launch \
--worker-urls http://w1:8000 http://w2:8000 \
--max-concurrent-requests=-1Client-Side Handling¶
Retry Strategy¶
Retry 429 and 503 responses after the Retry-After delay, and use the X-SMG-Error-Code header to tell the cases apart. Add jitter so clients that were shed together don't all retry at the same moment:
import random
import time
import requests
def request_with_retry(url, data, max_retries=5):
for attempt in range(max_retries):
response = requests.post(url, json=data)
if response.status_code in (429, 503):
# SMG sheds carry Retry-After in seconds; fall back to exponential backoff.
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay + random.uniform(0, 1))
continue
return response
raise Exception("Max retries exceeded")Adaptive Rate¶
Monitor 429 and 503 responses and adjust the request rate:
class AdaptiveClient:
def __init__(self, base_rate=10):
self.rate = base_rate
def on_success(self):
self.rate = min(self.rate * 1.1, 100) # Increase slowly
def on_rate_limit(self):
self.rate = self.rate * 0.5 # Decrease quicklyWhat's Next?¶
Overload Protection¶
Per-worker load thresholds that take a saturated worker out of routing.
Priority Scheduling¶
Priority-aware admission with per-class queues, reserved slots, and preemption.
Tenant Rate Limiting¶
Cap per-tenant LLM token and request consumption per minute — a different axis than this page's worker concurrency limits.