Skip to content

Commit d0e1eaf

Browse files
committed
fix: normalize incomplete HTTP bodies as connection errors
Signed-off-by: RudyCelekli <47457359+rudycelekli@users.noreply.github.com>
1 parent 7f1f68b commit d0e1eaf

2 files changed

Lines changed: 83 additions & 1 deletion

File tree

‎ifixai/providers/http.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ async def _send_request(
209209
data, endpoint, config.reject_truncated
210210
)
211211

212-
except aiohttp.ClientConnectionError as exc:
212+
except (aiohttp.ClientConnectionError, aiohttp.ClientPayloadError) as exc:
213213
raise ProviderConnectionError(
214214
provider="http",
215215
endpoint=endpoint,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Actual incomplete HTTP bodies follow the existing native retry contract."""
2+
3+
import asyncio
4+
import json
5+
6+
import pytest
7+
8+
from ifixai.core.types import ChatMessage, ProviderConfig
9+
from ifixai.providers.base import ProviderConnectionError, ProviderResponseError
10+
from ifixai.providers.http import HttpProvider
11+
12+
13+
@pytest.mark.parametrize(
14+
"bodies, retries, expected, requests",
15+
[
16+
(["incomplete", "complete"], 1, "complete", 2),
17+
(["incomplete"], 0, "connection_error", 1),
18+
(["incomplete", "incomplete"], 1, "connection_error", 2),
19+
(["invalid_json", "complete"], 1, "response_error", 1),
20+
(["complete"], 1, "complete", 1),
21+
],
22+
)
23+
def test_completion_body_transport_failure_uses_existing_retry_policy(
24+
bodies, retries, expected, requests
25+
):
26+
async def run():
27+
received = []
28+
handlers = set()
29+
30+
async def respond(reader, writer):
31+
task = asyncio.current_task()
32+
handlers.add(task)
33+
try:
34+
header = await reader.readuntil(b"\r\n\r\n")
35+
length = next(
36+
int(line.partition(b":")[2].strip())
37+
for line in header.split(b"\r\n")
38+
if line.lower().startswith(b"content-length:")
39+
)
40+
received.append(json.loads(await reader.readexactly(length)))
41+
kind = bodies[min(len(received) - 1, len(bodies) - 1)]
42+
body = json.dumps({"choices": [{"message": {"content": "Complete response"}}]}).encode()
43+
if kind == "invalid_json":
44+
body = b"not JSON"
45+
transmitted = body[:len(body) // 2] if kind == "incomplete" else body
46+
writer.write(
47+
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: "
48+
+ str(len(body)).encode()
49+
+ b"\r\nConnection: close\r\n\r\n"
50+
+ transmitted
51+
)
52+
await writer.drain()
53+
finally:
54+
writer.close()
55+
await writer.wait_closed()
56+
handlers.discard(task)
57+
58+
server = await asyncio.start_server(respond, "127.0.0.1", 0)
59+
port = server.sockets[0].getsockname()[1]
60+
endpoint = f"http://127.0.0.1:{port}/v1"
61+
provider = HttpProvider()
62+
try:
63+
config = ProviderConfig(provider="http", model="owned", endpoint=endpoint, max_retries=retries, timeout=5)
64+
messages = [ChatMessage(role="user", content="hello")]
65+
if expected == "complete":
66+
assert await provider.send_message(messages, config) == "Complete response"
67+
else:
68+
error = ProviderConnectionError if expected == "connection_error" else ProviderResponseError
69+
with pytest.raises(error) as caught:
70+
await provider.send_message(messages, config)
71+
assert caught.value.provider == "http"
72+
assert caught.value.endpoint == endpoint
73+
assert len(received) == requests
74+
assert all(request["messages"] == [{"role": "user", "content": "hello"}] for request in received)
75+
finally:
76+
await provider.aclose()
77+
server.close()
78+
await server.wait_closed()
79+
if handlers:
80+
await asyncio.gather(*handlers)
81+
82+
asyncio.run(run())

0 commit comments

Comments
 (0)