186 lines
7.3 KiB
Python
186 lines
7.3 KiB
Python
"""Keepalive httpx clients share one HTTPTransport per (verify, proxy) identity.
|
|
|
|
Every AIAgent (and every delegated child) gets its own ``httpx.Client`` — the
|
|
#10933 contract that closing one client must never poison the next. What is
|
|
shared underneath is the connection pool + SSL context, so a fan-out of N
|
|
children no longer holds N TLS socket sets to the same provider.
|
|
"""
|
|
|
|
import ssl
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
import certifi
|
|
import httpx
|
|
import pytest
|
|
|
|
from agent import process_bootstrap
|
|
from agent.agent_runtime_helpers import _iter_pool_sockets, force_close_tcp_sockets
|
|
from agent.process_bootstrap import build_keepalive_http_client
|
|
|
|
|
|
@pytest.fixture
|
|
def no_proxy_env(monkeypatch):
|
|
for name in (
|
|
"HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
|
|
"https_proxy", "http_proxy", "all_proxy", "NO_PROXY", "no_proxy",
|
|
):
|
|
monkeypatch.delenv(name, raising=False)
|
|
process_bootstrap.close_shared_transports()
|
|
yield
|
|
process_bootstrap.close_shared_transports()
|
|
|
|
|
|
class _Handler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1" # keep-alive so pooled connections persist
|
|
|
|
def do_GET(self): # noqa: N802
|
|
body = b"ok"
|
|
self.send_response(200)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def local_server():
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
|
server.daemon_threads = True
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield f"http://127.0.0.1:{server.server_address[1]}"
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
|
|
|
|
def _inner(client, scheme="https://"):
|
|
mount = next(t for pat, t in client._mounts.items() if str(pat.pattern) == scheme)
|
|
return mount._inner
|
|
|
|
|
|
def test_same_identity_clients_share_transport_but_not_client(no_proxy_env):
|
|
a = build_keepalive_http_client("https://api.example.com/v1")
|
|
b = build_keepalive_http_client("https://api.example.com/v1")
|
|
assert isinstance(a, httpx.Client) and isinstance(b, httpx.Client)
|
|
assert a is not b
|
|
assert _inner(a) is _inner(b)
|
|
assert _inner(a, "http://") is _inner(b, "http://")
|
|
# The per-client view is distinct, so each client has its own close state.
|
|
assert a._mounts is not b._mounts
|
|
a.close()
|
|
b.close()
|
|
|
|
|
|
def test_closing_one_client_leaves_sibling_functional(no_proxy_env, local_server):
|
|
a = build_keepalive_http_client(local_server)
|
|
b = build_keepalive_http_client(local_server)
|
|
assert _inner(a, "http://") is _inner(b, "http://")
|
|
assert a.get(local_server + "/x").status_code == 200
|
|
a.close()
|
|
assert a.is_closed
|
|
# #10933 shape: the shared pool must still serve the surviving client and
|
|
# any successor client built after the close.
|
|
assert b.get(local_server + "/y").status_code == 200
|
|
c = build_keepalive_http_client(local_server)
|
|
assert _inner(c, "http://") is _inner(b, "http://")
|
|
assert c.get(local_server + "/z").status_code == 200
|
|
with pytest.raises(RuntimeError):
|
|
a.get(local_server + "/closed")
|
|
b.close()
|
|
c.close()
|
|
|
|
|
|
def test_pool_survives_client_close(no_proxy_env, local_server):
|
|
a = build_keepalive_http_client(local_server)
|
|
a.get(local_server + "/warm")
|
|
pool = _inner(a, "http://")._pool
|
|
before = len(pool.connections)
|
|
assert before >= 1
|
|
a.close()
|
|
assert len(pool.connections) == before, "client close must not drain the shared pool"
|
|
|
|
|
|
def test_different_verify_or_proxy_get_different_transports(no_proxy_env, monkeypatch):
|
|
default = build_keepalive_http_client("https://api.example.com/v1")
|
|
insecure = build_keepalive_http_client("https://api.example.com/v1", verify=False)
|
|
ctx = ssl.create_default_context(cafile=certifi.where())
|
|
with_ctx = build_keepalive_http_client("https://api.example.com/v1", verify=ctx)
|
|
with_ctx2 = build_keepalive_http_client("https://api.example.com/v1", verify=ctx)
|
|
codex = build_keepalive_http_client("https://chatgpt.com/backend-api/codex")
|
|
assert _inner(default) is not _inner(insecure)
|
|
assert _inner(default) is not _inner(with_ctx)
|
|
assert _inner(with_ctx) is _inner(with_ctx2)
|
|
assert _inner(with_ctx)._pool._ssl_context is ctx
|
|
assert _inner(insecure)._pool._ssl_context.check_hostname is False
|
|
# Codex cloud gets the happy-eyeballs backend, so it can't share a pool.
|
|
assert _inner(codex) is not _inner(default)
|
|
assert isinstance(
|
|
_inner(codex)._pool._network_backend, process_bootstrap._HappyEyeballsSyncBackend
|
|
)
|
|
for c in (default, insecure, with_ctx, with_ctx2, codex):
|
|
c.close()
|
|
|
|
monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:3128")
|
|
proxied = build_keepalive_http_client("https://api.example.com/v1")
|
|
# Proxy clients keep httpx's own per-client proxy transport (unshared).
|
|
assert all(
|
|
type(t).__name__ != "_SharedTransport" for t in proxied._mounts.values() if t
|
|
)
|
|
proxied.close()
|
|
|
|
|
|
def test_async_clients_are_not_shared(no_proxy_env):
|
|
a = build_keepalive_http_client("https://api.example.com/v1", async_mode=True)
|
|
b = build_keepalive_http_client("https://api.example.com/v1", async_mode=True)
|
|
assert isinstance(a, httpx.AsyncClient)
|
|
ta = [t for t in a._mounts.values() if t is not None]
|
|
tb = [t for t in b._mounts.values() if t is not None]
|
|
assert all(isinstance(t, httpx.AsyncHTTPTransport) for t in ta + tb)
|
|
assert not {id(t) for t in ta} & {id(t) for t in tb}
|
|
|
|
|
|
def test_force_close_only_touches_owning_clients_inflight_sockets(no_proxy_env, local_server):
|
|
"""A stranger-thread abort on client A must not shut down client B's
|
|
idle/in-flight connections that live on the same shared pool."""
|
|
a = build_keepalive_http_client(local_server)
|
|
b = build_keepalive_http_client(local_server)
|
|
b.get(local_server + "/warm") # idle keepalive connection on the shared pool
|
|
pool = _inner(a, "http://")._pool
|
|
assert pool.connections
|
|
# A has nothing in flight: nothing of A's may be touched.
|
|
assert list(_iter_pool_sockets(a)) == []
|
|
assert force_close_tcp_sockets(a) == 0
|
|
# B's idle connection is still healthy.
|
|
assert b.get(local_server + "/again").status_code == 200
|
|
|
|
# Now hold a B stream open and confirm A's abort still sees zero sockets
|
|
# while B's abort sees exactly its own.
|
|
with b.stream("GET", local_server + "/stream") as resp:
|
|
assert resp.status_code == 200
|
|
assert list(_iter_pool_sockets(a)) == []
|
|
assert len(list(_iter_pool_sockets(b))) == 1
|
|
a.close()
|
|
b.close()
|
|
|
|
|
|
def test_shared_transport_cache_is_bounded(no_proxy_env, monkeypatch):
|
|
monkeypatch.setattr(process_bootstrap, "_SHARED_TRANSPORTS_MAX", 2)
|
|
clients = [
|
|
build_keepalive_http_client("https://api.example.com/v1", verify=False),
|
|
build_keepalive_http_client("https://api.example.com/v1"),
|
|
]
|
|
assert len(process_bootstrap._SHARED_TRANSPORTS) == 2
|
|
ctx = ssl.create_default_context()
|
|
extra = build_keepalive_http_client("https://api.example.com/v1", verify=ctx)
|
|
assert len(process_bootstrap._SHARED_TRANSPORTS) == 2
|
|
# Past the cap the caller still gets a working (private) transport.
|
|
assert _inner(extra)._pool._ssl_context is ctx
|
|
for c in clients + [extra]:
|
|
c.close()
|
|
assert process_bootstrap.close_shared_transports() == 2
|