hermes-agent/tests/run_agent/test_openai_client_lifecycle.py
Soju06 82e2c9ce40 perf(agent): reuse the per-request OpenAI wire client across sequential LLM calls
Every LLM call built a fresh openai.OpenAI wire client (new httpx pool,
TCP+TLS handshake, measured 19.2ms p50 / 35.5ms p95 per call at ~5 calls
per tool-loop turn) and closed it when the request finished. Cache ONE
reusable wire client on the agent, keyed by the effective client kwargs:

- _create_request_openai_client hands back the cached client when the
  effective kwargs are identical; any change (credential rotation,
  provider failover, vision default_headers) evicts and rebuilds.
- Only a request that produced a response reports a reuse close reason
  (request_complete / stream_request_complete); error unwinds report
  *_error_cleanup and really close, so a retry after a request error
  always builds a fresh pool.
- Cross-thread aborts poison the slot: a pool whose sockets were
  shutdown(SHUT_RDWR) from a stranger thread is never reused (#29507) —
  the owner-thread close discards it and the next create rebuilds. The
  holder read and the abort are atomic (under the holder lock) at all
  three abort sites, so a late abort can never poison the NEXT request's
  checked-out client.
- Worker-side interrupt breaks close the half-read SSE stream on the
  owning thread before building the partial response; a failed close
  poisons the slot (otherwise each interrupt leaked one checked-out
  connection until the pool hit PoolTimeout). run_codex_stream gets the
  same poison-on-close-failure handling.
- Single checked-out slot (in_use): a concurrent call gets an untracked
  client with the old per-request lifecycle.
- release_clients() / close() really close the cached client when idle;
  if a worker has it checked out they abort the sockets and detach the
  slot, deferring the FD release to the worker's own close.
- MoA facade and Mock passthroughs never enter the cache; max_retries=0
  is preserved on all request clients.
2026-07-28 19:06:59 +05:30

217 lines
7.8 KiB
Python

import sys
import threading
import time
import types
from types import SimpleNamespace
import httpx
import pytest
from openai import APIConnectionError
sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None))
sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object))
sys.modules.setdefault("fal_client", types.SimpleNamespace())
import run_agent
class FakeRequestClient:
def __init__(self, responder):
self._responder = responder
self._client = SimpleNamespace(is_closed=False)
self.chat = SimpleNamespace(
completions=SimpleNamespace(create=self._create)
)
self.responses = SimpleNamespace()
self.close_calls = 0
def _create(self, **kwargs):
return self._responder(**kwargs)
def close(self):
self.close_calls += 1
self._client.is_closed = True
class FakeSharedClient(FakeRequestClient):
pass
class OpenAIFactory:
def __init__(self, clients):
self._clients = list(clients)
self.calls = []
def __call__(self, **kwargs):
self.calls.append(dict(kwargs))
if not self._clients:
raise AssertionError("OpenAI factory exhausted")
return self._clients.pop(0)
def _build_agent(shared_client=None):
agent = run_agent.AIAgent.__new__(run_agent.AIAgent)
agent.api_mode = "chat_completions"
agent.provider = "openai-codex"
agent.base_url = "https://chatgpt.com/backend-api/codex"
agent.model = "gpt-5-codex"
agent.log_prefix = ""
agent.quiet_mode = True
agent._interrupt_requested = False
agent._interrupt_message = None
agent._client_lock = threading.RLock()
agent._client_kwargs = {"api_key": "***", "base_url": agent.base_url}
agent.client = shared_client or FakeSharedClient(lambda **kwargs: {"shared": True})
agent.stream_delta_callback = None
agent._stream_callback = None
agent.reasoning_callback = None
agent.status_callback = None
return agent
def _connection_error():
return APIConnectionError(
message="Connection error.",
request=httpx.Request("POST", "https://example.com/v1/chat/completions"),
)
def test_retry_after_api_connection_error_recreates_request_client(monkeypatch):
first_request = FakeRequestClient(lambda **kwargs: (_ for _ in ()).throw(_connection_error()))
second_request = FakeRequestClient(lambda **kwargs: {"ok": True})
factory = OpenAIFactory([first_request, second_request])
monkeypatch.setattr(run_agent, "OpenAI", factory)
agent = _build_agent()
with pytest.raises(APIConnectionError):
agent._interruptible_api_call({"model": agent.model, "messages": []})
result = agent._interruptible_api_call({"model": agent.model, "messages": []})
assert result == {"ok": True}
assert len(factory.calls) == 2
assert first_request.close_calls >= 1
# The successful request's wire client is cached for reuse across
# sequential calls (not closed at request end); teardown really closes it.
assert second_request.close_calls == 0
agent._close_cached_request_openai_client(reason="agent_close")
assert second_request.close_calls == 1
def test_stale_non_stream_close_is_single_owner(monkeypatch):
def slow_responder(**kwargs):
time.sleep(0.1)
raise _connection_error()
request_client = FakeRequestClient(slow_responder)
factory = OpenAIFactory([request_client])
monkeypatch.setattr(run_agent, "OpenAI", factory)
agent = _build_agent()
agent._compute_non_stream_stale_timeout = lambda api_payload: 0.01
with pytest.raises(APIConnectionError):
agent._interruptible_api_call({"model": agent.model, "messages": []})
assert request_client.close_calls == 1
def test_closed_shared_client_is_recreated_before_request(monkeypatch):
stale_shared = FakeSharedClient(lambda **kwargs: (_ for _ in ()).throw(AssertionError("stale shared client used")))
stale_shared._client.is_closed = True
replacement_shared = FakeSharedClient(lambda **kwargs: {"replacement": True})
request_client = FakeRequestClient(lambda **kwargs: {"ok": "fresh-request-client"})
factory = OpenAIFactory([replacement_shared, request_client])
monkeypatch.setattr(run_agent, "OpenAI", factory)
agent = _build_agent(shared_client=stale_shared)
result = agent._interruptible_api_call({"model": agent.model, "messages": []})
assert result == {"ok": "fresh-request-client"}
assert agent.client is replacement_shared
assert stale_shared.close_calls >= 1
assert replacement_shared.close_calls == 0
assert len(factory.calls) == 2
def test_concurrent_requests_do_not_break_each_other_when_one_client_closes(monkeypatch):
first_started = threading.Event()
first_closed = threading.Event()
def first_responder(**kwargs):
first_started.set()
first_client.close()
first_closed.set()
raise _connection_error()
def second_responder(**kwargs):
assert first_started.wait(timeout=2)
assert first_closed.wait(timeout=2)
return {"ok": "second"}
first_client = FakeRequestClient(first_responder)
second_client = FakeRequestClient(second_responder)
factory = OpenAIFactory([first_client, second_client])
monkeypatch.setattr(run_agent, "OpenAI", factory)
agent = _build_agent()
results = {}
def run_call(name):
try:
results[name] = agent._interruptible_api_call({"model": agent.model, "messages": []})
except Exception as exc: # noqa: BLE001 - asserting exact type below
results[name] = exc
thread_one = threading.Thread(target=run_call, args=("first",), daemon=True)
thread_two = threading.Thread(target=run_call, args=("second",), daemon=True)
thread_one.start()
thread_two.start()
thread_one.join(timeout=5)
thread_two.join(timeout=5)
values = list(results.values())
assert sum(isinstance(value, APIConnectionError) for value in values) == 1
assert values.count({"ok": "second"}) == 1
assert len(factory.calls) == 2
def test_streaming_call_recreates_closed_shared_client_before_request(monkeypatch):
chunks = iter([
SimpleNamespace(
model="gpt-5-codex",
choices=[SimpleNamespace(delta=SimpleNamespace(content="Hello", tool_calls=None), finish_reason=None)],
),
SimpleNamespace(
model="gpt-5-codex",
choices=[SimpleNamespace(delta=SimpleNamespace(content=" world", tool_calls=None), finish_reason="stop")],
),
])
stale_shared = FakeSharedClient(lambda **kwargs: (_ for _ in ()).throw(AssertionError("stale shared client used")))
stale_shared._client.is_closed = True
replacement_shared = FakeSharedClient(lambda **kwargs: {"replacement": True})
request_client = FakeRequestClient(lambda **kwargs: chunks)
factory = OpenAIFactory([replacement_shared, request_client])
monkeypatch.setattr(run_agent, "OpenAI", factory)
agent = _build_agent(shared_client=stale_shared)
agent.stream_delta_callback = lambda _delta: None
# Force chat_completions mode so the streaming path uses
# chat.completions.create(stream=True) instead of Codex responses.stream()
agent.api_mode = "chat_completions"
response = agent._interruptible_streaming_api_call({"model": agent.model, "messages": []})
assert response.choices[0].message.content == "Hello world"
assert agent.client is replacement_shared
assert stale_shared.close_calls >= 1
# The clean stream's wire client is cached for reuse across sequential
# calls (not closed at request end); teardown really closes it.
assert request_client.close_calls == 0
agent._close_cached_request_openai_client(reason="agent_close")
assert request_client.close_calls == 1
assert len(factory.calls) == 2