NousResearch/hermes-agent

httpx transport keepalive fix for streaming issues

Cluster 54049 · agent. Data as of

Nothing open on GitHub. Fix merged in #58766 on 5 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #58766. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items address a problem where custom socket_options transport caused issues (including dropped chunked streaming with OpenResty/DeepSeek), proposing replacement with httpx pool-level keepalive expiry configuration.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #58766 fix(agent): replace socket_options transport with httpx pool-level keepalive expiry (#54550 salvage)

    merged to main

    files: agent/process_bootstrap.py, run_agent.py, tests/run_agent/test_create_openai_client_proxy_env.py

    Show the diff
    • agent/process_bootstrap.py
      @@ -151,42 +151,51 @@ def build_keepalive_http_client(
           """Build an httpx client for OpenAI SDK calls with env-only proxy policy.
       
           Uses explicit ``HTTPS_PROXY`` / ``NO_PROXY`` env vars via
      -    ``_get_proxy_for_base_url``. A custom transport disables httpx's default
      -    ``trust_env`` path, so macOS system proxy settings from
      +    ``_get_proxy_for_base_url``. Plain no-proxy mounts disable httpx's default
      +    ``trust_env`` proxy path, so macOS system proxy settings from
           ``urllib.request.getproxies()`` (which omit the ExceptionsList) are not
           applied. Mirrors ``AIAgent._build_keepalive_http_client``.
       
      +    Connection lifecycle is managed at the HTTP pool layer
      +    (``keepalive_expiry=20.0`` reaps idle connections before reverse proxies'
      +    typical 30-60 s timeouts) instead of the former custom
      +    ``socket_options`` transport, which broke streaming behind reverse
      +    proxies (#54049, #12952) and stalled TLS handshakes by stripping
      +    ``TCP_NODELAY``.
      +
           ``verify`` is forwarded to httpx so auxiliary-client calls (compression,
           vision, web_extract, title generation, etc.) honor the same per-provider
           ``ssl_ca_cert`` / ``ssl_verify`` and ``HERMES_CA_BUNDLE`` settings the main
      -    client uses. It is passed on the ``HTTPTransport`` (which owns the SSL
      -    context when a custom transport is supplied) and, for the copilot branch
      -    that has no custom transport, on the client itself.
      +    client uses. It is passed on the client AND on the plain no-proxy mounts
      +    (a mounted transport owns the SSL context for its scheme).
           """
           try:
               import httpx
      -        import socket
       
      -        if "api.githubcopilot.com" in str(base_url or "").lower():
      -            client_cls = httpx.AsyncClient if async_mode else httpx.Client
      -            return client_cls(verify=verify)
      +        proxy = _get_proxy_for_base_url(base_url)
       
      -        sock_opts = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
      -        if hasattr(socket, "TCP_KEEPIDLE"):
      -            sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30))
      -            sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10))
      -            sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3))
      -        elif hasattr(socket, "TCP_KEEPALIVE"):
      -            sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 30))
      +        limits = httpx.Limits(
      +            max_keepalive_connections=20,
      +            max_connections=100,
      +            keepalive_expiry=20.0,
      +        )
      +        # Generous read=None for SSE streaming endpoints.
      +        timeout = httpx.Timeout(connect=15.0, read=None, write=15.0, pool=10.0)
       
      -        proxy = _get_proxy_for_base_url(base_url)
               transport_cls = httpx.AsyncHTTPTransport if async_mode else httpx.HTTPTransport
               client_cls = httpx.AsyncClient if async_mode else httpx.Client
      -        # verify lives on the transport: httpx ignores the client-level
      -        # ``verify`` when a custom ``transport=`` is supplied.
      +        mounts = {}
      +        if proxy is None:
      +            mounts = {
      +                "http://": transport_cls(verify=verify),
      +                "https://": transport_cls(verify=verify),
      +            }
               return client_cls(
      -            transport=transport_cls(socket_options=sock_opts, verify=verify),
      +            limits=limits,
      +            timeout=timeout,
                   proxy=proxy,
      +            mounts=mounts or None,
      +            verify=verify,
               )
           except Exception:
               return None
    • run_agent.py
      @@ -3896,30 +3896,70 @@ def _is_openai_client_closed(client: Any) -> bool:
       
           @staticmethod
           def _build_keepalive_http_client(base_url: str = "", *, verify: Any = True) -> Any:
      +        """Build an httpx.Client with proactive idle-connection reaping.
      +
      +        Previously this method injected a custom ``httpx.HTTPTransport``
      +        with ``socket_options`` (``SO_KEEPALIVE``, ``TCP_KEEPIDLE``, …) to
      +        prevent CLOSE-WAIT accumulation on long-lived connections (#10324).
      +
      +        That approach broke streaming for providers behind reverse proxies
      +        (OpenResty, Cloudflare, etc.) because the custom socket options
      +        conflict with the proxy's chunked-transfer handling (#54049,
      +        #12952).  It also stripped ``TCP_NODELAY``, stalling TLS handshakes
      +        and SSE encoding.
      +
      +        The fix moves connection lifecycle management from the socket layer
      +        to the HTTP pool layer: ``keepalive_expiry=20.0`` tells httpx to
      +        close idle pooled connections *before* a reverse proxy's typical
      +        30–60 s timeout drops them, preventing CLOSE-WAIT accumulation
      +        without modifying socket options.  The default httpx transport
      +        preserves OS TCP defaults (including ``TCP_NODELAY``).
      +
      +        ``verify`` carries per-provider ``ssl_ca_cert`` / ``ssl_verify`` and
      +        ``HERMES_CA_BUNDLE`` settings.  It is passed on the client AND on
      +        the plain no-proxy mounts (a mounted transport owns the SSL context
      +        for its scheme).
      +        """
               try:
                   import httpx as _httpx
      -            import socket as _socket
      -
      -            if "api.githubcopilot.com" in str(base_url or "").lower():
      -                return _httpx.Client(verify=verify)
      -
      -            _sock_opts = [(_socket.SOL_SOCKET, _socket.SO_KEEPALIVE, 1)]
      -            if hasattr(_socket, "TCP_KEEPIDLE"):
      -                _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPIDLE, 30))
      -                _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPINTVL, 10))
      -                _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPCNT, 3))
      -            elif hasattr(_socket, "TCP_KEEPALIVE"):
      -                _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPALIVE, 30))
      -            # When a custom transport is provided, httpx won't auto-read proxy
      -            # from env vars (allow_env_proxies = trust_env and transport is None).
      -            # Explicitly read proxy settings while still honoring NO_PROXY for
      -            # loopback / local endpoints such as a locally hosted sub2api.
      +
      +            # Explicitly read proxy settings so requests route through
      +            # HTTP_PROXY / HTTPS_PROXY / NO_PROXY correctly.
                   _proxy = _get_proxy_for_base_url(base_url)
      -            # verify lives on the transport: httpx ignores the client-level
      -            # ``verify`` when a custom ``transport=`` is supplied.
      +
      +            # Proactive pool reaping: close idle connections at 20 s,
      +            # before reverse proxies (30–60 s typical) send FIN and
      +            # cause CLOSE-WAIT accumulation.
      +            _limits = _httpx.Limits(
      +                max_keepalive_connections=20,
      +                max_connections=100,
      +                keepalive_expiry=20.0,
      +            )
      +
      +            # Timeouts: generous read=None for SSE streaming endpoints.
      +            _timeout = _httpx.Timeout(
      +                connect=15.0,
      +                read=None,
      +                write=15.0,
      +                pool=10.0,
      +            )
      +
      +            # When _proxy is None (NO_PROXY bypass or no proxy configured),
      +            # mount plain transports to prevent httpx from reading env proxy
      +            # vars and creating an HTTPProxy mount that would bypass our
      +            # NO_PROXY resolution.
      +            _mounts = {}
      +            if _proxy is None:
      +                _mounts = {
      +                    "http://": _httpx.HTTPTransport(verify=verify),
      +                    "https://": _httpx.HTTPTransport(verify=verify),
      +                }
                   return _httpx.Client(
      -                transport=_httpx.HTTPTransport(socket_options=_sock_opts, verify=verify),
      +                limits=_limits,
      +                timeout=_timeout,
                       proxy=_proxy,
      +                mounts=_mounts or None,
      +                verify=verify,
                   )
               except Exception:
                   return None
    • tests/run_agent/test_create_openai_client_proxy_env.py
      @@ -1,22 +1,14 @@
       """Regression guard: _create_openai_client must honor HTTP(S)_PROXY env vars.
       
      -When #11277 re-landed TCP keepalives, ``_create_openai_client`` began passing
      -a custom ``transport=httpx.HTTPTransport(...)`` to ``httpx.Client``. httpx only
      -auto-reads ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` when
      -``transport is None`` (see ``Client.__init__``:
      -``allow_env_proxies = trust_env and transport is None``). As a result, proxy
      -env vars were silently ignored for the primary chat client, causing requests
      -to bypass local proxies (Clash, corporate egress, etc.) and hit upstream
      -directly from the raw interface.
      -
      -For users on WSL2 + Clash TUN this surfaced as Cloudflare ``cf-mitigated:
      -challenge`` 403s against ``chatgpt.com/backend-api/codex`` once they upgraded
      -past #11277. The fix forwards the proxy URL explicitly to ``httpx.Client``
      -while keeping the keepalive-enabled transport in place.
      -
      -This test pins that the constructed ``httpx.Client`` mounts an ``HTTPProxy``
      -pool when a proxy env var is set, AND that the socket-level keepalive
      -transport is still installed on the no-proxy default path.
      +The keepalive client now uses ``httpx.Limits(keepalive_expiry=20.0)``
      +instead of a custom ``httpx.HTTPTransport(socket_options=...)`` to
      +prevent CLOSE-WAIT accumulation.  This avoids breaking streaming for
      +providers behind reverse proxies (#54049, #12952) while still reaping
      +idle connections before a proxy's timeout drops them.
      +
      +This test pins that the constructed ``httpx.Client`` mounts an
      +``HTTPProxy`` pool when a proxy env var is set, and that no
      +custom socket-options transport is used (default httpx transport).
       """
       from unittest.mock import patch
       
      @@ -117,8 +109,8 @@ def test_create_openai_client_routes_via_proxy_when_env_set(mock_openai, monkeyp
       
       @patch("run_agent.OpenAI")
       def test_create_openai_client_no_proxy_when_env_unset(mock_openai, monkeypatch):
      -    """Without proxy env vars, the keepalive transport must still be installed
      -    and no HTTPProxy mount should exist."""
      +    """Without proxy env vars, no HTTPProxy mount should exist and
      +    no custom socket-options transport should be installed."""
           for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
                       "https_proxy", "http_proxy", "all_proxy"):
               monkeypatch.delenv(key, raising=False)
      @@ -147,7 +139,8 @@ def test_create_openai_client_no_proxy_when_env_unset(mock_openai, monkeypatch):
       
       @patch("run_agent.OpenAI")
       def test_create_openai_client_uses_plain_httpx_client_for_copilot(mock_openai, monkeypatch):
      -    """Copilot Claude chat-completions rejects the custom socket-options transport."""
      +    """All providers now use a standard httpx.Client (no custom socket-options
      +    transport) so Copilot Claude chat-completions works without a host bypass."""
           for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
                       "https_proxy", "http_proxy", "all_proxy"):
               monkeypatch.delenv(key, raising=False)

1 PR closed without merging

From the close comments: merged via another change 1

  • #54550 fix(agent): replace custom socket_options transport with httpx pool-level keepalive expiry · merged via another change → 51c1ba6, #58766
Closed issues · 1
  • Closed#54049DeepSeek: OpenResty drops chunked streaming with custom httpx transport
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#58766
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
5 Jul 2026

For your agent

The same facts, typed.