From da1170670b967bd357773a1040d89c7022ee9d57 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:20:28 -0600 Subject: [PATCH] fix(agent): log Codex transport failure details --- agent/codex_runtime.py | 81 +++++++++++++++++++ ...est_codex_request_transport_diagnostics.py | 52 ++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 tests/agent/test_codex_request_transport_diagnostics.py diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index f36f1c78fe..34ef399b26 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -27,6 +27,62 @@ from agent.stream_single_writer import claim_stream_writer, stream_writer_is_cur logger = logging.getLogger(__name__) +def _codex_request_failure_details(error: BaseException) -> tuple[int | None, str]: + """Return the serialized request size and exception class chain. + + OpenAI connection exceptions retain the final ``httpx.Request``. Reading + its already-buffered content gives us the exact byte count handed to the + transport without logging any request content. The class-only chain keeps + the underlying transport failure visible without exposing URLs or payloads + from exception messages. + """ + request_body_bytes: int | None = None + exception_classes: list[str] = [] + current: BaseException | None = error + seen: set[int] = set() + + while current is not None and id(current) not in seen and len(seen) < 8: + seen.add(id(current)) + exception_classes.append(type(current).__name__) + + if request_body_bytes is None: + request = getattr(current, "request", None) + if request is not None: + try: + content = request.content + except Exception: + content = None + if isinstance(content, str): + request_body_bytes = len(content.encode("utf-8")) + elif isinstance(content, (bytes, bytearray, memoryview)): + request_body_bytes = len(content) + + cause = current.__cause__ + if cause is None and not current.__suppress_context__: + cause = current.__context__ + current = cause + + return request_body_bytes, " <- ".join(exception_classes) + + +def _log_codex_request_failure( + agent: Any, + error: BaseException, + *, + stream_opened: bool, +) -> None: + request_body_bytes, exception_chain = _codex_request_failure_details(error) + logger.warning( + "Codex Responses request failed: " + "serialized_request_body_bytes=%s stream_opened=%s " + "exception_chain=%s model=%s", + request_body_bytes if request_body_bytes is not None else "unknown", + str(stream_opened).lower(), + exception_chain, + getattr(agent, "model", "unknown"), + ) + + def _coerce_usage_int(value: Any) -> int: if isinstance(value, bool): return 0 @@ -1253,6 +1309,7 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta the terminal event's ``output`` field. """ import httpx as _httpx + from openai import APIConnectionError as _APIConnectionError from agent import relay_llm @@ -1356,6 +1413,18 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta exc, ) continue + _log_codex_request_failure( + agent, + exc, + stream_opened=writer_token["value"] is not None, + ) + raise + except _APIConnectionError as exc: + _log_codex_request_failure( + agent, + exc, + stream_opened=writer_token["value"] is not None, + ) raise def _interrupt_or_superseded() -> bool: @@ -1389,11 +1458,23 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta agent._client_log_context(), exc, ) continue + _log_codex_request_failure( + agent, + exc, + stream_opened=writer_token["value"] is not None, + ) raise except RuntimeError: if event_stream.final_response is not None: return event_stream.final_response raise + except _APIConnectionError as exc: + _log_codex_request_failure( + agent, + exc, + stream_opened=writer_token["value"] is not None, + ) + raise # A terminal response has already been assembled at this point # (``final`` is built), so a transport error while draining the diff --git a/tests/agent/test_codex_request_transport_diagnostics.py b/tests/agent/test_codex_request_transport_diagnostics.py new file mode 100644 index 0000000000..deea310fdb --- /dev/null +++ b/tests/agent/test_codex_request_transport_diagnostics.py @@ -0,0 +1,52 @@ +"""Diagnostics for Codex Responses transport failures.""" + +from __future__ import annotations + +import logging +from types import SimpleNamespace + +import httpx +import pytest +from openai import APIConnectionError + +from agent.codex_runtime import run_codex_stream + + +def test_transport_failure_logs_exact_request_bytes_and_class_chain(caplog): + request_content = b'{"input":"payload"}' + request = httpx.Request( + "POST", + "https://example.invalid/responses", + content=request_content, + ) + transport_error = httpx.RemoteProtocolError( + "server disconnected without sending a response", + request=request, + ) + connection_error = APIConnectionError(request=request) + connection_error.__cause__ = transport_error + + class FailingResponses: + def create(self, **_kwargs): + raise connection_error + + client = SimpleNamespace(responses=FailingResponses()) + agent = SimpleNamespace( + _interrupt_requested=False, + _current_api_request_id="request-id", + _fallback_index=0, + is_subagent=False, + model="gpt-5.6-sol", + provider="openai-codex", + session_id="", + ) + + with caplog.at_level(logging.WARNING, logger="agent.codex_runtime"): + with pytest.raises(APIConnectionError): + run_codex_stream(agent, {"model": "gpt-5.6-sol"}, client=client) + + message = caplog.messages[-1] + assert f"serialized_request_body_bytes={len(request_content)}" in message + assert "stream_opened=false" in message + assert "exception_chain=APIConnectionError <- RemoteProtocolError" in message + assert "payload" not in message