{"repo":"NousResearch/hermes-agent","public_id":"46762","display_label":"Telegram flood control retry_after handling fix","summary":"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.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52143,"merged_at":"2026-06-24T21:13:47+00:00"},"resolution":{"state":"resolved","by":"pr-52143"},"freshness":{"state":"verified","last_verified_at":"2026-06-24T21:13:47+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":46762,"pr_number":52143,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-24T21:13:47+00:00"}],"members":[{"kind":"issue","number":46762,"title":"Telegram sendRichMessage flood-control retry ignores server retry_after and drops final response","url":null,"state":"closed"},{"kind":"pr","number":46774,"title":"fix(gateway): honor server retry_after in _send_with_retry for Telegram flood control","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"404b06ac4fc307d868ea51ac656387641ef16b39","number":0},{"kind":"pr","value":"#46762","number":46762}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":52143,"title":"fix(gateway): honor server retry_after in Telegram flood control (#46762)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","plugins/platforms/telegram/adapter.py","tests/gateway/test_send_retry.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1743,6 +1743,9 @@ class SendResult:\n     # stream consumer can send the missing tail instead of marking a clipped\n     # response complete.\n     retryable: bool = False  # True for transient connection errors — base will retry automatically\n+    # Server-requested retry delay in seconds (e.g. Telegram FloodWait retry_after).\n+    # When present, _send_with_retry() honors this instead of its default backoff.\n+    retry_after: Optional[float] = None\n     # When the adapter had to split an oversized payload across multiple\n     # platform messages (e.g. Telegram edit_message overflow split-and-deliver),\n     # ``message_id`` is the LAST visible message id (so subsequent edits target","@@ -3760,9 +3763,16 @@ async def _send_with_retry(\n             return result\n \n         if is_network:\n-            # Retry with exponential backoff for transient errors\n+            # Retry with exponential backoff for transient errors.\n+            # Honor server-requested retry_after (e.g. Telegram FloodWait)\n+            # when present — it is authoritative over our backoff schedule.\n+            server_retry_after = result.retry_after\n             for attempt in range(1, max_retries + 1):\n-                delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 1)\n+                if server_retry_after is not None:\n+                    delay = server_retry_after + random.uniform(0, 1)\n+                    server_retry_after = None  # only honor once per send\n+                else:\n+                    delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 1)\n                 logger.warning(\n                     \"[%s] Send failed (attempt %d/%d, retrying in %.1fs): %s\",\n                     self.name, attempt, max_retries, delay, error_str,","@@ -3778,6 +3788,8 @@ async def _send_with_retry(\n                     logger.info(\"[%s] Send succeeded on retry %d\", self.name, attempt)\n                     return result\n                 error_str = result.error or \"\"\n+                if result.retry_after is not None:\n+                    server_retry_after = result.retry_after\n                 if not (result.retryable or self._is_retryable_error(error_str)):\n                     break  # error switched to non-transient — fall through to plain-text fallback\n             else:"]},{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -1378,6 +1378,15 @@ async def _try_send_rich(\n                 _TimedOut = None\n             is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or \"timed out\" in err_str\n             is_connect_timeout = self._looks_like_connect_timeout(exc)\n+            # Extract server-requested retry_after for flood control so the\n+            # base retry layer honors Telegram's backoff instead of its own\n+            # short exponential schedule.\n+            _retry_after = getattr(exc, \"retry_after\", None)\n+            if _retry_after is None:\n+                import re as _re\n+                _m = _re.search(r\"retry\\s+(?:in\\s+)?(\\d+)\", err_str, _re.IGNORECASE)\n+                if _m:\n+                    _retry_after = float(_m.group(1))\n             logger.warning(\n                 \"[%s] sendRichMessage transient failure (no legacy resend): %s\",\n                 self.name, exc,","@@ -1386,6 +1395,7 @@ async def _try_send_rich(\n                 success=False,\n                 error=str(exc),\n                 retryable=(is_connect_timeout or not is_timeout),\n+                retry_after=_retry_after,\n             )\n \n         message_id = None"]},{"file":"tests/gateway/test_send_retry.py","hunks":["@@ -282,3 +282,57 @@ async def test_fallback_failure_logged_but_not_raised(self):\n             result = await adapter._send_with_retry(\"chat1\", \"hello\", max_retries=2)\n         assert not result.success\n         assert len(adapter._send_calls) == 2  # original + fallback only\n+\n+\n+# ---------------------------------------------------------------------------\n+# _send_with_retry — retry_after honor\n+# ---------------------------------------------------------------------------\n+\n+class TestSendWithRetryAfter:\n+    @pytest.mark.asyncio\n+    async def test_retry_after_honored_on_first_retry(self):\n+        \"\"\"When the initial result has retry_after, the first retry waits that long.\"\"\"\n+        adapter = _StubAdapter()\n+        adapter._send_results = [\n+            SendResult(success=False, error=\"Flood control exceeded. Retry in 37 seconds\",\n+                       retryable=True, retry_after=37.0),\n+            SendResult(success=True, message_id=\"ok\"),\n+        ]\n+        with patch(\"asyncio.sleep\", new_callable=AsyncMock) as mock_sleep:\n+            result = await adapter._send_with_retry(\"chat1\", \"hello\", max_retries=2, base_delay=2.0)\n+        assert result.success\n+        # First sleep should use retry_after (~37s + jitter), not base_delay (~2s)\n+        first_sleep = mock_sleep.call_args_list[0][0][0]\n+        assert first_sleep >= 36.0  # 37 - 1 (max jitter)\n+\n+    @pytest.mark.asyncio\n+    async def test_retry_after_from_subsequent_result(self):\n+        \"\"\"If a retry itself returns retry_after, the next retry honors it.\"\"\"\n+        adapter = _StubAdapter()\n+        adapter._send_results = [\n+            SendResult(success=False, error=\"ConnectError\", retryable=True),\n+            SendResult(success=False, error=\"Flood control exceeded. Retry in 30 seconds\",\n+                       retryable=True, retry_after=30.0),\n+            SendResult(success=True, message_id=\"ok\"),\n+        ]\n+        with patch(\"asyncio.sleep\", new_callable=AsyncMock) as mock_sleep:\n+            result = await adapter._send_with_retry(\"chat1\", \"hello\", max_retries=3, base_delay=2.0)\n+        assert result.success\n+        # Second sleep should use the retry_after from the second result\n+        second_sleep = mock_sleep.call_args_list[1][0][0]\n+        assert second_sleep >= 29.0  # 30 - 1 (max jitter)\n+\n+    @pytest.mark.asyncio\n+    async def test_no_retry_after_uses_default_backoff(self):\n+        \"\"\"Without retry_after, default exponential backoff is used.\"\"\"\n+        adapter = _StubAdapter()\n+        adapter._send_results = [\n+            SendResult(success=False, error=\"ConnectError\", retryable=True),\n+            SendResult(success=True, message_id=\"ok\"),\n+        ]\n+        with patch(\"asyncio.sleep\", new_callable=AsyncMock) as mock_sleep:\n+            result = await adapter._send_with_retry(\"chat1\", \"hello\", max_retries=2, base_delay=2.0)\n+        assert result.success\n+        # Sleep should be ~2s (base_delay * 2^0 + jitter), NOT 37s\n+        first_sleep = mock_sleep.call_args_list[0][0][0]\n+        assert first_sleep < 5.0\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}