Skip to content

bug: Telegram duplicate messages caused by stacked retry layers after send timeout #3906

@tunamitom

Description

@tunamitom

Bug

Since PR #3288 (bde45f5a — "retry transient send failures and notify user on exhaustion"), Telegram replies can be posted twice when a sendMessage call times out.

Root Cause

PR #3288 added _send_with_retry() to BasePlatformAdapter which retries on transient errors (including timeouts). However, TelegramAdapter.send() already has its own internal retry loop for network errors. This creates two stacked retry layers:

  1. Telegram adapter send() retries internally (up to 3 attempts)
  2. Base _send_with_retry() retries on top of that (up to 2 more attempts)

The critical problem: Telegram send timeouts are ambiguous. The Bot API may have already accepted and delivered the message even though the HTTP client timed out waiting for the response. When either retry layer fires after such a timeout, it re-sends the same content, producing user-visible duplicate messages.

Reproduction

  1. Send a message via Telegram adapter when the Bot API is slow (e.g., under load or flaky network)
  2. The sendMessage call times out (ReadTimeout / TimedOut)
  3. Both the Telegram internal retry and the base retry wrapper fire
  4. The same message appears 2-3x in the chat

Suggested Fix

Two changes needed:

1. SendResult.delivery_uncertain flag in base.py

Add a delivery_uncertain: bool = False field to SendResult. When a platform returns delivery_uncertain=True, _send_with_retry() should immediately stop — no retries, no plain-text fallback.

2. Timeout detection + _send_with_retry override in telegram.py

  • Add _looks_like_send_timeout() to detect TimedOut, ReadTimeout, WriteTimeout exceptions
  • When a send times out, return SendResult(success=False, delivery_uncertain=True) instead of retrying
  • Override _send_with_retry() to bypass the base retry wrapper entirely, since Telegram send() already handles its own retry/fallback logic

This ensures that an ambiguous Telegram timeout stops all retry attempts immediately rather than risking duplicate delivery.

Affected Version

Any version including commit bde45f5a (PR #3288) onward.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions