fix(rate-limit): charge plain messages and chat actions to the global bucket

send_media_group, send_animation and copy_messages paced themselves against both buckets; send_message and send_chat_action paced against neither. A dead-letter storm — or one error reply per failed item, plus the action refresh loop's repeated typing requests — could therefore burst past Telegram's per-bot30/s ceiling with only429s left to absorb it, exactly what the bot-wide bucket exists to prevent. Both now acquire one global token like their siblings (per-chat pacing for messages already happens at their call sites; chat actions are cheap and frequent, so only the global bucket applies to them).
This commit is contained in:
2026-09-24 15:28:59 +08:00
parent 715d6a59b0
commit d1f2ae09b2
@@ -185,6 +185,10 @@ impl MediaSender for Bot {
reply_markup: Option<InlineKeyboardMarkup>,
) -> BoxFuture<'_, Result<i64, RequestError>> {
Box::pin(async move {
// Plain messages never met a bucket: a dead-letter storm (or one
// error reply per failed item) could burst past Telegram's
// per-bot ceiling with only 429s left to absorb it.
crate::rate_limit::acquire_global(1.0).await;
let mut request = <Bot as Requester>::send_message(self, chat_id, text);
if let Some(reply_to) = reply_to {
request = request
@@ -272,6 +276,10 @@ impl MediaSender for Bot {
action: ChatAction,
) -> BoxFuture<'_, Result<(), RequestError>> {
Box::pin(async move {
// Same gap as send_message: actions count against the bot-wide
// budget too (see there); the refresh loop behind
// `run_with_chat_action` makes them frequent enough to matter.
crate::rate_limit::acquire_global(1.0).await;
// teloxide's `send_chat_action` returns `Result<True, _>` (its
// unit marker type); map the success to `()`.
<Bot as Requester>::send_chat_action(self, chat_id, action)