Skip to content

fix(gateway): redeliver a flood-refused final reply once the penalty passes - #103669

Closed
AlexxRussell wants to merge 5 commits into
NousResearch:mainfrom
AlexxRussell:fix/ledger-flood-retry
Closed

AlexxRussell wants to merge 5 commits into
NousResearch:mainfrom
AlexxRussell:fix/ledger-flood-retry

Conversation

@AlexxRussell

@AlexxRussell AlexxRussell commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

The Telegram adapter fails a flood-controlled final send closed as
SendResult(success=False, error="flood_control:<seconds>") so the send
coroutine never sleeps a long penalty (#91969). The comment at that site says
the caller's retry machinery, the delivery ledger, owns the wait. The ledger
did not: sweep_failed_for_runtime only replays send_path_degraded rows, so
a flood-refused row sat in failed until the next restart, whose
sweep_recoverable then redelivered it prefixed with the restart marker
("Recovered reply, the gateway restarted during delivery, so this may be a
duplicate").

Observed on a live gateway:

2026-09-05 08:58:33 WARNING [Telegram] Telegram flood control on send (retry_after=185.0s > 5s); failing closed instead of sleeping: Flood control exceeded. Retry in 185 seconds
2026-09-05 08:58:33 WARNING [Telegram] Send failed: flood_control:185.0 - trying plain-text fallback
2026-09-05 08:58:33 ERROR   [Telegram] Fallback send also failed: flood_control:185.0
2026-09-05 12:15:22 INFO    Redelivered recovered final response to telegram:5230977008 (obligation fe45c84ed559fdd843425bbe, attempt 1)

The reply reached the user three hours late, only because the gateway happened
to restart, and under a marker describing a restart that had nothing to do with
it. Whether Telegram had accepted an earlier chunk of that reply is not knowable
from the logs, which is why the marker below says so.

Related Issue

No existing issue. Reproduction and log evidence are above; #91969 is the
change that introduced the fail-closed flood result this completes.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

gateway/delivery_ledger.py

  • flood_control:* rows become runtime-retryable, but only once their own
    deadline has passed: the refusal's updated_at plus the platform's wait
    (flood_not_before). Neither an early timer nor a reconnect sweep spends a
    redelivery attempt inside the penalty window.
  • Every flood redelivery carries its own marker: FLOOD_MARKER says the
    platform's rate limit refused the original, so part of it may already have
    arrived. The stored text's raw length cannot say how many requests the
    adapter made (MarkdownV2 escaping alone turns 3000 dots into two Telegram
    messages) and the send result does not say which chunk was refused, so there
    is no length-based "nothing was delivered" shortcut. The restart and reconnect
    markers would describe an event that never happened.
  • A runtime claim released unsent (its resume flag could not be cleared, or its
    adapter vanished before dispatch) goes back to failed with its own pre-claim
    error instead of send_path_degraded, so a flood row stays on the flood
    timer's list and is retried after the platform's figure rather than stranded
    until a reconnect or restart.
  • Claiming a flood row clears the stale refusal (last_error NULL, state
    attempting), so a resend interrupted before mark_delivered is seen as
    uncertain by the next boot and gets the marker as before.
  • At boot, a dead owner's flood row that is not yet due is adopted (owner
    re-stamped, no attempt spent) instead of being resent early. The adopted row
    is returned to the caller flagged adopted with its not_before, so the
    caller clears its session's resume_pending flag exactly as it does for
    every other claimed row (the answer is in the ledger; the turn must not be
    re-run) while the row itself is left to the timer.
  • A legacy row without adapter_profile (recorded before that column existed)
    is normalised to default when the boot sweep claims or adopts it. The boot
    sweep accepts such rows only on a non-multiplexed gateway, but the runtime
    sweep matches profiles exactly and the timer asks for default, so without
    this an adopted legacy row could only ever wake the timer without being sent.
  • New pending_flood_retries() lists this process's waiting flood rows per
    adapter identity with the earliest deadline.

gateway/run_startup.py

  • _schedule_flood_redelivery arms one timer per adapter identity that runs
    the existing runtime sweep after the wait. Sleeps are capped at 15 minutes;
    the row's deadline, not the timer, decides eligibility, so a capped timer
    wakes early, sends nothing, and re-arms for the remainder. The slot stays
    occupied until the timer ends and only the running timer may arm its
    successor into it, and the post-sweep re-arm reads the ledger synchronously so
    a refusal that lands during the sweep is picked up before the slot is freed.
    A timer that is still asleep is replaced when a shorter refusal arrives (a
    10s row must not wait behind a 185s sibling); the shorter timer re-arms for
    the longer row after its sweep.
  • _redeliver_claimed_obligations skips adopted rows (their resume flags were
    cleared with the others) and arms the timers afterwards, so a boot with only
    adopted rows still gets its timer.
  • _arm_flood_timers_for_waiting_rows runs after the boot redelivery pass and
    after every timer sweep, covering adopted rows, rows skipped as not yet due,
    and rows refused again. Timer-triggered runtime sweeps that find nothing to
    claim return early and rely on the timer's own re-arm. That re-arm reads the
    ledger synchronously so its snapshot cannot miss a row recorded by a
    concurrent flood refusal whose own timer request was declined while the slot
    was held.

gateway/platforms/base.py

  • _finalize_delivery_obligation arms the timer on a flood_control failure,
    best-effort inside the existing try, alongside the send_path_degraded
    branch.

Deliberately unchanged: permanent rejections (blocked bot, bad auth, missing
chat) are never replayed by runtime recovery (the boot sweep's handling of
failed rows is as before); send_path_degraded handling is untouched.

How to Test

  1. pytest tests/gateway/test_delivery_ledger_flood_retry.py -q (43 tests).
    They drive the real ledger against an isolated state.db and the real
    GatewayRunner redelivery methods with a controllable clock shared by the
    ledger and the runner, and cover: the deadline (a 10s and a 185s refusal
    arriving together in either order, only the due row is sent and the timer
    re-arms for the other), a capped timer that wakes early and never sends
    early, a refusal during redelivery arming a successor, adoption at boot with
    ownership actually moving from a dead stamp, the adopted row's session
    having its resume flag cleared without the row being sent, the legacy
    NULL-profile row being claimable by the timer, the interrupted resend
    keeping its marker, the chunked reply keeping its marker, and the adapter
    hook.
  2. pytest tests/gateway/test_delivery_ledger.py tests/gateway/test_delivery_ledger_producer.py tests/gateway/test_restart_redelivery_dedup.py tests/gateway/test_platform_reconnect.py tests/gateway/test_silent_partial_delivery_95382.py tests/gateway/test_multiplex_adapter_registry.py -q
    (130 tests, unchanged behaviour for every non-flood path).
  3. Each guard was mutation-tested: removing any one of the deadline check, the
    stale-error clearing, the unconditional marker, the boot adoption, the
    adopted-row return, the profile normalisation, the timer cap, the successor
    arming, the sleeping-timer replacement or the adapter hook fails at least
    one test.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run the affected gateway suites and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (tests) and Ubuntu 22.04 (live gateway on the box the incident came from)

Documentation & Housekeeping

  • Relevant documentation updated: module docstrings and comments (no user-facing docs affected)
  • cli-config.yaml.example: N/A, no config keys added or changed
  • CONTRIBUTING.md or AGENTS.md: N/A, no architecture or workflow change
  • Cross-platform impact considered: pure Python, no platform-specific code

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/telegram Telegram bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 5, 2026
@AlexxRussell
AlexxRussell force-pushed the fix/ledger-flood-retry branch from 1ca8fbb to 067cc9a Compare September 5, 2026 13:57

@gertsio gertsio left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on 067cc9a3. Two reproduced gaps:

  1. flood_non_delivery_is_certain assumes raw text under 4096 UTF-16 units means one Telegram request. With "." * 3000, Markdown escaping produces 6000 units. Accept chunk 1 and return RetryAfter=185 on chunk 2: recovery incorrectly sets needs_marker=False. Delete the raw-length certainty check and its UTF-16 helper/constant; keep the duplicate warning for flood recovery. That removes the faulty assumption without adding delivery tracking. The tradeoff is a warning even when nothing was delivered.

  2. In _redeliver_failed_obligations_for_platform, make clear_resume_pending fail once when a flood retry becomes due. _release_runtime_claim_quiet changes the error to send_path_degraded, so pending_flood_retries() drops the row and the timer ends. Observed: no send, row ('failed', 0, 'send_path_degraded'). Preserve flood retry eligibility when releasing that unsent claim, while retaining the resume-flag check.

Validation through scripts/run_tests.sh: 166 existing tests passed; two added regressions failed and their two controls passed. Tests use the real adapter/ledger/runner with a fake transport and clock. Full suite not run.

@AlexxRussell

Copy link
Copy Markdown
Contributor Author

Thanks, both reproduced here as well, and both are fixed in the follow-up commit.

  1. Dropped the length-based certainty (flood_non_delivery_is_certain, the UTF-16 helper and the 4096 constant). Every flood redelivery now carries the rate-limit marker, at runtime and at boot, and the marker's wording no longer presumes partial acceptance ("part of it may already have arrived above"). Regression: a parametrized test over "x" * 10, "." * 3000 and a genuinely long reply asserts the marker on both sweeps.

  2. The runtime claim now carries its pre-claim last_error, and _release_runtime_claim_quiet writes that back instead of send_path_degraded (both call sites: the resume-flag clear in _redeliver_failed_obligations_for_platform and the vanished-adapter path in _obligation_adapter). A released flood row therefore stays in pending_flood_retries(); since the release re-stamps updated_at, it waits the platform's figure once more and is sent by the successor timer. Regression: clear_resume_pending fails once at the first due time, the row is sent by the second timer with attempts == 1 and sleeps [32, 32]; a second test covers the adapter-gone release keeping flood_control:9.

40 tests in tests/gateway/test_delivery_ledger_flood_retry.py; the related ledger, reconnect and dedup suites still pass. The PR description is updated to match.

@AlexxRussell

Copy link
Copy Markdown
Contributor Author

Second pass after re-reviewing the branch end to end (f779842):

  • the boot sweep now returns adopted flood rows flagged adopted, so their sessions get the same resume_pending clear as every other claimed row. Without it a dead owner's not-yet-due row left its session flagged, the resume path re-ran the turn at boot and the timer later delivered the recorded answer as well;
  • legacy rows without adapter_profile are normalised to default on claim or adoption. The runtime sweep matches profiles exactly and the timer asks for default, so an adopted NULL row could only wake the timer without ever being sent;
  • a shorter refusal now replaces a timer that is still asleep instead of waiting behind it; the shorter timer re-arms for the longer row after its sweep. A timer already sweeping is never cancelled from outside;
  • pending_flood_retries is read in a worker thread like the surrounding ledger calls;
  • the adoption tests seed a distinct dead-owner stamp so they prove ownership actually moves.

Body updated to match (43 tests, the mutation list, and the wording about what the platform may have accepted).

@AlexxRussell

Copy link
Copy Markdown
Contributor Author

Third pass (4914486): reverted the flood re-arm's ledger read to synchronous. Making it read in a worker thread (an earlier reviewer suggestion) opened a race — a flood refusal recorded during the thread's yield had its own reschedule request declined while the timer held its slot, and the timer then cleared the slot on a stale snapshot, stranding that reply until the next reconnect. The read is a single indexed SELECT, so the arm decision is now atomic with respect to concurrent schedule requests. Added a test that refuses a final during the timer's own send and asserts the same timer arms it. Body corrected.

…passes, without a false duplicate marker

The Telegram adapter fails a flood-controlled final send closed as
flood_control:<seconds> so the send coroutine never sleeps a long penalty
(NousResearch#91969), on the understanding that the delivery ledger owns the wait. The
ledger did not: sweep_failed_for_runtime only replayed send_path_degraded
rows, so a flood-refused row sat in 'failed' until the next restart, whose
sweep_recoverable then redelivered it hours late prefixed with "Recovered
reply, the gateway restarted during delivery, so this may be a duplicate".
Observed: a reply refused at 08:58 UTC (both the MarkdownV2 send and the
plain fallback got flood_control:185) arrived at 12:15 UTC after a restart,
labelled as a possible duplicate although the platform had never accepted it.

Ledger (gateway/delivery_ledger.py):
- flood_control rows are runtime-retryable, but only once their own deadline
  has passed: the refusal's updated_at plus the platform's wait
  (flood_not_before). Neither an early timer nor a reconnect sweep spends a
  redelivery attempt inside the penalty window.
- A flood refusal of a reply that fits in one Telegram message (4096 UTF-16
  units) proves non-delivery, so that redelivery carries no duplicate marker.
  A chunked reply may have had its first chunk accepted before the refusal
  and keeps the marker.
- Claiming a flood row clears the stale refusal (last_error NULL, state
  'attempting'), so a resend interrupted before mark_delivered is seen as
  uncertain by the next boot and gets the marker.
- At boot, a dead owner's flood row that is not yet due is adopted (owner
  re-stamped, no attempt spent) instead of being resent early.
- pending_flood_retries() lists this process's waiting flood rows per adapter
  identity with the earliest deadline.

Runner (gateway/run_startup.py):
- _schedule_flood_redelivery arms one timer per adapter identity that runs
  the existing runtime sweep after the wait (capped at 15 minutes per sleep;
  the row's deadline, not the timer, decides eligibility, so a capped timer
  wakes early, sends nothing, and re-arms for the remainder). The slot stays
  occupied until the timer ends and only the running timer may arm its
  successor into it, so a refusal during the sweep can never strand the row.
- _arm_flood_timers_for_waiting_rows runs after every redelivery pass (boot
  and runtime), covering adopted rows, rows skipped as not yet due, and rows
  refused again.

Adapter (gateway/platforms/base.py): _finalize_delivery_obligation arms the
timer on a flood_control failure, best-effort inside the existing try.

Tests: 35 in tests/gateway/test_delivery_ledger_flood_retry.py, with a
controllable clock shared by ledger and runner. Each guard was mutation
tested. Existing ledger, reconnect and redelivery suites (131 tests) pass.
…laim on the timer

Review on the first cut reproduced two gaps.

The raw UTF-16 length of the stored reply said nothing about how many
requests the adapter made: MarkdownV2 escaping turns 3000 dots into 6000
units, two Telegram messages, and the send result does not say which chunk
the platform refused. Drop the length-based certainty (and its helper and
constant) and mark every flood redelivery with the rate-limit marker, at
runtime and at boot. The cost is a marker on a reply nothing of which was
delivered; the alternative was a silent duplicate.

Releasing an unsent runtime claim always wrote send_path_degraded, so a flood
row whose resume flag could not be cleared (or whose adapter vanished before
dispatch) left the flood timer's list and stayed stranded until a reconnect or
restart. The claimed row now carries its pre-claim error and the release
writes it back, so the row waits the platform's figure once more and is sent
on the next timer.
…es reach the timer

Second review pass on the flood-retry change.

- sweep_recoverable returns a dead owner's not-yet-due flood row flagged
  `adopted` (with its `not_before`) instead of dropping it from the result,
  so _claim_pending_obligations clears its session's resume_pending flag like
  every other claimed row; the answer is in the ledger and the turn must not
  be re-run at boot. _redeliver_claimed_obligations skips adopted rows and
  still arms the timer for them.
- A legacy row without adapter_profile is normalised to 'default' when the
  boot sweep claims or adopts it: the runtime sweep matches profiles exactly
  and the timer asks for 'default', so an adopted NULL row could only wake the
  timer without ever being sent.
- A sleeping timer is replaced by a shorter refusal's timer; the shorter one
  re-arms for the longer row after its sweep. A timer already sweeping is
  never cancelled from outside.
- pending_flood_retries is read in a worker thread like the other ledger calls.
- Adoption tests seed a distinct dead-owner stamp and assert ownership moves.
- Scratch tags and long comment lines removed.
…ep refusal is not lost

Round-2 review. Making _arm_flood_timers_for_waiting_rows read the ledger in a
worker thread introduced a race: a flood refusal recorded during that thread's
yield had its own _schedule_flood_redelivery request declined (the timer still
held the slot), and the timer then cleared its slot on a snapshot taken before
the new row committed, leaving that reply with no timer until the next reconnect
or restart. The read is a single indexed SELECT; doing it synchronously keeps the
arm decision atomic with respect to concurrent schedule requests. A test drives a
refusal during the timer's own redelivery send and asserts the same timer arms it.
…nonical one

The redelivery hook keyed on the canonical flood_control:<seconds> result, so
two real refusals slipped past it and armed no timer, leaving the reply for the
next restart. A short wait that outlived the send retries raised instead of
failing closed, and an edit refused again after its inline wait returned the
platform's raw text. Both now fail closed canonically, the second carrying the
new delay rather than the first refusal's.

The ledger also accepts a row still carrying the platform's own wording, so a
row persisted by an unnormalized path is dated from the delay it states instead
of the generic default. Without that a boot sweep claims it at once and spends
its one attempt inside the penalty. Matching requires the flood wording as well
as a delay, so an unrelated retry suggestion is never read as a flood.

Six new assertions fail without this change. 770 passed across the ledger,
Telegram, send-retry and queued suites.
@AlexxRussell
AlexxRussell force-pushed the fix/ledger-flood-retry branch from 4914486 to 7a87be7 Compare September 7, 2026 08:52
@AlexxRussell

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (fc8d15d77), and closed a gap that #104370 made reachable.

#104370 now returns a typed failure in two new places: when the server's retry_after exceeds the 60s inline cap, and when retries are exhausted while still rate limited. Both comments say the delivery ledger redelivers after the cooldown. That mechanism is what this PR adds, and on current main gateway/delivery_ledger.py has no flood handling at all, so those two paths presently return a typed failure that nothing owns.

The hook here keyed only on the canonical flood_control:<seconds> result, and two real refusals never produce it: a wait under the inline cap that outlives the send retries used to raise, and an edit refused again after its inline wait returned the platform's raw text. Both left a failed row with no redelivery timer, so the reply waited for the next restart.

7a87be7cb normalizes both paths to the canonical result, the edit carrying the new delay rather than the first refusal's. It also lets the ledger accept a row still carrying the platform's own wording, so such a row is dated from the delay it states instead of the generic default; without that a boot sweep claims it immediately and spends its one attempt inside the penalty that caused it. Matching requires the flood wording as well as a delay, so an unrelated retry suggestion is not read as a flood.

Six new assertions fail without the change. 770 passed, 2 skipped across the ledger, Telegram, send-retry and queued suites.

@teknium1

teknium1 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Landed via #104950 at 7e77401ad011576330346f44336206d6b3dde3b8. Thank you @AlexxRussell for diagnosing and implementing the flood-refused final-reply handoff, then following through on partial-delivery warnings, released claims, boot adoption/resume clearing, legacy profiles, and raw Telegram flood errors. All five of your commits are preserved on main under your authorship: ae900cba9ce36a3aba1fde4702ec85a688ec0346, 678b0649abed68105a6e1bce88f34d37cfd74c28, 289ece2e59451ddba128b82a575304722397735c, e41e5e236be950a63327515b3659029f61b6093e, and 5495c29cf8f3e5c82aeb2e849a2f0bcff39e5995.

The salvage keeps your ledger deadline gates and adapter normalization. On top, the synchronous timer re-arm was replaced by one event-woken, shutdown-tracked worker per bot identity: SQLite reads stay off the event loop while a refusal during a read/send leaves a wake. Timer-specific tests were replaced by two ledger invariants and a repeatable local-wire probe; no contributor commit was dropped. Thanks also @gertsio for reproducing the escaped-chunk warning and released-claim gaps, and @pvdb2178 for independently documenting the missing runtime trigger in #103877.

Post-merge verification on an archive of current origin/main: both ledger invariants passed. The real Telegram SDK/adapter → SQLite → runner loopback probe recovered a 61-second flood refusal automatically at runtime and after a real producer-process exit; each recovered once after expiry, spent zero attempts during the penalty, retained thread 77, and cleared resume_pending. Foreign-profile and permanent-rejection runtime controls were not sent. This is local HTTP evidence, not a live Telegram service claim. Scope is ledger-bracketed final text, not every media/notification producer. Closing this source PR as landed via the salvage.

@AlexxRussell

Copy link
Copy Markdown
Contributor Author

Superseded, and happy to see it land. #104950 merged today as 7e77401ad, salvaging this PR and preserving all five commits here plus this morning's follow-up.

I checked upstream main rather than assuming, and the whole mechanism is there: is_flood_error including the raw flood-wording match, _schedule_flood_redelivery, the deadline-enforcing boot and runtime sweeps, and both adapter fail-closed paths (the exhausted short flood on send, and the edit refused again after its inline wait).

The one substantive difference is worth recording for anyone reading this later: the merged version replaces the synchronous SQLite timer re-arm used here with one event-woken worker per bot identity, which keeps the database work off the event loop. That is the better shape, and it means the tests in this branch describe a mechanism upstream no longer has, so they should not be carried over.

Nothing here is still needed. This can be closed whenever suits you. I have already retired the corresponding local patch on my own deployment so it does not fight the native implementation on the next update.

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 P2 Medium — degraded but workaround exists 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.

4 participants