Skip to main content
Bot platform adapters now ship in the praisonai-bot package. praisonai bot serve still works exactly as documented here; for a standalone install see praisonai-bot Migration.
Bot Rate Limiting prevents messaging platform 429 errors by throttling outbound messages to channels, respecting platform-specific limits and per-channel delays. Since PraisonAI #2578, the limiter also covers the proactive path — scheduled and agent-initiated sends now share one bucket per platform with the reply path. DeliveryRouter.deliver reuses adapter._rate_limiter if the adapter already carries one; otherwise it lazily calls RateLimiter.for_platform(platform).

Quick Start

1

Simple Usage

Use the default rate limiter for general messaging bots.
2

Platform-Specific Configuration

Use platform presets for optimal compliance with API limits.
3

Custom Configuration

Fine-tune rate limits for specific platform policies or custom requirements.

How It Works

The rate limiter uses a two-phase approach:

Configuration Options

RateLimiter constructor kwargs (multi-worker)

Platform Presets


Memory Management

Per-channel state is tracked in an LRU cache capped at 4096 channels. If a bot serves more channels than that, the least-recently-used channels fall out of the cache and their per_channel_delay window resets. This bounds memory for long-running bots.

Multi-worker (horizontally-scaled gateway)

A single-process gateway is served by the fast in-memory token bucket — RateLimiter() with no store= — and that’s the right default. But once you run N workers, each with its own RateLimiter, each worker keeps its own bucket and the platform sees N × messages_per_second. That’s the exact scenario a token-bucket limiter exists to prevent, and it produces 429s and temporary bans. Pass a shared DeliveryControlStore so every worker draws from one SQLite-backed bucket:
The token reservation now happens inside a BEGIN IMMEDIATE transaction, so N workers racing on acquire() get atomic wait times summing to the configured ceiling. The sleep still happens outside the transaction — workers block only for their share of the wait, not for the transaction round-trip.

scope — sharing identity

scope is the shared-bucket identity. All limiters that must share one ceiling must use the same scope string. Two common patterns:
  • Per platform (default): every worker running for_platform("telegram", store=store) uses scope="telegram" — one ceiling per platform per host.
  • Per bot token (multi-tenant): hash the token so tenant A’s Telegram bot doesn’t share a bucket with tenant B’s Telegram bot: RateLimiter.for_platform("telegram", store=store, scope=f"telegram:{token_hash}").

Restart survival

The bucket state (tokens, last_refill, global_penalty_until, per-channel last_send, penalty_until) is persisted on every reservation. A worker restart resumes from the last recorded reservation anchor instead of refilling to burst_size, so a rolling restart doesn’t briefly triple the send rate.

penalise() and reset() are also shared

When any worker records a 429/Retry-After hint via penalise(), every other worker on the same scope immediately respects the same hold-off window. reset() clears the shared row.
The shared store uses time.time() (wall clock) instead of time.monotonic() because monotonic clocks are per-process and cannot be shared. Keep the workers’ clocks in sync (NTP or the same host) — a clock skew of X seconds between two workers effectively widens the bucket by X × messages_per_second tokens during that skew.

DeliveryControlStore

The store is stdlib sqlite3-only (no extra dependency), uses WAL journaling, and can back both the rate limiter and the dead-target registry from one file.
Table shape (informational — you never write SQL yourself):
  • rate_limit_state(scope, tokens, last_refill, global_penalty_until) — one row per scope.
  • rate_limit_channel(scope, channel_id, last_send, penalty_until) — per-channel rows, capped at 4096 per scope with LRU eviction.
  • dead_targets(platform, channel_id, reason, ts) — see Dead-Target Registry.

Concurrency Design

The global lock is held only long enough to reserve a token and update bookkeeping — the actual sleep happens outside the lock. Multiple channels can be rate-limited concurrently without serialising on one mutex.
When a store= is set, “Phase 1: Reserve” runs inside a SQLite BEGIN IMMEDIATE transaction so N workers on N hosts race atomically on one bucket. The sleep in “Phase 2” still happens outside the transaction, so a slow worker never blocks another worker’s reservation. See Multi-worker (horizontally-scaled gateway).
Before PR #1870 (serialized):
After PR #1870 (concurrent):

Server-provided Retry-After

When a messaging platform explicitly tells the bot how long to wait, that hint takes precedence over the policy backoff. Precedence order (highest first):
  1. Server-mandated wait — extracted from the platform error:
    • Telegram: parameters.retry_after field (or .retry_after attr on RetryAfter exception)
    • HTTP channels (Slack / Discord / WhatsApp): Retry-After header — integer seconds or HTTP-date
    • Text fallback: retry after / retry_after: in the error message body
  2. Policy exponential backoff — only used when the server provides no hint
The resilience layer (_resilience.deliver_with_retry and _delivery.deliver_with_retry) reads the server hint via server_retry_after(err) and sleeps for exactly that duration before the next attempt. The OutboundQueue also stores the hint and gates the next drain cycle accordingly.
The hint is available across all delivery paths — both the immediate retry helper and the durable outbound queue — so no send bypasses a server-mandated backoff.

Penalised Lanes

After a 429 from a messaging platform, RateLimiter.penalise(channel_id, seconds) widens the wait window for that channel — and the global window — so the next sends don’t immediately re-trip the limit.
penalise is called automatically by _delivery.deliver_with_retry via its optional rate_limiter= argument when a server-mandated Retry-After hint is detected. You can also call it manually if you observe 429s through other means.

Best Practices

Start with RateLimiter.for_platform() instead of custom configs. Platform presets are tuned for each API’s documented limits and real-world behavior.
Within one process, share one RateLimiter object across every bot instance. Across multiple processes (a horizontally-scaled gateway), instead pass a shared DeliveryControlStore:
See Multi-worker (horizontally-scaled gateway).
The rate limiter logs debug messages when applying delays. Monitor these to tune your configuration.
Some platforms allow bursts followed by longer delays. The burst_size parameter accommodates this pattern.

Rate Limiter (LLM)

Rate limiting for LLM API calls (different from bot message rate limiting)

Messaging Bots

Build bots for Telegram, Discord, Slack, and WhatsApp platforms

Bot Platform Capabilities

How platform capabilities drive this feature