Rate Limiting
Token bucket rate limits per task — count/period syntax, persistence, retries.
Token bucket rate limits per task — count/period syntax, persistence, retries.
flexiq uses a token bucket (a standard rate-limit algorithm that refills allowance over time) algorithm to limit how fast tasks execute. Rate limits are per-task and persisted in the queue database (SQLite or Postgres).
@queue.task(rate_limit="100/m") # 100 per minute
def send_email(to, subject, body):
...
@queue.task(rate_limit="10/s") # 10 per second
def api_call(endpoint):
...
@queue.task(rate_limit="3600/h") # 3600 per hour
def generate_report(report_id):
...Rate limits use the format count/period:
| Format | Meaning |
|---|---|
"10/s" | 10 per second |
"100/m" | 100 per minute |
"3600/h" | 3600 per hour |
The count has to be finite and at least 1. A zero, a negative or a fraction
builds a bucket that never holds the token a dispatch needs, and nan loses the
comparison outright, so the worker refuses to start on one rather than running a
task that silently never dispatches. inf is rejected with them — it is not a
rate.
The token bucket algorithm:
max_tokens = count and a refill_rate = count / periodToken bucket state (current tokens, last refill time) is stored in the queue database (SQLite or Postgres). This means rate limits survive worker restarts.
Flood the limit and the excess waits its turn instead of being dropped. The bucket paces dispatch; the queue behind it does not shrink.
Deferral is the right default: the job keeps its place and runs once tokens are
available. But some work is worth less than the backlog it would build — a
metrics sample, a cache warm — and for that, on_excess="drop" sheds the job
instead of rescheduling it:
@queue.task(rate_limit="10/s", on_excess="drop")
def record_sample(metric, value):
...A dropped job is dead-lettered on the spot, not silently deleted. Its
reason is prefixed rate_limit: and its DLQ metadata is {"shed":"rate_limit"},
so shedding stays visible in the dashboard and countable in metrics — an
operator can always tell shedding apart from data loss. The DLQ auto-retry
sweep skips these entries: resurrecting a job the scheduler deliberately shed
would undo the shed.
Three things worth knowing:
on_excess applies to the limit on this task and to the limit on the
queue it runs in — either one rejecting means the same thing to the caller.Flow control walks through what a shed job looks like in the dashboard, and where throttling ends and debouncing begins.
Rate limits apply to the task name, regardless of which queue the job is in:
@queue.task(rate_limit="10/s", queue="emails")
def send_email(to, subject, body):
...
# Both of these are rate-limited together (same task name)
send_email.delay("alice@example.com", "Hi", "Body")
send_email.apply_async(args=("bob@example.com", "Hi", "Body"), queue="urgent")Coming from Celery: Celery's
rate_limitis enforced per worker — the effective rate scales with worker count. flexiq enforces it globally per task name at the scheduler, so"100/m"means 100/min across the whole deployment regardless of how many workers run.
Rate limiting and retries work together seamlessly. If a rate-limited task fails and retries, the retry attempt is also subject to the rate limit:
@queue.task(rate_limit="5/s", max_retries=3, retry_backoff=2.0)
def external_api(url):
response = requests.get(url)
response.raise_for_status()
return response.json()