b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
85 lines
2.9 KiB
Python
85 lines
2.9 KiB
Python
"""Shared HTTP client factory for long-lived platform adapters.
|
|
|
|
Gateway messaging platforms (QQ Bot, Feishu, WeCom, DingTalk, Signal,
|
|
BlueBubbles, WeCom-callback) keep a persistent ``httpx.AsyncClient``
|
|
alive for the adapter's lifetime. That amortises TLS/connection setup
|
|
across many API calls, but it also means the process's file-descriptor
|
|
pressure is sensitive to how aggressively the pool recycles idle keep-
|
|
alive connections.
|
|
|
|
httpx's default ``keepalive_expiry`` is 5 seconds. On macOS behind
|
|
Cloudflare Warp (and other transparent proxies), peer-initiated FIN can
|
|
sit in ``CLOSE_WAIT`` longer than that before the local socket actually
|
|
drains — which, multiplied across 7 long-lived adapters plus the LLM
|
|
client and MCP clients, walks straight into the default 256 fd limit.
|
|
See #18451.
|
|
|
|
``platform_httpx_limits()`` returns a tighter ``httpx.Limits`` the
|
|
adapter factories use instead of the httpx default. The values chosen:
|
|
|
|
* ``max_keepalive_connections=10`` — plenty for any single adapter;
|
|
platform APIs rarely parallelise beyond this.
|
|
* ``keepalive_expiry=2.0`` — close idle sockets aggressively so a
|
|
proxy's lingering CLOSE_WAIT window can't starve the process.
|
|
|
|
Override via ``HERMES_GATEWAY_HTTPX_KEEPALIVE_EXPIRY`` /
|
|
``HERMES_GATEWAY_HTTPX_MAX_KEEPALIVE`` env vars when tuning under load.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
try:
|
|
import httpx
|
|
except ImportError: # pragma: no cover — optional dep
|
|
httpx = None # type: ignore[assignment]
|
|
|
|
|
|
_DEFAULT_KEEPALIVE_EXPIRY_S = 2.0
|
|
_DEFAULT_MAX_KEEPALIVE = 10
|
|
|
|
|
|
def platform_httpx_limits() -> "httpx.Limits | None":
|
|
"""Return ``httpx.Limits`` tuned for persistent platform-adapter clients.
|
|
|
|
Returns ``None`` when httpx isn't importable, so callers can fall
|
|
back to httpx's built-in default without a hard dependency on this
|
|
helper being reachable.
|
|
"""
|
|
if httpx is None:
|
|
return None
|
|
|
|
def _env_float(name: str, default: float) -> float:
|
|
raw = os.environ.get(name, "").strip()
|
|
if not raw:
|
|
return default
|
|
try:
|
|
val = float(raw)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return val if val > 0 else default
|
|
|
|
def _env_int(name: str, default: int) -> int:
|
|
raw = os.environ.get(name, "").strip()
|
|
if not raw:
|
|
return default
|
|
try:
|
|
val = int(raw)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return val if val > 0 else default
|
|
|
|
keepalive_expiry = _env_float(
|
|
"HERMES_GATEWAY_HTTPX_KEEPALIVE_EXPIRY", _DEFAULT_KEEPALIVE_EXPIRY_S
|
|
)
|
|
max_keepalive = _env_int(
|
|
"HERMES_GATEWAY_HTTPX_MAX_KEEPALIVE", _DEFAULT_MAX_KEEPALIVE
|
|
)
|
|
|
|
return httpx.Limits(
|
|
max_keepalive_connections=max_keepalive,
|
|
# Leave max_connections at httpx default (100) — plenty of headroom.
|
|
keepalive_expiry=keepalive_expiry,
|
|
)
|