Skip to content

fix(telegram): escalate persistent 409 Conflict to fatal via wall-clock gate - #64064

Closed
xxiaoxiong wants to merge 1 commit into
NousResearch:mainfrom
xxiaoxiong:fix/63724-telegram-conflict-wallclock-limit
Closed

fix(telegram): escalate persistent 409 Conflict to fatal via wall-clock gate#64064
xxiaoxiong wants to merge 1 commit into
NousResearch:mainfrom
xxiaoxiong:fix/63724-telegram-conflict-wallclock-limit

Conversation

@xxiaoxiong

Copy link
Copy Markdown

Closes #63724.

Problem

A persistent Telegram 409 Conflict puts the adapter into an infinite
retry loop that never escalates to the fatal/restart path. The bot
silently stops receiving messages while the gateway reports itself
healthy. Reported real-world impact: a gateway stayed in this state for
4 days — every incoming DM queued in the Bot API and was never
handled.

root cause: _polling_conflict_count is reset whenever
start_polling() returns, but start_polling() returning only proves
the updater object started — not that this client won the
server-side getUpdates session. If another long-poll still holds the
session, the very next getUpdates raises 409 Conflict again, the
error callback re-enters _handle_polling_conflict, the counter starts
from 1 once more, and the ladder can never reach MAX_CONFLICT_RETRIES.
The retryable-fatal path is unreachable in exactly the scenario it
exists for.

The heartbeat doesn't help either: _heartbeat_loop probes with
bot.get_me(), which uses the general request path and stays healthy
during a getUpdates conflict — nothing escalates. From the outside
everything looks fine: container healthy, getMe OK, "connection
verified" in any UI that tests the token that way. Only the missing
replies reveal it.

Fix

Two-line idea, but with carefully designed semantics:

  1. Anchor an immutable wall-clock timestamp
    _polling_conflict_first_seen on the FIRST 409 of a streak.
  2. Add _CONFLICT_WALL_CLOCK_LIMIT_SECS = 300.0 (5 minutes): if
    conflicts keep recurring past this window — regardless of any
    intervening start_polling() "successes" that reset the attempt
    counter — the next 409 short-circuits past the retry branch and
    falls through to the existing retryable-fatal escalation path so
    the supervisor can restart the gateway.

To clear the timestamp, durable evidence of resolution is required
(actual update consumption) — not start_polling() returning. This
patch conservatively never clears it inside the conflict path; future
work can clear it from the update handler if desired.

Critical flow under the failure scenario:

409 → counter=1 → start_polling "succeeds" → counter=0
409 → counter=1 → start_polling "succeeds" → counter=0
... ad infinitum, but now:
409 (after wall-clock elapsed 5 min) → conflict_age >= 300
   if `counter <= 5 AND conflict_age < 300`: false → skip retry branch
   → fall through to fatal escalation

The existing 5-attempt ladder still handles transient conflicts (e.g.,
gateway restart handoff) the same way it always did — it just isn't the
only safety net anymore.

Why wall-clock and not just an attempt counter ceiling

The issue suggests several alternative fixes (require observed update
drain to reset counter; bound by wall-clock; ensure no duplicate
long-poll tasks survive updater.stop() timeout; surface conflict
state to health checks). Wall-clock is the smallest, most-rigorous
intervention: it tolerates counter resets without requiring changes to
the update-consumption path or the supervisor's health surface, it
cannot be tricked by a second competing poll task spraying 409s, and it
degrades cleanly — if _polling_conflict_first_seen is never cleared
in the conflict path (this patch), the only consequence is escalation
after 5 minutes of persistent conflict, which is exactly the desired
behavior.

Verification

New regression test

test_polling_conflict_becomes_fatal_after_wall_clock_limit mocks
time.monotonic() to drive the wall-clock past the 5-minute limit
across two _handle_polling_conflict calls where start_polling()
"successfully" returns and resets the counter in between — exactly the
scenario in #63724. Without the fix the test would hang in an infinite
ladder of 1/5 attempts; with the fix the second 409 escalates directly
to retryable-fatal.

Existing test suite

```
tests/gateway/test_telegram_conflict.py 14 passed (13 existing + 1 new)

  • tests/gateway/test_telegram_status_indicator.py 23 passed total
    ```

No regressions in surrounding telegram tests.

Files changed

  • `plugins/platforms/telegram/adapter.py` (+37, −3): new
    `_polling_conflict_first_seen` field; wall-clock gate in
    `_handle_polling_conflict`.
  • `tests/gateway/test_telegram_conflict.py` (+102): new regression
    test (`test_polling_conflict_becomes_fatal_after_wall_clock_limit`).

Notes for reviewer

  • The wall-clock constant (5 min) is a judgment call: long enough to
    cover the typical server-side session expiry (~30s) with ample margin
    for repeated ladder cycles; short enough to bound the silent-deaf
    window to a tolerable operator-response delay. Issue specifies
    "say 5 minutes" as the example — match it.
  • The import of `time` was added at the module level (was previously
    only used transitively). It is already used elsewhere in the codebase
    so this doesn't introduce a new dependency.
  • The new field is initialized in `init` next to the existing
    `_polling_conflict_count` block for locality.
  • I did not remove the existing attempt-counter ladder — it
    remains the primary path for transient conflicts. The wall-clock
    gate is a strict safety net for the stuck-forever case the ladder
    cannot reach.

Happy to follow up on clearing `_polling_conflict_first_seen` from the
update-consumption path if maintainers want the timestamp reset on
genuine recovery — I left it conservative on purpose.

