fix(telegram): per-chat send cooldown + skip progress fallback on flood control - #66722
fix(telegram): per-chat send cooldown + skip progress fallback on flood control#66722profikid wants to merge 1 commit into
Conversation
…od control
Telegram flood-control penalties escalated to multi-thousand-second
back-offs ('Retry in 7000+ seconds') because a single user turn
routinely fanned out 3-5 sends across independent code paths that
were not coordinated with each other:
- status callbacks fired via safe_schedule_threadsafe (no per-chat
serialization)
- progress bubbles from the progress-queue consumer
- streaming previews from the stream consumer
- the final answer
- photo batches
Even though each path had its own per-send retry-after handling, the
cumulative burst crossed Telegram's ~1 msg/sec/chat limit and the
penalty kept escalating because the progress path's flood fallback
(see gateway/run.py) issued a fresh adapter.send() during the
penalty window \u2014 the exact burst pattern that triggered the
penalty in the first place.
Fix 1: per-chat send cooldown in the telegram adapter
- New _send_cooldown_until dict keyed by chat_id with a
default minimum gap of 1.1s (send_cooldown_seconds)
- Configurable via platforms.telegram.extra.send_cooldown_seconds
and send_cooldown_max_wait_seconds; defaults tuned for
~1 msg/sec/chat with a 5s ceiling on the per-send wait
- When the wait exceeds the ceiling, send() returns a
retryable flood_control-style error instead of blocking the
chat path for two hours
- Cooldown is stamped on both the legacy send loop AND the rich
fast path so they cannot race each other
Fix 2: skip the progress fallback on flood control
- The progress handler in gateway/run.py used to fall back to a
fresh adapter.send() when the edit hit flood control. That
send re-triggers the penalty. With the fix, the tick is
dropped, progress_lines and progress_msg_id are kept
intact, and the next tick retries the edit once the
per-chat cooldown (Fix 1) releases the chat
- Non-flood permanent failures (message deleted, permission
revoked) still fall back to a fresh send, since those are
local failures and not Telegram-side rate limits
Tests:
- 5 new tests in test_telegram_send_cooldown.py pin the gate's
behaviour (cooldown stamp, second-send blocking, per-chat
independence, post-window pass-through, oversized-wait error)
- 1 new test in test_run_progress_interrupt.py
(test_progress_flood_control_does_not_trigger_fallback_send)
pins the gateway-side fix
- All 28 tests in the affected telegram + run-progress suite
pass; no regression
teknium1
left a comment
There was a problem hiding this comment.
Thanks for isolating the progress fallback; the current-main premise is real: gateway/run.py:18537-18552 sends a new progress message immediately after a flood-controlled edit.
Problems
- The new map is not a per-chat serializer. The rich path awaits
_try_send_rich()before its new timestamp write (plugins/platforms/telegram/adapter.py:4045in the PR), and callers sleeping for one deadline wake together without reserving the next slot. - The bounded-wait claim is disconnected from live
RetryAfter: currentsend()still directly sleeps the server value atplugins/platforms/telegram/adapter.py:4189-4203; the PR never records that value in its new map. - Native media bypasses
send()(send_media_groupatplugins/platforms/telegram/adapter.py:6537,send_photoat:6797), so photo traffic is not coordinated. - The overflow progress-edit path still converts a failed edit to a fresh send via
gateway/run.py:18423-18428then:18569-18576.
Suggested changes
- Use a per-chat reservation/lock before every await, propagate real RetryAfter deadlines with structured rate-limit metadata, cover media paths, and test concurrent sends plus overflow-edit flood control.
Automated hermes-sweeper review.
| # next non-rich send (or another rich one) honours | ||
| # the per-chat gate. Without this, the rich path | ||
| # would bypass the limiter entirely and a | ||
| # rich→markdown sequence would race. |
There was a problem hiding this comment.
This timestamp is written only after _try_send_rich() has awaited the Bot API. Concurrent rich sends can all pass the empty cooldown check first; callers that sleep for an existing deadline also wake together because no next slot is reserved before the await. Use a per-chat reservation/lock and add a concurrent-send regression.
| @@ -4011,6 +4106,17 @@ async def send( | |||
| _TimedOut = None # type: ignore[assignment,misc] | |||
There was a problem hiding this comment.
This records only the fixed minimum gap. The unchanged RetryAfter handler below still awaits the server-provided value directly, and never updates this map, so a real 7000-second penalty bypasses send_cooldown_max_wait_seconds. Record the server deadline or return a structured rate-limit result there, with a RetryAfter-based test.
Verification on current main (2026-08-01) — fix works, needs rebase + one small changeI ported this PR's changes onto current What I verified
One small change needed to land cleanly (per issue #76494)
Observations (not blockers)
SummaryThe PR implements exactly what issue #76494 asks for and resolves the escalation when ported to current main. Needs: (1) rebase onto current main, (2) move |
SummaryOne PR, #66722, addresses the reported Telegram penalty escalation by suppressing a fresh progress-message send after a flood-controlled edit and adding a per-chat send cooldown. Its diff targets the immediate fallback-send cause, but the cooldown implementation does not fully serialize concurrent senders or propagate Telegram RetryAfter deadlines across all outbound paths. Related pull requests
Suggested consolidationKeep #66722 open for author action: rebase onto current main and revise the cooldown into a per-chat reservation/lock that records live RetryAfter deadlines and covers native media and the remaining overflow fallback path. This preserves the verified progress-fallback fix while explicitly addressing the blocking technical concerns in the contributor keep_open review. Cross-PR triage: Reviewed 1 pull request and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 29 kB of PR diffs, 8 kB of issue/PR text, 5 kB of discussion (3 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
Telegram flood-control penalties escalated to multi-thousand-second back-offs because a single user turn routinely fanned out 3-5 sends across independent code paths that were not coordinated with each other.
Root cause
A single user turn triggers sends from several independent code paths:
safe_schedule_threadsafe(no per-chat serialization)Each path had its own per-send retry-after handling, but the cumulative burst crossed Telegram's ~1 msg/sec/chat limit. The penalty then kept escalating because the progress path's flood-control fallback issued a fresh
adapter.send()during the penalty window — exactly the burst pattern that triggered the penalty in the first place.Fix 1 — per-chat send cooldown in the telegram adapter
plugins/platforms/telegram/adapter.py:_send_cooldown_untildict keyed by chat_id with a default minimum gap of 1.1s (send_cooldown_seconds)platforms.telegram.extra.send_cooldown_secondsandsend_cooldown_max_wait_secondssend()returns a retryableflood_control-style error instead of blocking the chat path for two hoursFix 2 — skip the progress fallback on flood control
gateway/run.py:adapter.send()when the edit hit flood control. That send re-triggers the penalty. With the fix, the tick is dropped,progress_linesandprogress_msg_idare kept intact, and the next tick retries the edit once the per-chat cooldown (Fix 1) releases the chatTests
tests/gateway/test_telegram_send_cooldown.py:test_first_send_stamps_cooldown_for_same_chat— successful send records a cooldown timestamptest_second_send_within_window_blocks— second send within the gap waits for the gatetest_second_send_after_window_passes_immediately— no sleep when the cooldown already expiredtest_independent_cooldowns_per_chat— chat A's cooldown does not block chat Btest_oversized_wait_returns_retryable_error— multi-thousand-second penalty yields a retryable error instead of blocking the chat pathtests/gateway/test_run_progress_interrupt.py:test_progress_flood_control_does_not_trigger_fallback_send— pins the gateway-side fix: at most one progress-bubble send (the initial bubble) is allowed; additional sends triggered by flood-control edit failures are regressed on