|
3 | 3 | import json |
4 | 4 | import shutil |
5 | 5 | import sys |
| 6 | +import threading |
6 | 7 | import unittest |
7 | 8 | import uuid |
8 | 9 | from io import BytesIO |
9 | 10 | from pathlib import Path |
10 | 11 | from types import SimpleNamespace |
| 12 | +from typing import Any |
11 | 13 |
|
12 | 14 |
|
13 | 15 | ROOT = Path(__file__).resolve().parents[1] |
|
20 | 22 | safe_url_display, |
21 | 23 | ) |
22 | 24 | from codex_fast_proxy.proxy import ( # noqa: E402 |
| 25 | + FastProxyHandler, |
23 | 26 | copy_request_headers, |
24 | 27 | dashboard_requested, |
25 | 28 | parse_args, |
@@ -50,6 +53,26 @@ def read(self, _size: int) -> bytes: |
50 | 53 | raise AssertionError("SSE should use line-buffered reads") |
51 | 54 |
|
52 | 55 |
|
| 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 | + |
53 | 76 | class ProxyPatchTests(unittest.TestCase): |
54 | 77 | def test_injects_missing_service_tier_without_changing_payload_fields(self) -> None: |
55 | 78 | payload = { |
@@ -139,6 +162,55 @@ def test_sse_payload_bytes_are_forwarded_without_event_rewrite(self) -> None: |
139 | 162 |
|
140 | 163 | self.assertEqual(writer.getvalue(), b"event: response.output_text.delta\ndata: {\"x\":1}\n\n") |
141 | 164 |
|
| 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 | + |
142 | 214 | def test_foreground_proxy_default_log_dir_uses_runtime_state_dir(self) -> None: |
143 | 215 | args = parse_args(["--upstream-base", "https://api.example.test/v1"]) |
144 | 216 | expected_suffixes = ( |
|
0 commit comments