Rate-limit safely¶
Pick a limiter that keeps your bot under Rubika's ceiling instead of collecting TOO_REQUESTS errors.
Two limiters, one interface¶
from darkob import AdaptiveRateLimiter, RateLimiter
RateLimiter(rate=20, burst=5) # fixed ceiling
AdaptiveRateLimiter(rate=20, min_rate=2) # self-tuning ceiling
Both are token buckets: rate tokens are added per second, the bucket
holds at most burst of them, and every API call takes one. When the
bucket is empty the caller waits — the limit is enforced before the
server has to.
AdaptiveRateLimiter(...) is exactly RateLimiter(..., adaptive=True),
named separately so the behaviour is visible at the call site.
Pass the object to the client and you are done:
bot = Bot(token="...", rate_limiter=RateLimiter(rate=20, burst=5))
client = Messenger(string_session="...", rate_limiter=AdaptiveRateLimiter(rate=20))
One limiter per client
The limiter is shared by every request the client makes, including retries. Do not reuse one limiter object across two clients: its buckets belong to the event loop that first used them.
The four knobs you will actually set¶
| Knob | Default | What it does |
|---|---|---|
rate |
20.0 |
Tokens per second, i.e. requests per second |
burst |
None |
Bucket capacity; None means max(1.0, rate) |
per_chat_rate |
None |
A second bucket per conversation |
per_chat_burst |
None |
Capacity of each per-conversation bucket |
Rates must be greater than zero, and min_rate may not exceed rate;
both are ValueError at construction rather than a silent misconfiguration.
With per_chat_rate, one busy group cannot starve the others: each
conversation gets its own bucket, so a broadcast to a thousand chats is
paced per chat as well as globally. The buckets are capped at
max_buckets (4096 by default) and evicted oldest-first, so memory stays
bounded no matter how many chats you touch.
The AIMD knobs¶
A fixed rate is a guess. Too low and you waste throughput; too high and the server throttles you anyway. An adaptive limiter closes the loop with additive increase / multiplicative decrease — the control law TCP congestion control uses.
| Knob | Default | Meaning |
|---|---|---|
min_rate |
1.0 |
Floor the effective rate can be reduced to |
decrease_factor |
0.5 |
Multiplier applied on every throttle |
increase_step |
1.0 |
Tokens per second added on recovery |
increase_interval |
1.0 |
Minimum seconds between two increases |
max_block |
60.0 |
Ceiling on a server-requested pause, in seconds |
limiter = AdaptiveRateLimiter(
rate=20, # start optimistic: 20 requests/second
per_chat_rate=3, # never more than 3/second into one chat
min_rate=2, # never drop below 2/second
decrease_factor=0.25, # a throttle costs 75% of the current rate
increase_step=1.0, # climb back 1/second ...
increase_interval=2, # ... at most every 2 seconds
max_block=30, # cap a Retry-After at 30 seconds
)
On a throttle the effective rate is multiplied by decrease_factor, never
below min_rate. On success it grows by increase_step, never above
rate, and at most once per increase_interval seconds. Per-chat bucket
rates scale with the global rate, so they shrink and recover with it.
Who calls on_throttle() and on_success()¶
You do not. On the Messenger/Shad surface the client reports both events for you, inside the request loop:
- an HTTP
429or a{"status": "TOO_REQUESTS"}body callslimiter.on_throttle(retry_after)with theRetry-Afterheader when the server sent one, which also pauses acquisition for up tomax_blockseconds for every caller; - every successful response calls
limiter.on_success().
A plain RateLimiter has the same methods, and they do nothing: no rate
change, no pause, no error. That is the point of adaptive=False — enabling
it can never alter the behaviour of a bot that already worked.
Both surfaces feed the limiter
darkob.Bot acquires a token before each attempt, using the payload's
chat_id (or object_guid) as the per-chat key, and it reports outcomes
back the same way the userbot surface does: a 429 or a
{"status": "TOO_REQUESTS"} body calls on_throttle() with any
Retry-After the server sent, and a success calls on_success(). There
is nothing to wire up when you pass a limiter to Bot(...).
You only need to report outcomes yourself if you bypass Bot.request —
for instance by calling aiohttp directly:
Reading the state¶
limiter.current_rate # effective rate now, tokens per second
limiter.blocked_for # seconds of forced quiet left, 0.0 when free
limiter.stats() # a dict, ready for a log line or a metric
stats() returns rate, current_rate, min_rate, adaptive,
throttles, recoveries, acquired, blocked_for, chats_tracked and
tokens (what is left in the global bucket). limiter.reset() returns to
the configured rate and clears the counters.
@bot.on_start()
async def announce(bot):
print("limiter:", limiter.stats())
@bot.on_shutdown()
async def report(bot):
stats = limiter.stats()
print(f"throttled {stats['throttles']}x, "
f"ended at {stats['current_rate']:.1f}/s")
Choosing¶
- Start with a plain
RateLimiter.rate=20, burst=5is a safe default, andper_chat_ratefixes the most common real problem: one noisy group. - Switch to adaptive when you do not know the ceiling, or when the
ceiling moves — a shared bot, several deployments, or a platform that
changes its tolerance. Keep
min_ratehigh enough to stay useful. - Raise
max_blockaboveRetry-Afterif you would rather wait than lose the message; lower it if a stalled request is worse than a dropped one.