Skip to content

Commit 1ac3969

Browse files
semantic-releaseHellnight2005
authored andcommitted
1.3.0
Automatically generated by python-semantic-release
1 parent b7bc8d3 commit 1ac3969

File tree

4 files changed

+136
-12
lines changed

4 files changed

+136
-12
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "lingodotdev"
7-
version = "1.2.0"
7+
version = "1.3.0"
88
description = "Lingo.dev Python SDK"
99
readme = "README.md"
1010
requires-python = ">=3.8"

src/lingodotdev/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
plain text, objects, chat sequences, and HTML documents.
66
"""
77

8-
__version__ = "1.2.0"
8+
__version__ = "1.3.0"
99

1010
from .engine import LingoDotDevEngine
1111

src/lingodotdev/engine.py

Lines changed: 71 additions & 10 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,31 @@ 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:
220+
error_details = ""
221+
try:
222+
error_json = response.json()
223+
if isinstance(error_json, dict) and "error" in error_json:
224+
error_details = f" {error_json['error']}"
225+
except Exception:
226+
pass
227+
188228
raise RuntimeError(
189-
f"Server error ({response.status_code}): {response.reason_phrase}. "
190-
f"{response.text}. This may be due to temporary service issues."
229+
f"Server error ({response.status_code}): {response.reason_phrase}.{error_details} "
230+
"This may be due to temporary service issues."
191231
)
192232
elif response.status_code == 400:
193233
raise ValueError(
194-
f"Invalid request ({response.status_code}): {response.reason_phrase}"
234+
f"Invalid request ({response.status_code}): {response.reason_phrase}. "
235+
f"Response: {response_preview}"
195236
)
196237
else:
197-
raise RuntimeError(response.text)
238+
raise RuntimeError(
239+
f"Request failed ({response.status_code}): {response_preview}"
240+
)
198241

199-
json_response = response.json()
242+
json_response = self._safe_parse_json(response)
200243

201244
# Handle streaming errors
202245
if not json_response.get("data") and json_response.get("error"):
@@ -426,16 +469,26 @@ async def recognize_locale(self, text: str) -> str:
426469
response = await self._client.post(url, json={"text": text})
427470

428471
if not response.is_success:
472+
response_preview = self._truncate_response(response.text)
429473
if 500 <= response.status_code < 600:
474+
error_details = ""
475+
try:
476+
error_json = response.json()
477+
if isinstance(error_json, dict) and "error" in error_json:
478+
error_details = f" {error_json['error']}"
479+
except Exception:
480+
pass
481+
430482
raise RuntimeError(
431-
f"Server error ({response.status_code}): {response.reason_phrase}. "
483+
f"Server error ({response.status_code}): {response.reason_phrase}.{error_details} "
432484
"This may be due to temporary service issues."
433485
)
434486
raise RuntimeError(
435-
f"Error recognizing locale: {response.reason_phrase}"
487+
f"Error recognizing locale ({response.status_code}): {response.reason_phrase}. "
488+
f"Response: {response_preview}"
436489
)
437490

438-
json_response = response.json()
491+
json_response = self._safe_parse_json(response)
439492
return json_response.get("locale") or ""
440493

441494
except httpx.RequestError as e:
@@ -456,13 +509,21 @@ async def whoami(self) -> Optional[Dict[str, str]]:
456509
response = await self._client.post(url)
457510

458511
if response.is_success:
459-
payload = response.json()
512+
payload = self._safe_parse_json(response)
460513
if payload.get("email"):
461514
return {"email": payload["email"], "id": payload["id"]}
462515

463516
if 500 <= response.status_code < 600:
517+
error_details = ""
518+
try:
519+
error_json = response.json()
520+
if isinstance(error_json, dict) and "error" in error_json:
521+
error_details = f" {error_json['error']}"
522+
except Exception:
523+
pass
524+
464525
raise RuntimeError(
465-
f"Server error ({response.status_code}): {response.reason_phrase}. "
526+
f"Server error ({response.status_code}): {response.reason_phrase}.{error_details} "
466527
"This may be due to temporary service issues."
467528
)
468529

tests/test_502_handling.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import pytest
2+
import json
3+
from unittest.mock import Mock, patch
4+
from lingodotdev import LingoDotDevEngine
5+
6+
7+
@pytest.mark.asyncio
8+
async def test_502_html_handling():
9+
"""Test that 502 errors with HTML bodies are sanitized"""
10+
config = {"api_key": "test_key", "api_url": "https://api.test.com"}
11+
12+
html_body = "<html><body>" + ("<h1>502 Bad Gateway</h1>" * 50) + "</body></html>"
13+
assert len(html_body) > 200 # Ensure it triggers truncation
14+
15+
with patch("lingodotdev.engine.httpx.AsyncClient.post") as mock_post:
16+
mock_response = Mock()
17+
mock_response.is_success = False
18+
mock_response.status_code = 502
19+
mock_response.reason_phrase = "Bad Gateway"
20+
mock_response.text = html_body
21+
mock_response.json.side_effect = ValueError(
22+
"Not JSON"
23+
) # simulating non-JSON response
24+
mock_post.return_value = mock_response
25+
26+
async with LingoDotDevEngine(config) as engine:
27+
with pytest.raises(RuntimeError) as exc_info:
28+
await engine.localize_text("hello", {"target_locale": "es"})
29+
30+
error_msg = str(exc_info.value)
31+
32+
# Assertions
33+
assert "Server error (502): Bad Gateway." in error_msg
34+
assert "This may be due to temporary service issues." in error_msg
35+
assert "Response:" not in error_msg
36+
assert "<html>" not in error_msg
37+
assert "<body>" not in error_msg
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_500_json_handling():
42+
"""Test that 500 errors with JSON bodies are preserved"""
43+
config = {"api_key": "test_key", "api_url": "https://api.test.com"}
44+
error_json = {"error": "Specific internal error message"}
45+
46+
with patch("lingodotdev.engine.httpx.AsyncClient.post") as mock_post:
47+
mock_response = Mock()
48+
mock_response.is_success = False
49+
mock_response.status_code = 500
50+
mock_response.reason_phrase = "Internal Server Error"
51+
mock_response.text = json.dumps(error_json) # Needed for response_preview
52+
mock_response.json.return_value = error_json
53+
mock_post.return_value = mock_response
54+
55+
async with LingoDotDevEngine(config) as engine:
56+
with pytest.raises(RuntimeError) as exc_info:
57+
await engine.localize_text("hello", {"target_locale": "es"})
58+
59+
error_msg = str(exc_info.value)
60+
61+
# Assertions
62+
assert "Server error (500): Internal Server Error." in error_msg
63+
assert "Specific internal error message" in error_msg

0 commit comments

Comments
 (0)