Skip to content

Commit fc07925

Browse files
authored
feat: enhance error handling with JSON parsing and response truncation (#7)
* feat: enhance error handling with JSON parsing and response truncation * chore: remove unnecessary blank line in test_integration.py * feat: add CI handling for integration tests with xfail for intermittent server errors
1 parent 56a09e4 commit fc07925

File tree

3 files changed

+272
-10
lines changed

3 files changed

+272
-10
lines changed

src/lingodotdev/engine.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# mypy: disable-error-code=unreachable
66

77
import asyncio
8+
import json
89
from typing import Any, Callable, Dict, List, Optional
910
from urllib.parse import urljoin
1011

@@ -80,6 +81,36 @@ async def close(self):
8081
if self._client and not self._client.is_closed:
8182
await self._client.aclose()
8283

84+
@staticmethod
85+
def _truncate_response(text: str, max_length: int = 200) -> str:
86+
"""Truncate response text for error messages"""
87+
if len(text) > max_length:
88+
return text[:max_length] + "..."
89+
return text
90+
91+
@staticmethod
92+
def _safe_parse_json(response: httpx.Response) -> Dict[str, Any]:
93+
"""
94+
Safely parse JSON response, handling HTML error pages gracefully.
95+
96+
Args:
97+
response: The httpx response object
98+
99+
Returns:
100+
Parsed JSON as a dictionary
101+
102+
Raises:
103+
RuntimeError: If the response cannot be parsed as JSON
104+
"""
105+
try:
106+
return response.json()
107+
except json.JSONDecodeError:
108+
preview = LingoDotDevEngine._truncate_response(response.text)
109+
raise RuntimeError(
110+
f"Failed to parse API response as JSON (status {response.status_code}). "
111+
f"This may indicate a gateway or proxy error. Response: {preview}"
112+
)
113+
83114
async def _localize_raw(
84115
self,
85116
payload: Dict[str, Any],
@@ -184,19 +215,23 @@ async def _localize_chunk(
184215
response = await self._client.post(url, json=request_data)
185216

186217
if not response.is_success:
218+
response_preview = self._truncate_response(response.text)
187219
if 500 <= response.status_code < 600:
188220
raise RuntimeError(
189221
f"Server error ({response.status_code}): {response.reason_phrase}. "
190-
f"{response.text}. This may be due to temporary service issues."
222+
f"This may be due to temporary service issues. Response: {response_preview}"
191223
)
192224
elif response.status_code == 400:
193225
raise ValueError(
194-
f"Invalid request ({response.status_code}): {response.reason_phrase}"
226+
f"Invalid request ({response.status_code}): {response.reason_phrase}. "
227+
f"Response: {response_preview}"
195228
)
196229
else:
197-
raise RuntimeError(response.text)
230+
raise RuntimeError(
231+
f"Request failed ({response.status_code}): {response_preview}"
232+
)
198233

199-
json_response = response.json()
234+
json_response = self._safe_parse_json(response)
200235

201236
# Handle streaming errors
202237
if not json_response.get("data") and json_response.get("error"):
@@ -426,16 +461,18 @@ async def recognize_locale(self, text: str) -> str:
426461
response = await self._client.post(url, json={"text": text})
427462

428463
if not response.is_success:
464+
response_preview = self._truncate_response(response.text)
429465
if 500 <= response.status_code < 600:
430466
raise RuntimeError(
431467
f"Server error ({response.status_code}): {response.reason_phrase}. "
432-
"This may be due to temporary service issues."
468+
f"This may be due to temporary service issues. Response: {response_preview}"
433469
)
434470
raise RuntimeError(
435-
f"Error recognizing locale: {response.reason_phrase}"
471+
f"Error recognizing locale ({response.status_code}): {response.reason_phrase}. "
472+
f"Response: {response_preview}"
436473
)
437474

438-
json_response = response.json()
475+
json_response = self._safe_parse_json(response)
439476
return json_response.get("locale") or ""
440477

441478
except httpx.RequestError as e:
@@ -456,14 +493,15 @@ async def whoami(self) -> Optional[Dict[str, str]]:
456493
response = await self._client.post(url)
457494

458495
if response.is_success:
459-
payload = response.json()
496+
payload = self._safe_parse_json(response)
460497
if payload.get("email"):
461498
return {"email": payload["email"], "id": payload["id"]}
462499

463500
if 500 <= response.status_code < 600:
501+
response_preview = self._truncate_response(response.text)
464502
raise RuntimeError(
465503
f"Server error ({response.status_code}): {response.reason_phrase}. "
466-
"This may be due to temporary service issues."
504+
f"This may be due to temporary service issues. Response: {response_preview}"
467505
)
468506

469507
return None

tests/test_engine.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import asyncio
77
from unittest.mock import Mock, patch, AsyncMock
88

9+
import httpx
10+
911
from lingodotdev import LingoDotDevEngine
1012
from lingodotdev.engine import EngineConfig
1113

@@ -55,6 +57,215 @@ def test_invalid_ideal_batch_item_size(self):
5557
EngineConfig(api_key="test_key", ideal_batch_item_size=3000)
5658

5759

60+
class TestErrorHandling:
61+
"""Test error handling utilities for non-JSON responses (e.g., 502 HTML errors)"""
62+
63+
def test_truncate_response_short_text(self):
64+
"""Test that short responses are not truncated"""
65+
short_text = "Short error message"
66+
result = LingoDotDevEngine._truncate_response(short_text)
67+
assert result == short_text
68+
69+
def test_truncate_response_long_text(self):
70+
"""Test that long responses are truncated with ellipsis"""
71+
long_text = "x" * 300
72+
result = LingoDotDevEngine._truncate_response(long_text)
73+
assert len(result) == 203 # 200 chars + "..."
74+
assert result.endswith("...")
75+
76+
def test_truncate_response_custom_max_length(self):
77+
"""Test truncation with custom max length"""
78+
text = "x" * 100
79+
result = LingoDotDevEngine._truncate_response(text, max_length=50)
80+
assert len(result) == 53 # 50 chars + "..."
81+
assert result.endswith("...")
82+
83+
def test_truncate_response_exact_length(self):
84+
"""Test text exactly at max length is not truncated"""
85+
text = "x" * 200
86+
result = LingoDotDevEngine._truncate_response(text, max_length=200)
87+
assert result == text
88+
assert not result.endswith("...")
89+
90+
def test_safe_parse_json_valid_json(self):
91+
"""Test parsing valid JSON response"""
92+
mock_response = Mock(spec=httpx.Response)
93+
mock_response.json.return_value = {"data": "test"}
94+
95+
result = LingoDotDevEngine._safe_parse_json(mock_response)
96+
assert result == {"data": "test"}
97+
98+
def test_safe_parse_json_html_response(self):
99+
"""Test handling HTML response (like 502 error page)"""
100+
import json as json_module
101+
102+
# Use a large HTML body (>200 chars) to test truncation
103+
html_body = """<!DOCTYPE html>
104+
<html>
105+
<head><title>502 Bad Gateway</title></head>
106+
<body>
107+
<center><h1>502 Bad Gateway</h1></center>
108+
<p>The server encountered a temporary error and could not complete your request.</p>
109+
<p>Please try again in a few moments. If the problem persists, contact support.</p>
110+
<hr><center>nginx/1.18.0 (Ubuntu)</center>
111+
</body>
112+
</html>"""
113+
mock_response = Mock(spec=httpx.Response)
114+
mock_response.json.side_effect = json_module.JSONDecodeError(
115+
"Expecting value", html_body, 0
116+
)
117+
mock_response.text = html_body
118+
mock_response.status_code = 502
119+
120+
with pytest.raises(RuntimeError) as exc_info:
121+
LingoDotDevEngine._safe_parse_json(mock_response)
122+
123+
error_msg = str(exc_info.value)
124+
assert "Failed to parse API response as JSON" in error_msg
125+
assert "status 502" in error_msg
126+
assert "gateway or proxy error" in error_msg
127+
# Verify HTML is truncated (original is ~400 chars, should be truncated to 200 + ...)
128+
assert "..." in error_msg
129+
assert len(error_msg) < len(html_body) + 150
130+
131+
def test_safe_parse_json_empty_response(self):
132+
"""Test handling empty response body"""
133+
import json as json_module
134+
135+
mock_response = Mock(spec=httpx.Response)
136+
mock_response.json.side_effect = json_module.JSONDecodeError(
137+
"Expecting value", "", 0
138+
)
139+
mock_response.text = ""
140+
mock_response.status_code = 500
141+
142+
with pytest.raises(RuntimeError) as exc_info:
143+
LingoDotDevEngine._safe_parse_json(mock_response)
144+
145+
assert "status 500" in str(exc_info.value)
146+
147+
def test_safe_parse_json_malformed_json(self):
148+
"""Test handling malformed JSON response"""
149+
import json as json_module
150+
151+
mock_response = Mock(spec=httpx.Response)
152+
mock_response.json.side_effect = json_module.JSONDecodeError(
153+
"Expecting value", '{"data": incomplete', 8
154+
)
155+
mock_response.text = '{"data": incomplete'
156+
mock_response.status_code = 200
157+
158+
with pytest.raises(RuntimeError) as exc_info:
159+
LingoDotDevEngine._safe_parse_json(mock_response)
160+
161+
assert "Failed to parse API response as JSON" in str(exc_info.value)
162+
163+
164+
@pytest.mark.asyncio
165+
class TestErrorHandlingIntegration:
166+
"""Integration tests for error handling with mocked HTTP responses"""
167+
168+
def setup_method(self):
169+
"""Set up test fixtures"""
170+
self.config = {"api_key": "test_api_key", "api_url": "https://api.test.com"}
171+
self.engine = LingoDotDevEngine(self.config)
172+
173+
@patch("lingodotdev.engine.httpx.AsyncClient.post")
174+
async def test_localize_chunk_502_html_response(self, mock_post):
175+
"""Test that 502 with HTML body raises clean RuntimeError"""
176+
html_body = "<html><body><h1>502 Bad Gateway</h1></body></html>"
177+
mock_response = Mock()
178+
mock_response.is_success = False
179+
mock_response.status_code = 502
180+
mock_response.reason_phrase = "Bad Gateway"
181+
mock_response.text = html_body
182+
mock_post.return_value = mock_response
183+
184+
with pytest.raises(RuntimeError) as exc_info:
185+
await self.engine._localize_chunk(
186+
"en", "es", {"data": {"key": "value"}}, "workflow_id", False
187+
)
188+
189+
error_msg = str(exc_info.value)
190+
assert "Server error (502)" in error_msg
191+
assert "Bad Gateway" in error_msg
192+
assert "temporary service issues" in error_msg
193+
194+
@patch("lingodotdev.engine.httpx.AsyncClient.post")
195+
async def test_localize_chunk_success_but_html_response(self, mock_post):
196+
"""Test handling when server returns 200 but with HTML body (edge case)"""
197+
import json as json_module
198+
199+
mock_response = Mock()
200+
mock_response.is_success = True
201+
mock_response.status_code = 200
202+
mock_response.json.side_effect = json_module.JSONDecodeError(
203+
"Expecting value", "<html>Unexpected HTML</html>", 0
204+
)
205+
mock_response.text = "<html>Unexpected HTML</html>"
206+
mock_post.return_value = mock_response
207+
208+
with pytest.raises(RuntimeError) as exc_info:
209+
await self.engine._localize_chunk(
210+
"en", "es", {"data": {"key": "value"}}, "workflow_id", False
211+
)
212+
213+
assert "Failed to parse API response as JSON" in str(exc_info.value)
214+
215+
@patch("lingodotdev.engine.httpx.AsyncClient.post")
216+
async def test_recognize_locale_502_html_response(self, mock_post):
217+
"""Test recognize_locale handles 502 HTML gracefully"""
218+
mock_response = Mock()
219+
mock_response.is_success = False
220+
mock_response.status_code = 502
221+
mock_response.reason_phrase = "Bad Gateway"
222+
mock_response.text = "<html><body>502 Bad Gateway</body></html>"
223+
mock_post.return_value = mock_response
224+
225+
with pytest.raises(RuntimeError) as exc_info:
226+
await self.engine.recognize_locale("Hello world")
227+
228+
error_msg = str(exc_info.value)
229+
assert "Server error (502)" in error_msg
230+
231+
@patch("lingodotdev.engine.httpx.AsyncClient.post")
232+
async def test_whoami_502_html_response(self, mock_post):
233+
"""Test whoami handles 502 HTML gracefully"""
234+
mock_response = Mock()
235+
mock_response.is_success = False
236+
mock_response.status_code = 502
237+
mock_response.reason_phrase = "Bad Gateway"
238+
mock_response.text = "<html><body>502 Bad Gateway</body></html>"
239+
mock_post.return_value = mock_response
240+
241+
with pytest.raises(RuntimeError) as exc_info:
242+
await self.engine.whoami()
243+
244+
error_msg = str(exc_info.value)
245+
assert "Server error (502)" in error_msg
246+
247+
@patch("lingodotdev.engine.httpx.AsyncClient.post")
248+
async def test_error_message_truncation_in_api_call(self, mock_post):
249+
"""Test that large HTML error pages are truncated in error messages"""
250+
large_html = "<html>" + "x" * 1000 + "</html>"
251+
mock_response = Mock()
252+
mock_response.is_success = False
253+
mock_response.status_code = 503
254+
mock_response.reason_phrase = "Service Unavailable"
255+
mock_response.text = large_html
256+
mock_post.return_value = mock_response
257+
258+
with pytest.raises(RuntimeError) as exc_info:
259+
await self.engine._localize_chunk(
260+
"en", "es", {"data": {"key": "value"}}, "workflow_id", False
261+
)
262+
263+
error_msg = str(exc_info.value)
264+
# Error message should be much shorter than the full HTML
265+
assert len(error_msg) < 500
266+
assert "..." in error_msg # Truncation indicator
267+
268+
58269
@pytest.mark.asyncio
59270
class TestLingoDotDevEngine:
60271
"""Test the LingoDotDevEngine class"""
@@ -164,6 +375,7 @@ async def test_localize_chunk_bad_request(self, mock_post):
164375
mock_response.is_success = False
165376
mock_response.status_code = 400
166377
mock_response.reason_phrase = "Bad Request"
378+
mock_response.text = "Invalid parameters"
167379
mock_post.return_value = mock_response
168380

169381
with pytest.raises(ValueError, match="Invalid request \\(400\\)"):
@@ -284,6 +496,7 @@ async def test_recognize_locale_server_error(self, mock_post):
284496
mock_response.is_success = False
285497
mock_response.status_code = 500
286498
mock_response.reason_phrase = "Internal Server Error"
499+
mock_response.text = "Server error details"
287500
mock_post.return_value = mock_response
288501

289502
with pytest.raises(RuntimeError, match="Server error"):
@@ -324,6 +537,7 @@ async def test_whoami_server_error(self, mock_post):
324537
mock_response.is_success = False
325538
mock_response.status_code = 500
326539
mock_response.reason_phrase = "Internal Server Error"
540+
mock_response.text = "Server error details"
327541
mock_post.return_value = mock_response
328542

329543
with pytest.raises(RuntimeError, match="Server error"):

tests/test_integration.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,25 @@
99

1010
from lingodotdev import LingoDotDevEngine
1111

12-
1312
# Skip integration tests if no API key is provided
1413
pytestmark = pytest.mark.skipif(
1514
not os.getenv("LINGODOTDEV_API_KEY"),
1615
reason="Integration tests require LINGODOTDEV_API_KEY environment variable",
1716
)
1817

18+
# Check if running in CI environment
19+
IS_CI = (
20+
os.getenv("CI", "false").lower() == "true"
21+
or os.getenv("GITHUB_ACTIONS") is not None
22+
)
23+
1924

2025
@pytest.mark.asyncio
26+
@pytest.mark.xfail(
27+
IS_CI,
28+
reason="Real API tests may fail in CI due to intermittent server errors (502)",
29+
strict=False,
30+
)
2131
class TestRealAPIIntegration:
2232
"""Integration tests against the real API"""
2333

0 commit comments

Comments
 (0)