Skip to content

Commit 5853086

Browse files
committed
fix(ci): stabilize exec streaming behavior
1 parent 7942725 commit 5853086

3 files changed

Lines changed: 77 additions & 7 deletions

File tree

src/api/exec.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@
1515
from fastapi.responses import StreamingResponse
1616

1717
from ..models import ExecRequest, ExecResponse
18-
from ..models.errors import ErrorResponse, ValidationError, ServiceUnavailableError
18+
from ..models.errors import (
19+
CodeInterpreterException,
20+
ErrorResponse,
21+
ErrorType,
22+
ValidationError,
23+
ServiceUnavailableError,
24+
)
1925
from ..services.orchestrator import ExecutionOrchestrator
2026
from ..dependencies.services import (
2127
SessionServiceDep,
@@ -106,6 +112,14 @@ async def _execute() -> ExecResponse:
106112
)
107113
return response
108114
except asyncio.TimeoutError:
115+
if execution_task.done():
116+
response = await execution_task
117+
logger.info(
118+
"Code execution completed",
119+
request_id=request_id,
120+
session_id=response.session_id,
121+
)
122+
return response
109123
# Fall through to streamed keepalives for genuinely long-running work.
110124
pass
111125
except (ValidationError, ServiceUnavailableError):
@@ -119,6 +133,12 @@ async def _stream_response():
119133
whitespace is ignored by JSON parsers, so this is transparent
120134
to clients.
121135
"""
136+
# The endpoint already spent one interval deciding whether to switch to
137+
# streaming. Emit a first keepalive immediately so long-running
138+
# requests stay under client-side socket timeout thresholds.
139+
if not execution_task.done():
140+
yield b" "
141+
122142
# Send keepalive spaces while execution is running
123143
while not execution_task.done():
124144
try:
@@ -127,17 +147,24 @@ async def _stream_response():
127147
)
128148
except asyncio.TimeoutError:
129149
# Execution still running — send keepalive space
130-
yield b" "
150+
if not execution_task.done():
151+
yield b" "
152+
except Exception:
153+
# Task raised an exception — it will be handled below.
154+
break
131155

132156
# Ensure the task is complete
133157
try:
134158
response = await execution_task
135159
except Exception as err:
136160
# Once the streaming response has started, surface failures as a JSON
137161
# error payload instead of raising after headers have been sent.
162+
error_type = ErrorType.INTERNAL_SERVER
163+
if isinstance(err, CodeInterpreterException):
164+
error_type = err.error_type
138165
error_resp = ErrorResponse(
139166
error=str(err),
140-
error_type="execution",
167+
error_type=error_type,
141168
)
142169
yield error_resp.model_dump_json().encode()
143170
return

tests/integration/test_auth_integration.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,15 @@ def test_file_upload_flow_without_auth(self, client, mock_services):
217217
class TestAuthenticationEdgeCases:
218218
"""Test edge cases in authentication."""
219219

220+
@staticmethod
221+
def _protected_exec_request(client, headers):
222+
"""Hit a real authenticated endpoint with a minimal valid payload."""
223+
return client.post(
224+
"/exec",
225+
headers=headers,
226+
json={"code": "print('auth edge')", "lang": "py"},
227+
)
228+
220229
def test_auth_with_special_characters_in_key(self, client, mock_services):
221230
"""Test authentication with special characters in API key."""
222231
special_key = "test-key-with-special-chars!@#$%^&*()"
@@ -225,10 +234,10 @@ def test_auth_with_special_characters_in_key(self, client, mock_services):
225234
mock_settings.api_key = special_key
226235
headers = {"x-api-key": special_key}
227236

228-
response = client.get("/sessions", headers=headers)
237+
response = self._protected_exec_request(client, headers)
229238

230239
# Should handle special characters correctly
231-
# If 401, it means auth failed, but we want to ensure no 500 error
240+
# If 401, auth rejected the key. If 200, auth accepted it.
232241
assert response.status_code in [200, 401]
233242

234243
def test_auth_with_very_long_key(self, client, mock_services):
@@ -239,7 +248,7 @@ def test_auth_with_very_long_key(self, client, mock_services):
239248
mock_settings.api_key = long_key
240249
headers = {"x-api-key": long_key}
241250

242-
response = client.get("/sessions", headers=headers)
251+
response = self._protected_exec_request(client, headers)
243252

244253
# Should handle long keys (within reason)
245254
assert response.status_code in [200, 401]

tests/integration/test_exec_api.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Integration tests for the /exec endpoint."""
22

3+
import asyncio
34
import pytest
45
from fastapi.testclient import TestClient
56
from unittest.mock import AsyncMock, patch, MagicMock
@@ -8,7 +9,13 @@
89
from datetime import datetime, timezone, timedelta
910

1011
from src.main import app
11-
from src.models import CodeExecution, ExecutionStatus, ExecutionOutput, OutputType
12+
from src.models import (
13+
CodeExecution,
14+
ExecutionStatus,
15+
ExecutionOutput,
16+
OutputType,
17+
ServiceUnavailableError,
18+
)
1219

1320

1421
@pytest.fixture
@@ -381,6 +388,33 @@ def test_exec_service_error(self, client, auth_headers, mock_execution_service):
381388
assert response.status_code == 503
382389
assert "error" in response.json()
383390

391+
def test_exec_delayed_service_error_after_stream_start(
392+
self, client, auth_headers
393+
):
394+
"""Delayed failures should return a JSON error payload, not crash the stream."""
395+
396+
async def _delayed_failure(*args, **kwargs):
397+
await asyncio.sleep(3.2)
398+
raise ServiceUnavailableError(
399+
service="Code Execution",
400+
message="Delayed backend failure",
401+
)
402+
403+
with patch(
404+
"src.services.orchestrator.ExecutionOrchestrator.execute",
405+
side_effect=_delayed_failure,
406+
):
407+
response = client.post(
408+
"/exec",
409+
json={"code": "print('Hello')", "lang": "py"},
410+
headers=auth_headers,
411+
)
412+
413+
assert response.status_code == 200
414+
response_data = json.loads(response.text.lstrip())
415+
assert response_data["error_type"] == "service_unavailable"
416+
assert "Delayed backend failure" in response_data["error"]
417+
384418
def test_exec_response_format_compatibility(
385419
self, client, auth_headers, mock_execution_service
386420
):

0 commit comments

Comments
 (0)