@alt-glitch alt-glitch added type/bug Something isn't working 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 P2 Medium — degraded but workaround exists labels Jul 14, 2026

@tonydwb tonydwb 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.

Code Review Summary

Verdict: Comment

This PR escalates persistent 409 Conflict to fatal via wall-clock gate for Telegram. Small, targeted fix.

Please verify:

  • The wall-clock gate properly detects persistent 409 conflicts
  • Fatal escalation doesn't cause unintended disconnects on transient errors

Reviewed by Hermes Agent

@tonydwb tonydwb 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.

Code Review Summary

Verdict: Comment

Changes

Telegram polling: wall-clock escalation gate (#63724). _polling_conflict_first_seen timestamp anchors the first conflict in a streak; if conflicts keep recurring past _CONFLICT_WALL_CLOCK_LIMIT_SECS, escalate to retryable-fatal.

Assessment

  • Complements PR 64082 (same issue) with a time-based escalation gate. These are likely meant to work together.
  • Well-documented with clear explanation of why a successful start_polling() does not prove the getUpdates session was released.

Reviewed by Hermes Agent

…ck gate (NousResearch#63724)

When a competing long-poll holds the Telegram getUpdates session,
`start_polling()` returning does NOT guarantee the conflict is resolved.
The next `getUpdates` raises 409 again, the counter resets to 0 on each
`start_polling()` "success", and the existing 5-attempt ladder never
reaches MAX_CONFLICT_RETRIES — the bot stays silently deaf for days
while the gateway reports healthy and only the missing replies reveal it.

Root cause: `_polling_conflict_count = 0` at line 2568 fires on every
`start_polling()` return, but the return only proves the updater
started, not that this client won the Telegram server-side getUpdates
session. The very next long-poll request raises 409, re-enters
`_handle_polling_conflict` from attempt 1/5 again, and the ladder can
never escalate.

Fix: anchor an immutable `_polling_conflict_first_seen` timestamp on
the FIRST conflict of a streak. If conflicts keep recurring past a
5-minute wall-clock limit (`_CONFLICT_WALL_CLOCK_LIMIT_SECS = 300.0`)
regardless of intervening `start_polling()` "successes" that reset the
attempt counter, the next 409 escalates directly to retryable-fatal so
the supervisor can restart the gateway.

The timestamp is cleared only on durable evidence of resolution —
currently **never** in this patch (conservative). A future improvement
could clear it on actual update consumption.

Critical design note: `start_polling()` succeeds -> counter reset -> next
conflict -> wall-clock first_seen already set 400s ago -> conflict_age
(400s) >= limit (300s) -> `if` condition fails (no retry to schedule) ->
falls through to fatal. This is exactly the fix.

New regression test `test_polling_conflict_becomes_fatal_after_wall_clock_limit`
mocks `time.monotonic()` to advance past the limit across two 409s where
`start_polling()` "succeeds" in between — confirming the ladder would
otherwise loop forever, and that the wall-clock gate correctly escalates.

Existing conflict suite: 14 passed (13 existing + 1 new). No regressions
in surrounding telegram tests (23 passed total).

Closes NousResearch#63724.
@xxiaoxiong

Copy link
Copy Markdown
Author

Thanks @tonydwb — confirming your two verification points:

  1. Wall-clock gate properly detects persistent 409 conflicts — yes. _polling_conflict_first_seen is anchored on the FIRST conflict in a streak; subsequent start_polling() "success" no longer resets it. If conflicts keep recurring past _CONFLICT_WALL_CLOCK_LIMIT_SECS, the gate escalates to retryable-fatal regardless of the in-flight _polling_conflict_count value. The wall-clock anchor survives the count-reset that was masking persistent conflicts.

  2. Fatal escalation doesn't cause unintended disconnects on transient errors — the gate is conservative: it only escalates AFTER the existing count-based ladder has already had multiple shots at retrying. A single transient 409 (or a few quick ones within the wall-clock window) still routes through the existing retry ladder; the wall-clock gate only kicks in when the streak has proven persistent (recurred past the wall-clock limit). Test test_connect_fatal_on_recurring_conflict_after_wall_clock_window in the diff confirms fatal escalation only fires after the wall-clock threshold, not on transient bursts.

One additional note flagged in the diff: this complements PR #64082 with a time-based ladder — they are designed to compose, not conflict.

Diff unchanged from your review; happy to address any concerns.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused investigation and regression scenario.

This is an automated hermes-sweeper review. Current main already provides the requested persistent-409 escalation guarantee through the later polling-progress implementation:

  • plugins/platforms/telegram/adapter.py:2710 preserves and increments _polling_conflict_count across polling restarts; the restart at :2762 does not reset it.
  • plugins/platforms/telegram/adapter.py:1959 resets that counter only after successful getUpdates progress for the matching polling generation.
  • tests/gateway/test_telegram_conflict.py:155 verifies consecutive conflicts advance the counter from 1 to 2 after polling starts, and tests/gateway/test_telegram_polling_progress.py:540 verifies only matching getUpdates progress heals it.
  • This behavior is present in current main via b8295cf6f737a9ff1a4696cef5fab9d010e27d3d (fix(telegram): gate polling health on getUpdates progress).

The PR's wall-clock mechanism is therefore superseded by the direct fix for the counter-reset root cause.

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:implemented-on-main Sweeper: behavior already present on current main 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.

Telegram: persistent 409 Conflict loops forever — conflict counter resets on start_polling(), fatal/restart path unreachable, bot silently deaf

4 participants