mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
The per-IP httpx transports were built once in __init__ and never torn down. A connect that reached ESTABLISHED and was then closed by the peer left its socket in CLOSE_WAIT inside the pool, and the failure path only logged and continued — so the poisoned pool was retained and leaked one descriptor per retry. With DNS for api.telegram.org failing, every poll fell through to the seed IP and leaked another fd every ~2.5s. The bot gateway reached 177 CLOSE_WAIT sockets against launchd's 256 soft limit and wedged: accept() on the gateway port, config reads and DNS resolution all failed with EMFILE, which in turn made the primary path fail and fed the loop. Build fallback transports lazily and discard them on a retryable connect failure, and bound every pool at 8 connections (httpx defaults to 100, so two seed IPs plus primary could alone exceed the fd ceiling). Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""Telegram-specific network helpers.
|
|
|
|
Provides a hostname-preserving fallback transport for networks where
|
|
api.telegram.org resolves to an endpoint that is unreachable from the current
|
|
host. The transport keeps the logical request host and TLS SNI as
|
|
api.telegram.org while retrying the TCP connection against one or more fallback
|
|
IPv4 addresses.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ipaddress
|
|
import logging
|
|
import socket
|
|
from typing import Iterable, Optional
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_TELEGRAM_API_HOST = "api.telegram.org"
|
|
|
|
# DNS-over-HTTPS providers used to discover Telegram API IPs that may differ
|
|
# from the (potentially unreachable) IP returned by the local system resolver.
|
|
_DOH_TIMEOUT = 4.0 # seconds — bounded so connect() isn't noticeably delayed
|
|
|
|
_DOH_PROVIDERS: list[dict] = [
|
|
{
|
|
"url": "https://dns.google/resolve",
|
|
"params": {"name": _TELEGRAM_API_HOST, "type": "A"},
|
|
"headers": {},
|
|
},
|
|
{
|
|
"url": "https://cloudflare-dns.com/dns-query",
|
|
"params": {"name": _TELEGRAM_API_HOST, "type": "A"},
|
|
"headers": {"Accept": "application/dns-json"},
|
|
},
|
|
]
|
|
|
|
# Last-resort IPs when DoH is also blocked. These are stable Telegram Bot API
|
|
# endpoints in the 149.154.160.0/20 block (same seed used by OpenClaw).
|
|
_SEED_FALLBACK_IPS: list[str] = ["149.154.166.110", "149.154.167.220"]
|
|
|
|
|
|
def _resolve_proxy_url(target_hosts=None) -> str | None:
|
|
# Delegate to shared implementation (env vars + macOS system proxy detection)
|
|
from gateway.platforms.base import resolve_proxy_url
|
|
return resolve_proxy_url("TELEGRAM_PROXY", target_hosts=target_hosts)
|
|
|
|
|
|
class TelegramFallbackTransport(httpx.AsyncBaseTransport):
|
|
"""Retry Telegram Bot API requests via fallback IPs while preserving TLS/SNI.
|
|
|
|
Requests continue to target https://api.telegram.org/... logically, but on
|
|
connect failures the underlying TCP connection is retried against a known
|
|
reachable IP. This is effectively the programmatic equivalent of
|
|
``curl --resolve api.telegram.org:443:<ip>``.
|
|
"""
|
|
|
|
# Bound every pool. httpx defaults to 100 connections per pool, so a wedged
|
|
# endpoint plus the seed IPs can outgrow the process file-descriptor limit
|
|
# on its own (#63311).
|
|
_POOL_LIMITS = httpx.Limits(max_connections=8, max_keepalive_connections=4)
|
|
|
|
def __init__(self, fallback_ips: Iterable[str], **transport_kwargs):
|
|
self._fallback_ips = list(dict.fromkeys(_normalize_fallback_ips(fallback_ips)))
|
|
proxy_url = _resolve_proxy_url(target_hosts=[_TELEGRAM_API_HOST, *self._fallback_ips])
|
|
if proxy_url and "proxy" not in transport_kwargs:
|
|
transport_kwargs["proxy"] = proxy_url
|
|
transport_kwargs.setdefault("limits", self._POOL_LIMITS)
|
|
self._transport_kwargs = transport_kwargs
|
|
self._primary = httpx.AsyncHTTPTransport(**transport_kwargs)
|
|
# Built on demand and discarded on failure — see _reset_fallback.
|
|
self._fallbacks: dict[str, httpx.AsyncHTTPTransport] = {}
|
|
self._fallback_lock = asyncio.Lock()
|
|
self._sticky_ip: Optional[str] = None
|
|
self._sticky_lock = asyncio.Lock()
|
|
|
|
async def _get_fallback(self, ip: str) -> httpx.AsyncHTTPTransport:
|
|
async with self._fallback_lock:
|
|
transport = self._fallbacks.get(ip)
|
|
if transport is None:
|
|
transport = httpx.AsyncHTTPTransport(**self._transport_kwargs)
|
|
self._fallbacks[ip] = transport
|
|
return transport
|
|
|
|
async def _reset_fallback(self, ip: str) -> None:
|
|
"""Discard a failed fallback pool so its dead sockets are released.
|
|
|
|
A connect that reaches ESTABLISHED and is then closed by the peer leaves
|
|
its socket in CLOSE_WAIT inside the pool. Retaining the poisoned pool
|
|
leaks one descriptor per retry until the process hits its file limit and
|
|
can no longer accept connections or resolve DNS (#63311).
|
|
"""
|
|
async with self._fallback_lock:
|
|
transport = self._fallbacks.pop(ip, None)
|
|
if transport is None:
|
|
return
|
|
try:
|
|
await transport.aclose()
|
|
except Exception as exc: # closing a broken pool must never mask the real error
|
|
logger.debug("[Telegram] Error closing fallback transport %s: %s", ip, exc)
|
|
|
|
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
if request.url.host != _TELEGRAM_API_HOST or not self._fallback_ips:
|
|
return await self._primary.handle_async_request(request)
|
|
|
|
sticky_ip = self._sticky_ip
|
|
attempt_order: list[Optional[str]] = [sticky_ip] if sticky_ip else [None]
|
|
if sticky_ip:
|
|
attempt_order.append(None) # retry primary DNS after sticky failure
|
|
for ip in self._fallback_ips:
|
|
if ip != sticky_ip:
|
|
attempt_order.append(ip)
|
|
|
|
last_error: Exception | None = None
|
|
for ip in attempt_order:
|
|
candidate = request if ip is None else _rewrite_request_for_ip(request, ip)
|
|
transport = self._primary if ip is None else await self._get_fallback(ip)
|
|
try:
|
|
response = await transport.handle_async_request(candidate)
|
|
if ip is not None and self._sticky_ip != ip:
|
|
async with self._sticky_lock:
|
|
if self._sticky_ip != ip:
|
|
self._sticky_ip = ip
|
|
logger.warning(
|
|
"[Telegram] Primary api.telegram.org path unreachable; using sticky fallback IP %s",
|
|
ip,
|
|
)
|
|
return response
|
|
except Exception as exc:
|
|
last_error = exc
|
|
if not _is_retryable_connect_error(exc):
|
|
raise
|
|
if ip is not None and ip == self._sticky_ip:
|
|
async with self._sticky_lock:
|
|
if self._sticky_ip == ip:
|
|
self._sticky_ip = None
|
|
logger.warning(
|
|
"[Telegram] Sticky fallback IP %s failed; resetting to primary DNS path",
|
|
ip,
|
|
)
|
|
if ip is None:
|
|
logger.warning(
|
|
"[Telegram] Primary api.telegram.org connection failed (%s); trying fallback IPs %s",
|
|
exc,
|
|
", ".join(self._fallback_ips),
|
|
)
|
|
continue
|
|
logger.warning("[Telegram] Fallback IP %s failed: %s", ip, exc)
|
|
await self._reset_fallback(ip)
|
|
continue
|
|
|
|
if last_error is None:
|
|
raise RuntimeError("All Telegram fallback IPs exhausted but no error was recorded")
|
|
raise last_error
|
|
|
|
async def aclose(self) -> None:
|
|
await self._primary.aclose()
|
|
async with self._fallback_lock:
|
|
transports = list(self._fallbacks.values())
|
|
self._fallbacks.clear()
|
|
for transport in transports:
|
|
await transport.aclose()
|
|
|
|
|
|
def _normalize_fallback_ips(values: Iterable[str]) -> list[str]:
|
|
normalized: list[str] = []
|
|
for value in values:
|
|
raw = str(value).strip()
|
|
if not raw:
|
|
continue
|
|
try:
|
|
addr = ipaddress.ip_address(raw)
|
|
except ValueError:
|
|
logger.warning("Ignoring invalid Telegram fallback IP: %r", raw)
|
|
continue
|
|
if addr.version != 4:
|
|
logger.warning("Ignoring non-IPv4 Telegram fallback IP: %s", raw)
|
|
continue
|
|
if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_unspecified:
|
|
logger.warning("Ignoring private/internal Telegram fallback IP: %s", raw)
|
|
continue
|
|
normalized.append(str(addr))
|
|
return normalized
|
|
|
|
|
|
def parse_fallback_ip_env(value: str | None) -> list[str]:
|
|
if not value:
|
|
return []
|
|
parts = [part.strip() for part in value.split(",")]
|
|
return _normalize_fallback_ips(parts)
|
|
|
|
|
|
def _resolve_system_dns() -> set[str]:
|
|
"""Return the IPv4 addresses that the OS resolver gives for api.telegram.org."""
|
|
try:
|
|
results = socket.getaddrinfo(_TELEGRAM_API_HOST, 443, socket.AF_INET)
|
|
return {addr[4][0] for addr in results}
|
|
except Exception:
|
|
return set()
|
|
|
|
|
|
async def _query_doh_provider(
|
|
client: httpx.AsyncClient, provider: dict
|
|
) -> list[str]:
|
|
"""Query one DoH provider and return A-record IPs."""
|
|
try:
|
|
resp = await client.get(
|
|
provider["url"], params=provider["params"], headers=provider["headers"]
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
ips: list[str] = []
|
|
for answer in data.get("Answer", []):
|
|
if answer.get("type") != 1: # A record
|
|
continue
|
|
raw = answer.get("data", "").strip()
|
|
try:
|
|
ipaddress.ip_address(raw)
|
|
ips.append(raw)
|
|
except ValueError:
|
|
continue
|
|
return ips
|
|
except Exception as exc:
|
|
logger.debug("DoH query to %s failed: %s", provider["url"], exc)
|
|
return []
|
|
|
|
|
|
async def discover_fallback_ips() -> list[str]:
|
|
"""Auto-discover Telegram API IPs via DNS-over-HTTPS.
|
|
|
|
Resolves api.telegram.org through Google and Cloudflare DoH and returns all
|
|
unique A records. IPs that match the local system resolver are kept rather
|
|
than excluded: in many networks the system-DNS IP is the most reliable path
|
|
to api.telegram.org and a transient primary-path failure should be retried
|
|
against the same address via the IP-rewrite path before the seed list is
|
|
consulted (#14520). Falls back to a hardcoded seed list only when DoH
|
|
yields no usable answers.
|
|
"""
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(_DOH_TIMEOUT)) as client:
|
|
doh_tasks = [_query_doh_provider(client, p) for p in _DOH_PROVIDERS]
|
|
system_dns_task = asyncio.ensure_future(asyncio.to_thread(_resolve_system_dns))
|
|
results = await asyncio.gather(*doh_tasks, return_exceptions=True)
|
|
|
|
# The system-resolver leg runs socket.getaddrinfo in a worker thread with
|
|
# no timeout of its own — a wedged OS resolver (broken VPN/DNS) can sit for
|
|
# minutes. Its result only feeds the no-usable-answers log line below, so
|
|
# it must never gate discovery: bound it and move on (#63309). The DoH legs
|
|
# are already bounded by the client timeout above.
|
|
system_ips: set[str] = set()
|
|
try:
|
|
system_result = await asyncio.wait_for(system_dns_task, timeout=_DOH_TIMEOUT)
|
|
if isinstance(system_result, set):
|
|
system_ips = system_result
|
|
except Exception:
|
|
logger.debug("System-DNS resolution for %s did not complete in time", _TELEGRAM_API_HOST)
|
|
|
|
doh_ips: list[str] = []
|
|
for r in results:
|
|
if isinstance(r, list):
|
|
doh_ips.extend(r)
|
|
|
|
# Deduplicate preserving order
|
|
seen: set[str] = set()
|
|
candidates: list[str] = []
|
|
for ip in doh_ips:
|
|
if ip not in seen:
|
|
seen.add(ip)
|
|
candidates.append(ip)
|
|
|
|
# Validate through existing normalization
|
|
validated = _normalize_fallback_ips(candidates)
|
|
|
|
if validated:
|
|
logger.debug("Discovered Telegram fallback IPs via DoH: %s", ", ".join(validated))
|
|
return validated
|
|
|
|
logger.info(
|
|
"DoH discovery yielded no usable IPs (system DNS: %s); using seed fallback IPs %s",
|
|
", ".join(system_ips) or "unknown",
|
|
", ".join(_SEED_FALLBACK_IPS),
|
|
)
|
|
return list(_SEED_FALLBACK_IPS)
|
|
|
|
|
|
def _rewrite_request_for_ip(request: httpx.Request, ip: str) -> httpx.Request:
|
|
original_host = request.url.host or _TELEGRAM_API_HOST
|
|
url = request.url.copy_with(host=ip)
|
|
headers = request.headers.copy()
|
|
headers["host"] = original_host
|
|
extensions = dict(request.extensions)
|
|
extensions["sni_hostname"] = original_host
|
|
return httpx.Request(
|
|
method=request.method,
|
|
url=url,
|
|
headers=headers,
|
|
stream=request.stream,
|
|
extensions=extensions,
|
|
)
|
|
|
|
|
|
def _is_retryable_connect_error(exc: Exception) -> bool:
|
|
return isinstance(exc, (httpx.ConnectTimeout, httpx.ConnectError))
|