Skip to content

Commit d9f6110

Browse files
committed
Quiet expected client disconnects
1 parent 78227e7 commit d9f6110

2 files changed

Lines changed: 74 additions & 1 deletion

File tree

src/codex_fast_proxy/proxy.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
BODY_METHODS = {"POST", "PUT", "PATCH"}
3636
RESPONSES_PATH = "/v1/responses"
3737
HEALTH_PATH = "/__codex_fast_proxy/health"
38+
CLIENT_DISCONNECT_ERRORS = (BrokenPipeError, ConnectionAbortedError, ConnectionResetError)
3839

3940

4041
def source_fingerprint(paths: Iterable[Path]) -> str:
@@ -274,7 +275,7 @@ def proxy(self) -> None:
274275
self.forward_response(response)
275276
finally:
276277
connection.close()
277-
except BrokenPipeError:
278+
except CLIENT_DISCONNECT_ERRORS:
278279
error_type = "client_disconnected"
279280
except Exception as exc:
280281
error_type = type(exc).__name__

tests/test_proxy.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import json
44
import shutil
55
import sys
6+
import threading
67
import unittest
78
import uuid
89
from io import BytesIO
910
from pathlib import Path
1011
from types import SimpleNamespace
12+
from typing import Any
1113

1214

1315
ROOT = Path(__file__).resolve().parents[1]
@@ -20,6 +22,7 @@
2022
safe_url_display,
2123
)
2224
from codex_fast_proxy.proxy import ( # noqa: E402
25+
FastProxyHandler,
2326
copy_request_headers,
2427
dashboard_requested,
2528
parse_args,
@@ -50,6 +53,26 @@ def read(self, _size: int) -> bytes:
5053
raise AssertionError("SSE should use line-buffered reads")
5154

5255

56+
class FakeConnection:
57+
def __init__(self) -> None:
58+
self.closed = False
59+
60+
def request(self, _method: str, _path: str, body: bytes, headers: dict[str, str]) -> None:
61+
self.body = body
62+
self.headers = headers
63+
64+
def getresponse(self) -> Any:
65+
return SimpleNamespace(
66+
status=200,
67+
getheader=lambda name, default=None: "text/event-stream"
68+
if name.lower() == "content-type"
69+
else default,
70+
)
71+
72+
def close(self) -> None:
73+
self.closed = True
74+
75+
5376
class ProxyPatchTests(unittest.TestCase):
5477
def test_injects_missing_service_tier_without_changing_payload_fields(self) -> None:
5578
payload = {
@@ -139,6 +162,55 @@ def test_sse_payload_bytes_are_forwarded_without_event_rewrite(self) -> None:
139162

140163
self.assertEqual(writer.getvalue(), b"event: response.output_text.delta\ndata: {\"x\":1}\n\n")
141164

165+
def test_client_disconnect_during_stream_is_logged_without_bad_gateway(self) -> None:
166+
temp_root = ROOT / ".test_tmp"
167+
temp_root.mkdir(exist_ok=True)
168+
temp_dir = temp_root / f"client-disconnect-{uuid.uuid4().hex}"
169+
temp_dir.mkdir()
170+
try:
171+
log_path = temp_dir / "fast_proxy.jsonl"
172+
body = json.dumps({"model": "gpt-test", "stream": True}).encode("utf-8")
173+
connection = FakeConnection()
174+
handler = FastProxyHandler.__new__(FastProxyHandler)
175+
handler.command = "POST"
176+
handler.path = "/v1/responses"
177+
handler.headers = {
178+
"Content-Type": "application/json",
179+
"Content-Length": str(len(body)),
180+
}
181+
handler.rfile = BytesIO(body)
182+
handler.server = SimpleNamespace(
183+
proxy_base="/v1",
184+
upstream_base_path="/v1",
185+
upstream_netloc="api.example.test",
186+
service_tier="priority",
187+
log_path=log_path,
188+
log_lock=threading.Lock(),
189+
verbose=False,
190+
open_connection=lambda: connection,
191+
)
192+
193+
def raise_client_disconnect(_response: Any) -> None:
194+
raise ConnectionAbortedError("client closed SSE")
195+
196+
def fail_bad_gateway() -> None:
197+
raise AssertionError("client disconnect should not be converted to a 502 response")
198+
199+
handler.forward_response = raise_client_disconnect
200+
handler.respond_bad_gateway = fail_bad_gateway
201+
202+
FastProxyHandler.proxy(handler)
203+
event = json.loads(log_path.read_text(encoding="utf-8"))
204+
finally:
205+
shutil.rmtree(temp_dir)
206+
207+
self.assertTrue(connection.closed)
208+
self.assertEqual(event["status"], 200)
209+
self.assertEqual(event["error_type"], "client_disconnected")
210+
self.assertEqual(event["service_tier_before"], "<absent>")
211+
self.assertEqual(event["service_tier_after"], "priority")
212+
self.assertTrue(event["service_tier_injected"])
213+
142214
def test_foreground_proxy_default_log_dir_uses_runtime_state_dir(self) -> None:
143215
args = parse_args(["--upstream-base", "https://api.example.test/v1"])
144216
expected_suffixes = (

0 commit comments

Comments
 (0)