{"repo":"NousResearch/hermes-agent","public_id":"35610","display_label":"Telegram pool timeout retry and fallback handling","summary":"These members address a bug where Telegram send operations silently fail on HTTP connection pool timeouts, with proposed fixes involving retry logic and fallback mechanisms when the pool is exhausted.","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":35664,"merged_at":"2026-05-31T05:58:16+00:00"},"resolution":{"state":"resolved","by":"pr-35664"},"freshness":{"state":"verified","last_verified_at":"2026-05-31T05:58:16+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":35610,"pr_number":35664,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-31T05:58:16+00:00"}],"members":[{"kind":"issue","number":35610,"title":"[Bug] Telegram: Pool timeout causes silent send failures — no retry or fallback on exhausted general pool","url":null,"state":"closed"},{"kind":"pr","number":35623,"title":"[codex] fix(telegram): retry pool timeout via fresh request fallback","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"dc4de143778ab2e156063513fe64d6b41a675860","number":0},{"kind":"commit","value":"7e2ca7f68da63a29e1695b5e24901cc57e32f2ac","number":0},{"kind":"pr","value":"#35664","number":35664}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":35664,"title":"fix(telegram): retry on httpx pool timeout instead of dropping the send","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/telegram.py","tests/gateway/test_telegram_thread_fallback.py"],"fix_hunks":[{"file":"gateway/platforms/telegram.py","hunks":["@@ -840,6 +840,41 @@ def _looks_like_connect_timeout(error: Exception) -> bool:\n                 stack.append(context)\n         return False\n \n+    @staticmethod\n+    def _looks_like_pool_timeout(error: Exception) -> bool:\n+        \"\"\"Return True when a Telegram TimedOut wraps an httpx pool timeout.\n+\n+        PTB converts ``httpx.PoolTimeout`` into ``telegram.error.TimedOut`` with\n+        a message that explicitly states the request was *not* sent\n+        (``\"Pool timeout: All connections in the connection pool are occupied.\n+        Request was *not* sent to Telegram.\"``). Because the request never left\n+        the process, re-sending is safe and cannot duplicate -- the opposite of\n+        a generic TimedOut, which may have reached Telegram. We match the\n+        wrapped ``httpx.PoolTimeout`` class as well as the message string so the\n+        check survives PTB message-wording changes.\n+        \"\"\"\n+        seen: set[int] = set()\n+        stack: list[BaseException] = [error]\n+        while stack:\n+            cur = stack.pop()\n+            ident = id(cur)\n+            if ident in seen:\n+                continue\n+            seen.add(ident)\n+            name = cur.__class__.__name__.lower()\n+            text = str(cur).lower()\n+            if \"pooltimeout\" in name or \"pool timeout\" in text or (\n+                \"connection pool\" in text and \"occupied\" in text\n+            ):\n+                return True\n+            cause = getattr(cur, \"__cause__\", None)\n+            context = getattr(cur, \"__context__\", None)\n+            if cause is not None:\n+                stack.append(cause)\n+            if context is not None:\n+                stack.append(context)\n+        return False\n+\n     def _coerce_bool_extra(self, key: str, default: bool = False) -> bool:\n         value = self.config.extra.get(key) if getattr(self.config, \"extra\", None) else None\n         if value is None:","@@ -2001,11 +2036,15 @@ async def send(\n                         # TimedOut is also a subclass of NetworkError. A\n                         # generic timeout may have reached Telegram, so don't\n                         # retry; a wrapped ConnectTimeout means no connection\n-                        # was established, so retrying is safe.\n+                        # was established, so retrying is safe. A pool timeout\n+                        # (httpx pool exhausted) is explicitly \"not sent to\n+                        # Telegram\" -- retrying through the loop is safe and\n+                        # prevents silent drops when the pool frees up.\n                         if (\n                             _TimedOut\n                             and isinstance(send_err, _TimedOut)\n                             and not self._looks_like_connect_timeout(send_err)\n+                            and not self._looks_like_pool_timeout(send_err)\n                         ):\n                             raise\n                         if _send_attempt < 2:","@@ -2065,12 +2104,14 @@ async def send(\n                 return SendResult(success=False, error=\"message_too_long\")\n             # TimedOut usually means the request may have reached Telegram —\n             # mark as non-retryable so _send_with_retry() doesn't re-send.\n-            # Exception: wrapped ConnectTimeout, where no connection was\n-            # established; retrying is safe and prevents silent drops.\n+            # Exceptions: a wrapped ConnectTimeout (no connection established)\n+            # and an httpx pool timeout (request explicitly not sent) -- both\n+            # are safe to re-send and must not be silently dropped.\n             _to = locals().get(\"_TimedOut\")\n             is_timeout = (_to and isinstance(e, _to)) or \"timed out\" in err_str\n             is_connect_timeout = self._looks_like_connect_timeout(e)\n-            return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or not is_timeout))\n+            is_pool_timeout = self._looks_like_pool_timeout(e)\n+            return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or is_pool_timeout or not is_timeout))\n \n     async def send_or_update_status(\n         self,"]},{"file":"tests/gateway/test_telegram_thread_fallback.py","hunks":["@@ -1278,6 +1278,60 @@ async def mock_send_message(**kwargs):\n     assert attempt[0] == 3\n \n \n[redacted:email]\n+async def test_send_retries_pool_timeout():\n+    \"\"\"Retry TimedOut when it is an httpx pool-timeout (request not sent).\n+\n+    PTB wraps ``httpx.PoolTimeout`` into ``TimedOut`` with a message that\n+    explicitly states the request was *not* sent to Telegram. Re-sending is\n+    safe and prevents a silent drop when the pool frees up.\n+    \"\"\"\n+    adapter = _make_adapter()\n+\n+    attempt = [0]\n+\n+    async def mock_send_message(**kwargs):\n+        attempt[0] += 1\n+        if attempt[0] < 3:\n+            raise FakeTimedOut(\n+                \"Pool timeout: All connections in the connection pool are \"\n+                \"occupied. Request was *not* sent to Telegram. Consider \"\n+                \"adjusting the connection pool size or the pool timeout.\"\n+            )\n+        return SimpleNamespace(message_id=202)\n+\n+    adapter._bot = SimpleNamespace(send_message=mock_send_message)\n+\n+    result = await adapter.send(chat_id=\"123\", content=\"test message\")\n+\n+    assert result.success is True\n+    assert result.message_id == \"202\"\n+    assert attempt[0] == 3\n+\n+\n[redacted:email]\n+async def test_send_marks_pool_timeout_retryable_after_exhaustion():\n+    \"\"\"Pool timeout that never clears stays retryable for outer retry handling.\"\"\"\n+    adapter = _make_adapter()\n+\n+    attempt = [0]\n+\n+    async def mock_send_message(**kwargs):\n+        attempt[0] += 1\n+        raise FakeTimedOut(\n+            \"Pool timeout: All connections in the connection pool are occupied. \"\n+            \"Request was *not* sent to Telegram.\"\n+        )\n+\n+    adapter._bot = SimpleNamespace(send_message=mock_send_message)\n+\n+    result = await adapter.send(chat_id=\"123\", content=\"test message\")\n+\n+    assert result.success is False\n+    assert result.retryable is True\n+    assert attempt[0] == 3\n+\n+\n @pytest.mark.asyncio\n async def test_thread_fallback_only_fires_once():\n     \"\"\"After clearing thread_id, subsequent chunks should also use None.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}