fix(telegram): prevent draft-failure cascade that causes 4-minute flood-control silence - #53865
fix(telegram): prevent draft-failure cascade that causes 4-minute flood-control silence#53865elphamale wants to merge 7 commits into
Conversation
32d8d10 to
3105be3
Compare
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved
Telegram draft-failure cascade prevention (5 files). Clean resilience fix:
- New
_MAX_DRAFT_FAILURES = 3threshold (was: 1 failure permanently killed drafts) - Consecutive failures trigger edit-based fallback; successful frames reset the counter
- Good change from "permanent disable" to "consecutive threshold" — single transient errors no longer cascade
- Updated docstrings accurately describe the new behavior
The consecutive-successes-reset pattern is the right fix for flood-control hiccups. No concerns.
|
@tonydwb Thanks for the review! But I added another commit (1ef5861) after your approval. It touches only _send_fallback_final in stream_consumer.py and the draft test file (3 test removals + 6 new tests for the new path). The first 3 commits are unchanged. I am still not satisfied with bot's behavior with long, markdown/rich-formatted replies and thinking about how to improve it. So I am open to suggestions, but this PR is finalized so far. |
|
I compared this with a live
For So the long-flood path can still create a real message or enter edit streaming A targeted regression test would be:
That fallback is right for capability failures, but for a Bot API A targeted regression test would be:
One design concern: returning |
|
Thanks for isolating the draft-failure cascade and adding focused coverage. The bug remains present on current main, but the proposed rate-limit contract is not carried through the full streaming path. Problems
Suggested changes
Automated hermes-sweeper review. |
|
@szafranski Sorry for the silence on this — both edge cases you flagged were real and still present on Long draft flood still falling through to a real send/edit.
Both regression tests you described are in there ( @tonydwb — the original approval predates szafranski's review; flagging so it isn't mistaken for current sign-off on this branch. |
64b8d8d to
138dc65
Compare
|
@teknium1 (hermes-sweeper) — following up on your third point separately: "the fallback-final implementation has moved materially since this branch... reconcile with current main before salvage." You were right, and I'd missed it in my first reply. This branch was 2372 commits behind
Confirmed 252 tests pass across the affected files, and separately confirmed the 9 pre-existing telegram test-order-pollution failures elsewhere in the suite are identical on a clean |
|
Pushed That's broader suppression than the cascade-prevention goal actually needs, and it had a real side effect: it silently defeated Narrowed suppression to flood control only ( Updated/added tests: |
796bbd6 to
175323a
Compare
175323a to
0870ba2
Compare
…od-control silence A single sendMessageDraft failure permanently disabled draft streaming for the entire response, forcing every subsequent token through editMessageText. On a long response (~200 tokens) this exhausted Telegram's per-chat edit quota and triggered a 280 s RetryAfter on both the stream consumer and the base gateway send, causing 4+ minutes of silence with a stuck ▉ cursor. Three targeted fixes: 1. Failure threshold (_MAX_DRAFT_FAILURES = 3): transient draft errors no longer permanently disable draft streaming on the first occurrence; only three consecutive non-retryable failures trigger the edit fallback. 2. Flood-control retryable signal: sendMessageDraft RetryAfter ≤5 s is handled inline (sleep + retry); RetryAfter >5 s returns retryable=True so the caller does not count it against the disable threshold. 3. Cursor-strip delete fallback: when the cursor-removing edit is itself flood-controlled, try delete_message (different Bot API quota) so the stuck ▉ disappears before the full response arrives. Also adds TestDraftFallbackOnFailure and TestTryStripCursor to test_stream_consumer_draft.py, and a new test_telegram_send_draft_flood_control.py (13 tests total). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…view Address gaps identified in code review: - TestDraftFallbackOnFailure: add boundary test for exactly 2 failures (must NOT disable), tighten >= to == on the 3-failure assertion, restore integration test (test_draft_failure_falls_back_and_delivers_final_via_send) verifying the fallback path delivers the final message, with an explanatory note on why _use_draft_streaming stays True in run-loop context - TestTryStripCursor: add __no_edit__ sentinel noop test, add negative case for _is_flood_error (non-flood error must not trigger delete), add test_delete_message_raises_is_swallowed for the except Exception: pass branch - test_telegram_send_draft_flood_control.py: fix fragile module-wide asyncio patch (replace entire asyncio mock with targeted asyncio.sleep patch), add 5.01s boundary test confirming the long-wait path, remove dead RetryAfter stub that was never used by any test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cade Root cause: when sendMessageDraft fails (typing action expired, unsupported client, transient error), the stream consumer fell back to editMessageText for every remaining token — 200+ rapid edits on a long response exhaust Telegram's per-chat quota, causing a 280s RetryAfter and 4+ minutes of silence. Fix: any sendMessageDraft failure that is not a long flood-control wait now returns success=True, message_id=None (frame silently dropped) instead of a failure result. The consumer stays in draft mode throughout and never switches to the edit path. The final response is delivered by the base gateway send() at finalize — one call, no quota-burning intermediate edits. This mirrors the pattern in PR NousResearch#51886 (guest chats): returning success=True for structurally-unsupported frames keeps the consumer on the draft path. Cases that now return success=True (suppress): - ok=False from the Bot API (typing action expired, client too old, etc.) - Any non-MarkdownV2 exception (network hiccup, DRAFT_ID_INVALID, etc.) - Short flood-control (≤5s) sleep+retry that still fails after sleep Only long flood-control waits (>5s) still return retryable=True so the consumer can log them without counting against _draft_failures. _MAX_DRAFT_FAILURES is kept as a safety net but is now effectively unreachable via the adapter (no failures propagate as success=False). Also revert the earlier _try_strip_cursor delete-fallback: a partial message with a stuck cursor is better UX than deleting it and leaving the user with nothing for the flood-control wait duration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…allback When _send_fallback_final fires and the complete text fits in one message, try editMessageText on the existing partial first. No new message, no deletion, no extra rate-limit cost. Falls back to the original send+delete path if the edit fails (flood control, message too old, etc.). Also removes three tests that became vacuous or redundant after prior fixes: - test_two_consecutive_failures_do_not_disable_drafts (range covered by 1 and 3) - test_missing_delete_message_does_not_raise (_try_strip_cursor no longer deletes) - test_non_flood_edit_failure_skips_delete (no deletion logic exists to skip) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eal send/edit szafranski's review (echoed by hermes-sweeper) identified two edge cases where a long RetryAfter on a draft frame still cascades into the exact flood-control storm this PR set out to prevent: - _send_draft_frame's retryable branch correctly skipped counting the failure, but _send_or_edit treated any False return as "drafts already gave up, fall through to adapter.send()/edit_message()" — so a long flood-control wait immediately spent another call (a real message send) in the same window instead of waiting it out. - TelegramAdapter._try_send_rich_draft collapsed every exception, including RetryAfter, to a bare False, so send_draft() always fell through to the legacy sendMessageDraft endpoint for the same frame — burning a second call in the same flood-control bucket. Fixes: - _try_send_rich_draft now returns (success, retry_after); a RetryAfter propagates as SendResult(retryable=True, retry_after=...) directly, without touching the legacy endpoint. The legacy sendMessageDraft path's own long-wait branch now also sets retry_after (previously only embedded in the error string, which _send_draft_frame never parsed). - _send_draft_frame stores that retry_after as a cooldown deadline (_draft_cooldown_until) and skips the API call outright while still cooling down, rather than retrying every tick. - _send_or_edit only falls through to the real send/edit path when the miss was NOT a retryable cooldown (_last_draft_retryable) — ordinary (non-retryable) draft misses still fall through immediately exactly as before, preserving the existing get-a-real-message-fast behavior for actual rejections. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CvMiTppKDazXfakuoLgQK
Rebasing onto origin/main (this branch was 2372 commits behind) surfaced a real interaction between this PR's own "edit stale partial in-place" commit and three pre-existing NousResearch#10807/NousResearch#16668 regression tests in TestSegmentBreakOnToolBoundary: those tests asserted delivery happened specifically via adapter.send() (plus a delete_message() call for the full-resend case), which the edit-in-place path correctly bypasses when the final text fits in one message — same outcome (correct content visible, no duplicate/stale message), different mechanism. Updated the three tests to accept delivery via edit_message() as well as send(), added a dedicated test forcing the edit to fail so the send+delete fallback path still gets direct coverage, and confirmed all 9 gateway/test_stream_consumer.py + test_stream_consumer_draft.py + telegram draft/rich/flood-control/final-delivery suites pass (252 tests). Also verified the 9 pre-existing telegram test-order-pollution failures in the wider suite are identical on a clean origin/main checkout with none of this branch's commits — unrelated to this PR. Addresses hermes-sweeper's third point on this PR: "the fallback-final implementation has moved materially since this branch... reconcile with current main before salvage." The rebase itself resolved the textual _try_strip_cursor conflict with 0489863/4aa499ff9 (origin/main had already converged on the same "leave message intact" behavior this PR's first commit introduces); this commit resolves the remaining behavioral interaction the rebase surfaced. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CvMiTppKDazXfakuoLgQK
send_draft()'s legacy sendMessageDraft path returned success=True for every non-flood-control failure (ok=False, expired typing action, unsupported chat, network hiccup, retry-after-retry failures) to avoid recreating the edit cascade. That's broader than necessary: it also silently defeated _MAX_DRAFT_FAILURES, since a real/permanent failure (bot blocked, chat gone) never counted toward the 3-strikes fallback to edit-based delivery. Narrow the suppression to flood control only (retryable=True with retry_after) — the one case that actually needs special handling to avoid burning another call in the same rate-limit window. Every other failure now returns a normal success=False miss, so it participates in the existing consecutive-failure fallback like any other adapter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CvMiTppKDazXfakuoLgQK
0870ba2 to
3760b2d
Compare
Root cause
A single failed sendMessageDraft call permanently disabled draft streaming for the rest of the session, forcing every subsequent token through editMessageText. On a long response (180 s+, multiple tool calls) this produced 200+ edits, exhausted Telegram per-chat edit quota (RetryAfter ~280 s), and left the user staring at a stuck cursor in a frozen partial message for 4+ minutes.
Five targeted fixes
1. Draft-frame failure tolerance (stream_consumer.py)
Introduce _MAX_DRAFT_FAILURES = 3. Draft streaming is now only disabled after 3 consecutive failed frames. A single transient error no longer cascades into 200+ editMessageText calls. Consecutive successes reset the streak.
2. Suppress non-retryable draft failures (adapter.py)
Non-flood, non-MarkdownV2 sendMessageDraft failures now return SendResult(success=True, message_id=None). The consumer treats this as a silently dropped frame, stays in draft mode, never sets _message_id, and never switches to the editMessageText path. The final message arrives via the base send() call as normal.
Short RetryAfter (<=5 s): sleep + inline retry; if retry also fails, suppress the frame.
Long RetryAfter (>5 s): return retryable=True (and retry_after=) so the consumer skips counting it against the threshold and can cool down properly (see fix 5).
3. Cursor-strip: leave partial intact on failure
When the cursor-strip edit is flood-controlled or raises, leave the message as-is. A partial reply with a stuck cursor is better UX than deleting it and leaving the user with a blank screen for the flood-control wait.
4. Edit stale partial in-place at fallback
When fallback fires and the complete final text fits in one message, attempt editMessageText on the stale partial first. If the edit succeeds: no new message, no deletion, message keeps its position. If it fails, fall through to the original send+delete path.
5. Long draft-flood waits no longer cascade to a real send/edit or a duplicate call
szafranski's review (echoed independently by hermes-sweeper) identified two remaining edge cases where a long RetryAfter still triggers the same class of flood-control cascade this PR set out to fix:
_send_draft_frame's retryable branch correctly skipped counting the failure, but_send_or_edittreated anyFalsereturn as "drafts already gave up, fall through toadapter.send()/edit_message()" — so a long flood-control wait immediately spent another call (a real message send) in the same window instead of waiting it out.TelegramAdapter._try_send_rich_draftcollapsed every exception, including RetryAfter, to a bareFalse, sosend_draft()always fell through to the legacysendMessageDraftendpoint for the same frame — burning a second call in the same flood-control bucket.Fixed:
_try_send_rich_draftnow returns(success, retry_after); a RetryAfter propagates asSendResult(retryable=True, retry_after=...)directly, without touching the legacy endpoint. The legacy path's own long-wait branch now also setsretry_after(previously only embedded in the error string, which the consumer never parsed)._send_draft_framestores thatretry_afteras a cooldown deadline (_draft_cooldown_until) and skips thesend_draftcall outright while still cooling down, rather than retrying every tick._send_or_editonly falls through to the real send/edit path when the miss was NOT a retryable cooldown (_last_draft_retryable) — ordinary (non-retryable) draft misses still fall through immediately exactly as before, so the existing get-a-real-message-fast behavior for genuine rejections is unchanged.Tests (51 across 3 files, 8 new)
TestDraftFallbackOnFailure: single_failure_does_not_disable / three_consecutive_failures_disable / success_resets_failure_streak / retryable_failure_not_counted / long_retryable_failure_does_not_fall_through_to_send_or_edit / retryable_cooldown_skips_subsequent_send_draft_calls / expired_cooldown_resumes_calling_send_draft / draft_failure_falls_back_and_delivers_final_via_send
TestDraftSuppressionPreventsEditCascade: suppressed_drafts_never_call_edit_message / suppressed_drafts_do_not_accumulate_failures
TestTryStripCursor: successful_edit_skips_delete / flood_controlled_edit_leaves_message_intact / raised_edit_exception_leaves_message_intact / no_message_id_is_a_noop / no_edit_sentinel_is_a_noop
TestSendFallbackFinalEditInPlace: edit_in_place_skips_send_and_delete / failed_edit_falls_through_to_send_and_delete / raised_edit_falls_through_to_send / no_stale_message_id_goes_straight_to_send / no_edit_sentinel_goes_straight_to_send / preserve_partial_skips_edit
test_telegram_send_draft_format.py: passes_markdownv2_parse_mode / falls_back_to_plain_text_on_markdownv2_error / non_badrequest_is_suppressed
test_telegram_send_draft_flood_control.py: short_flood_control_sleeps_and_retries_successfully / short_flood_control_retry_failure_is_suppressed / long_flood_control_returns_retryable_without_sleeping (now also asserts
retry_after) / boundary_five_seconds_treated_as_short / boundary_just_above_five_seconds_treated_as_long (now also assertsretry_after)test_telegram_rich_messages.py: rich_draft_flood_control_propagates_retry_after_without_legacy_fallback (new)
test_stream_consumer.py::TestSegmentBreakOnToolBoundary: edits_stale_partial_in_place_when_it_fits (new) — plus the two pre-existing #10807/#16668 tests updated to accept delivery via
edit_message()as well assend()(see rebase note below)Note: a 4th commit (edit-in-place + test cleanup) was added after the initial approval, a 5th commit addressed szafranski's post-approval review and hermes-sweeper's first two review points, and this branch has now been rebased onto current origin/main (it was 2372 commits behind) with a 6th commit reconciling the result — addressing hermes-sweeper's third point ("the fallback-final implementation has moved materially since this branch... reconcile with current main before salvage").
The rebase itself cleanly resolved a textual conflict in
_try_strip_cursor: origin/main's04898631c/4aa499ff9(the two commits sweeper named) had independently converged on the same "leave message intact on failure" behavior this PR's first commit introduces, so no design conflict there. It did surface one real interaction: this PR's own edit-in-place commit changed how_send_fallback_finaldelivers content (edit vs. send+delete) for the single-message case, which three pre-existingTestSegmentBreakOnToolBoundaryregression tests hadn't been updated to account for — they asserted delivery specifically viasend()/delete_message(). Fixed by updating those tests to accept either delivery mechanism (same outcome: correct content visible, no stale/duplicate message) and adding a dedicated test that forces the edit to fail so the send+delete fallback still gets direct coverage. Confirmed all 252 tests across the affected files pass, and separately confirmed the 9 pre-existing telegram test-order-pollution failures in the wider suite are identical on a cleanorigin/maincheckout with none of this branch's commits — unrelated to this PR.Generated with Claude Code