diff --git a/src/agents/sandbox/session/sinks.py b/src/agents/sandbox/session/sinks.py index 77d90cc086..e98afdaaf7 100644 --- a/src/agents/sandbox/session/sinks.py +++ b/src/agents/sandbox/session/sinks.py @@ -317,7 +317,11 @@ def _post(self, body: bytes, spool_line: str | None) -> None: try: with urlopen(req, timeout=self.timeout_s) as resp: _ = resp.read(1) # ensure request completes - except (HTTPError, URLError) as e: + except (HTTPError, URLError, TimeoutError, OSError) as e: + # `urlopen` wraps most network failures in `URLError`, but socket-level + # timeouts (e.g. during connect) can surface as bare `TimeoutError`, and + # other low-level failures as `OSError`. Treat all of these as delivery + # failures so the configured spool fallback is exercised consistently. if spool_line is not None and self.spool_path is not None: try: self.spool_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/sandbox/test_session_sinks.py b/tests/sandbox/test_session_sinks.py index 420f142f3e..cc94f817da 100644 --- a/tests/sandbox/test_session_sinks.py +++ b/tests/sandbox/test_session_sinks.py @@ -21,6 +21,7 @@ CallbackSink, ChainedSink, EventPayloadPolicy, + HttpProxySink, Instrumentation, JsonlOutboxSink, SandboxSession, @@ -124,6 +125,44 @@ async def test_jsonl_outbox_sink_appends_one_line_per_event(tmp_path: Path) -> N assert json.loads(lines[1])["phase"] == "finish" +@pytest.mark.parametrize( + "exc", + [TimeoutError("timed out"), OSError("connection reset")], +) +@pytest.mark.asyncio +async def test_http_proxy_sink_writes_spool_on_timeout_or_oserror( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + exc: BaseException, +) -> None: + spool_path = tmp_path / "spool.jsonl" + sink = HttpProxySink( + "http://127.0.0.1:9/events", + timeout_s=0.001, + spool_path=spool_path, + ) + + def _raise(*_args: object, **_kwargs: object) -> None: + raise exc + + monkeypatch.setattr("agents.sandbox.session.sinks.urlopen", _raise) + + event = SandboxSessionStartEvent( + session_id=uuid.uuid4(), + seq=1, + op="write", + span_id="span_write", + ) + + with pytest.raises(RuntimeError, match="http proxy sink POST failed"): + await sink.handle(event) + + assert spool_path.exists() + lines = spool_path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + assert json.loads(lines[0])["seq"] == 1 + + @pytest.mark.asyncio async def test_chained_sink_runs_in_order(tmp_path: Path) -> None: outbox = tmp_path / "events.jsonl"