# NousResearch/hermes-agent · Telegram flood control retry_after handling fix

## Open now

- Nothing open on GitHub.

These members all concern a bug where the Telegram gateway's retry logic ignores the server-provided retry_after value during flood control, along with the corresponding fix. The issue and two related pull requests address the same specific problem.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #52143. Verify it addresses your case.
- **Recommended:** read PR #52143 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-52143`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #46762: Telegram sendRichMessage flood-control retry ignores server retry_after and drops final response

## Prior & competing PRs

- **#46774: fix(gateway): honor server retry_after in _send_with_retry for Telegram flood control**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `404b06ac4fc307d868ea51ac656387641ef16b39`, `#46762`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#52143: fix(gateway): honor server retry_after in Telegram flood control (#46762)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/platforms/base.py`, `plugins/platforms/telegram/adapter.py`, `tests/gateway/test_send_retry.py`
  - fix hunks:
  - `gateway/platforms/base.py`

    ```diff
    @@ -1743,6 +1743,9 @@ class SendResult:
         # stream consumer can send the missing tail instead of marking a clipped
         # response complete.
         retryable: bool = False  # True for transient connection errors — base will retry automatically
    +    # Server-requested retry delay in seconds (e.g. Telegram FloodWait retry_after).
    +    # When present, _send_with_retry() honors this instead of its default backoff.
    +    retry_after: Optional[float] = None
         # When the adapter had to split an oversized payload across multiple
         # platform messages (e.g. Telegram edit_message overflow split-and-deliver),
         # ``message_id`` is the LAST visible message id (so subsequent edits target
    ```

    ```diff
    @@ -3760,9 +3763,16 @@ async def _send_with_retry(
                 return result
     
             if is_network:
    -            # Retry with exponential backoff for transient errors
    +            # Retry with exponential backoff for transient errors.
    +            # Honor server-requested retry_after (e.g. Telegram FloodWait)
    +            # when present — it is authoritative over our backoff schedule.
    +            server_retry_after = result.retry_after
                 for attempt in range(1, max_retries + 1):
    -                delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 1)
    +                if server_retry_after is not None:
    +                    delay = server_retry_after + random.uniform(0, 1)
    +                    server_retry_after = None  # only honor once per send
    +                else:
    +                    delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 1)
                     logger.warning(
                         "[%s] Send failed (attempt %d/%d, retrying in %.1fs): %s",
                         self.name, attempt, max_retries, delay, error_str,
    ```

    ```diff
    @@ -3778,6 +3788,8 @@ async def _send_with_retry(
                         logger.info("[%s] Send succeeded on retry %d", self.name, attempt)
                         return result
                     error_str = result.error or ""
    +                if result.retry_after is not None:
    +                    server_retry_after = result.retry_after
                     if not (result.retryable or self._is_retryable_error(error_str)):
                         break  # error switched to non-transient — fall through to plain-text fallback
                 else:
    ```
  - `plugins/platforms/telegram/adapter.py`

    ```diff
    @@ -1378,6 +1378,15 @@ async def _try_send_rich(
                     _TimedOut = None
                 is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str
                 is_connect_timeout = self._looks_like_connect_timeout(exc)
    +            # Extract server-requested retry_after for flood control so the
    +            # base retry layer honors Telegram's backoff instead of its own
    +            # short exponential schedule.
    +            _retry_after = getattr(exc, "retry_after", None)
    +            if _retry_after is None:
    +                import re as _re
    +                _m = _re.search(r"retry\s+(?:in\s+)?(\d+)", err_str, _re.IGNORECASE)
    +                if _m:
    +                    _retry_after = float(_m.group(1))
                 logger.warning(
                     "[%s] sendRichMessage transient failure (no legacy resend): %s",
                     self.name, exc,
    ```

    ```diff
    @@ -1386,6 +1395,7 @@ async def _try_send_rich(
                     success=False,
                     error=str(exc),
                     retryable=(is_connect_timeout or not is_timeout),
    +                retry_after=_retry_after,
                 )
     
             message_id = None
    ```
  - `tests/gateway/test_send_retry.py`

    ```diff
    @@ -282,3 +282,57 @@ async def test_fallback_failure_logged_but_not_raised(self):
                 result = await adapter._send_with_retry("chat1", "hello", max_retries=2)
             assert not result.success
             assert len(adapter._send_calls) == 2  # original + fallback only
    +
    +
    +# ---------------------------------------------------------------------------
    +# _send_with_retry — retry_after honor
    +# ---------------------------------------------------------------------------
    +
    +class TestSendWithRetryAfter:
    +    @pytest.mark.asyncio
    +    async def test_retry_after_honored_on_first_retry(self):
    +        """When the initial result has retry_after, the first retry waits that long."""
    +        adapter = _StubAdapter()
    +        adapter._send_results = [
    +            SendResult(success=False, error="Flood control exceeded. Retry in 37 seconds",
    +                       retryable=True, retry_after=37.0),
    +            SendResult(success=True, message_id="ok"),
    +        ]
    +        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
    +            result = await adapter._send_with_retry("chat1", "hello", max_retries=2, base_delay=2.0)
    +        assert result.success
    +        # First sleep should use retry_after (~37s + jitter), not base_delay (~2s)
    +        first_sleep = mock_sleep.call_args_list[0][0][0]
    +        assert first_sleep >= 36.0  # 37 - 1 (max jitter)
    +
    +    @pytest.mark.asyncio
    +    async def test_retry_after_from_subsequent_result(self):
    +        """If a retry itself returns retry_after, the next retry honors it."""
    +        adapter = _StubAdapter()
    +        adapter._send_results = [
    +            SendResult(success=False, error="ConnectError", retryable=True),
    +            SendResult(success=False, error="Flood control exceeded. Retry in 30 seconds",
    +                       retryable=True, retry_after=30.0),
    +            SendResult(success=True, message_id="ok"),
    +        ]
    +        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
    +            result = await adapter._send_with_retry("chat1", "hello", max_retries=3, base_delay=2.0)
    +        assert result.success
    +        # Second sleep should use the retry_after from the second result
    +        second_sleep = mock_sleep.call_args_list[1][0][0]
    +        assert second_sleep >= 29.0  # 30 - 1 (max jitter)
    +
    +    @pytest.mark.asyncio
    +    async def test_no_retry_after_uses_default_backoff(self):
    +        """Without retry_after, default exponential backoff is used."""
    +        adapter = _StubAdapter()
    +        adapter._send_results = [
    +            SendResult(success=False, error="ConnectError", retryable=True),
    +            SendResult(success=True, message_id="ok"),
    +        ]
    +        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
    +            result = await adapter._send_with_retry("chat1", "hello", max_retries=2, base_delay=2.0)
    +        assert result.success
    +        # Sleep should be ~2s (base_delay * 2^0 + jitter), NOT 37s
    +        first_sleep = mock_sleep.call_args_list[0][0][0]
    +        assert first_sleep < 5.0
    ```

## Fix status

- Merged to main in PR #52143 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-24`.
- Subsystem: `gateway`
