Skip to content

fix(telegram): streaming previews stop earning flood penalties — shared send+edit slot, retry_after honoured, word-boundary continuation (#116312, salvage #116385) - #116987

Merged
teknium1 merged 5 commits into
mainfrom
fix/b0919-R08-platforms-streaming-telegram-pacing
Sep 20, 2026
Merged

teknium1 merged 5 commits into
mainfrom
fix/b0919-R08-platforms-streaming-telegram-pacing

Conversation

@teknium1

@teknium1 teknium1 commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Telegram streaming no longer earns flood penalties from its own preview edits: sends and edits share one per-chat 1/s slot, a refused edit waits out the retry_after Telegram hands back, and the fallback continuation starts at a word boundary.

  • Shared per-chat send+edit slot (plugins/platforms/telegram/adapter.py::_chat_outbound_slot_remaining / _hold_chat_outbound_slot, salvaged from fix(telegram): shared per-chat send+edit pacing budget and word-boundary continuation cut #116385): a send waits for its slot (never dropped), an interim preview edit is skipped while the slot is busy (the next tick shows the same text), the final edit is never gated. Measured by the reporter in production: 23 → 0 flood events/day at 2× the call density.
  • Word-boundary fallback cut (gateway/stream_consumer_fallback.py::_continuation_text, from fix(telegram): shared per-chat send+edit pacing budget and word-boundary continuation cut #116385): the continuation re-sends the broken word's tail instead of starting mid-word.
  • retry_after honoured on the edit path (gateway/stream_consumer_transport.py::_on_edit_failure, slim redo of the retry_after half of fix(gateway): honour Telegram flood waits on streaming edits #105340 per @AlexxRussell's analysis on the thread): interval becomes max(doubling, retry_after) (30s cap) instead of 1.6s → 3.2s burning all three strikes inside a 9s+ penalty.
  • _should_edit no longer lets buffer_threshold override an active flood backoff (gateway/stream_consumer.py): on main, once the reply passed 24 codepoints every 50 ms tick re-edited regardless of edit_interval (probe: edit_interval=1.0 → 9 interim edits in 1.3 s), which made both the legacy doubling and any server wait dead letters.

Root cause: Telegram counts editMessageText against the same per-chat allowance as sendMessage, but the adapter paced only sends and the consumer's own interval was bypassed by the buffer-threshold clause and ignored the server's retry_after.

Live A/B (real TelegramAdapter + real event loop, fake Bot object — no Bot API token on this host; scripts/probe_tg_pacing.py: 6 interim edits 0.1 s apart, then a send, then the final edit, one chat):

outbound API calls elapsed final text delivered
before (origin/main) 8 (7 edits + 1 send) in 0.6 s 0.60 s yes
after 3 (1 interim edit + paced send + ungated final edit) 1.00 s yes

Stream-consumer A/B (tests/gateway/test_stream_edit_flood_retry_after.py, real GatewayStreamConsumer.run() against an adapter that answers every edit with flood_control:9, retry_after=9.0): before → 3 interim edits fired inside the penalty; after → 1, interval ≥ 9 s. Control (flood without retry_after) keeps the legacy doubling ladder.

Tests: scripts/run_tests.sh on the 3 new files + test_telegram_typing_retrigger.py + 16 mirroring stream-consumer / Telegram delivery files → 261 passed, 0 failed.

Fixes #116312
Salvages #116385 (@HaisamAbbas — both commits cherry-picked, tests trimmed to two invariants per fix in a follow-up commit)
Supersedes #105340 (@AlexxRussell — retry_after honoured with a 5-line interval change instead of the pause/join-budget machinery; credited in the commit body)
Supersedes #116339 (paces inside the consumer only; the adapter-side slot is the design the reporter validated)

Dropped hunks

Infographic

infographic

Review follow-up

  • A slot-busy skipped interim edit was reported as a plain success, so the stream consumer recorded never-shown text as the visible prefix (and reset flood strikes); a later turn-final flood then marked the turn delivered / entered fallback with an empty continuation and the user never saw the tail. The Telegram adapter now flags the skip in raw_response={'skipped': True} and _edit_existing leaves _last_sent_text/_flood_strikes untouched for skipped edits. Rebased onto origin/main. (commit 3488e53)
  • Tests: tests/gateway/test_telegram_shared_outbound_budget.py (+1 invariant, red before) + 13 stream-consumer/flood files: 176 passed

@AlexxRussell

Copy link
Copy Markdown

Superseding #105340 with this is the right call, and the interval change is the
better shape: once the interim interval itself honours the wait, the pause
deadline, the _wait_for_flood_pause helper and the _await_stream_task join
budget in that PR are all machinery for a problem that no longer exists. I will
close #105340 once this lands.

The _should_edit finding is the part I had missed, and it changes the story I
told on #116312. I said the three strikes were spent in about five seconds of a
9s penalty, which assumed the interval was being honoured at all between them.
It was not: with the reply past 24 codepoints the buffer_threshold clause
re-edited on every 50ms tick regardless, so the legacy doubling was already a
dead letter before flood control entered it. That also explains something my
own production log showed and I could not account for, four refusals inside
0.4s against a 9s wait. Three strikes at 1.6s and 3.2s cannot produce that
spacing; an unthrottled tick can.

One narrow note, since it touches the same field from the other side.
SendResult.retry_after is not guaranteed to be a number on the Telegram path:
plugins/platforms/telegram/adapter.py:1467 copies the raw PTB attribute into
the result, and under PTB_TIMEDELTA=1 (PTB's migration mode for the 23.x
default) that attribute is a datetime.timedelta. Your
isinstance(retry_after, (int, float)) guard is correct as written and fails
safe by ignoring it. It is worth knowing that the guard is the thing standing
between a timedelta and a silently dropped wait, because the edit path only
avoids handing you one today by raising inside _flood_cap_result first, which
is the bug #100446 fixes. If that normalisation ever moves, this quietly goes
back to pure doubling for anyone in that mode. A one line comment, or
normalising once where the adapter builds the SendResult, would pin it.

Thank you for the credit in the commit body.

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins platform/telegram Telegram bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 20, 2026
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

૮ >ﻌ< ა ci review

ran on 3488e53 — fix: slot-busy skipped Telegram edits no longer count as sho

debug info

CI timings

CI timings · View report · View job

Wall time 22m36s vs 45m35s (-50.4%). 8 job(s) slower, 2 faster, 2 unchanged.

  • Python lints / Windows footguns (blocking): +38.0s
  • OS-specific tests / Windows-only tests: -14.0s
  • Python tests / Run tests: -8.0s
  • Python lints / ruff enforcement (blocking): +6.0s
  • OS-specific tests / macOS-only tests: +6.0s

HaisamAbbas and others added 5 commits September 20, 2026 00:57
When editing content grows past a streaming edit tick, the fallback prefix
can end mid-word. Continuing from that cut drops the broken word's tail and
reads as stutter. Back the cut up to the last space or newline so the tail
of the split word is re-sent; a boundary-less prefix (one long token) keeps
the original cut instead of re-sending the whole reply.
Telegram counts an editMessageText against the same per-chat allowance as a
sendMessage, but streaming previews paced only edits (DEFAULT_STREAMING_EDIT
_INTERVAL = 0.8s = 1.25 msg/s into one chat before any reply was sent) —
83% of measured flood penalties. One shared slot per chat: a send WAITS for
its slot (skipping would drop a message), an interim edit is SKIPPED (the
next tick shows the same text anyway), and the final edit is never gated
(the answer is never withheld). A per-adapter tuning knob keeps the slot
available to tests that model instantaneous bursts.
…er fix

Keep interim-skip/final-never-gated + send-waits-for-slot, and mid-word/one-long-token
for the continuation cut; the dropped cases restate the same two invariants.
…nstead of re-striking inside the penalty

`_on_edit_failure` treated a flood-refused edit as a generic failure: a strike plus
`min(interval * 2, 10)`. Starting from the 0.8s default that is 1.6s then 3.2s, so all
three strikes (and three more refused requests, each extending the ban) were spent in
about five seconds of a penalty Telegram had already told us is 9s or longer, and
edits were then abandoned for the rest of the turn.

- The interim interval now becomes `max(doubling, retry_after)` (capped at 30s; interim
  edits are skipped, not slept, so a long wait only costs a stale preview).
- `_should_edit`'s `buffer_threshold` clause no longer overrides an active flood backoff:
  once the reply passed 24 codepoints every 50ms tick re-edited regardless of the
  interval, which made both the legacy doubling and any server wait dead letters.

Slim redo of the retry_after half of #105340 (analysis by @AlexxRussell on #116312);
the pause/join-budget machinery there is not needed once the interval itself is honoured.
…review follow-up)

An interim edit skipped because the chat's shared send+edit slot was busy
returned a plain SendResult(success=True), so the stream consumer recorded
the never-shown text as _last_sent_text and reset _flood_strikes. A later
turn-final flood then saw _visible_prefix() == final text and either marked
the turn delivered or entered fallback with an empty continuation — the user
never saw the tail. The adapter now flags the skip in
raw_response={"skipped": True} and _edit_existing leaves the visible prefix
and flood state untouched, so the next tick retries and a flood fallback
re-sends exactly the unseen tail.
@teknium1
teknium1 force-pushed the fix/b0919-R08-platforms-streaming-telegram-pacing branch from a36ba68 to 3488e53 Compare September 20, 2026 08:00
@teknium1
teknium1 merged commit a57a5f9 into main Sep 20, 2026
34 checks passed
@teknium1
teknium1 deleted the fix/b0919-R08-platforms-streaming-telegram-pacing branch September 20, 2026 19:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/telegram Telegram bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Telegram streaming edits are unpaced — 83% of flood penalties come from them, and the fallback continuation then cuts mid-word

4 participants