Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 47 additions & 9 deletions src/lingodotdev/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# mypy: disable-error-code=unreachable

import asyncio
import json
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import urljoin

Expand Down Expand Up @@ -80,6 +81,36 @@ async def close(self):
if self._client and not self._client.is_closed:
await self._client.aclose()

@staticmethod
def _truncate_response(text: str, max_length: int = 200) -> str:
"""Truncate response text for error messages"""
if len(text) > max_length:
return text[:max_length] + "..."
return text

@staticmethod
def _safe_parse_json(response: httpx.Response) -> Dict[str, Any]:
"""
Safely parse JSON response, handling HTML error pages gracefully.

Args:
response: The httpx response object

Returns:
Parsed JSON as a dictionary

Raises:
RuntimeError: If the response cannot be parsed as JSON
"""
try:
return response.json()
except json.JSONDecodeError:
preview = LingoDotDevEngine._truncate_response(response.text)
raise RuntimeError(
f"Failed to parse API response as JSON (status {response.status_code}). "
f"This may indicate a gateway or proxy error. Response: {preview}"
)

async def _localize_raw(
self,
payload: Dict[str, Any],
Expand Down Expand Up @@ -184,19 +215,23 @@ async def _localize_chunk(
response = await self._client.post(url, json=request_data)

if not response.is_success:
response_preview = self._truncate_response(response.text)
if 500 <= response.status_code < 600:
raise RuntimeError(
f"Server error ({response.status_code}): {response.reason_phrase}. "
f"{response.text}. This may be due to temporary service issues."
f"This may be due to temporary service issues. Response: {response_preview}"
)
elif response.status_code == 400:
raise ValueError(
f"Invalid request ({response.status_code}): {response.reason_phrase}"
f"Invalid request ({response.status_code}): {response.reason_phrase}. "
f"Response: {response_preview}"
)
else:
raise RuntimeError(response.text)
raise RuntimeError(
f"Request failed ({response.status_code}): {response_preview}"
)

json_response = response.json()
json_response = self._safe_parse_json(response)

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

if not response.is_success:
response_preview = self._truncate_response(response.text)
if 500 <= response.status_code < 600:
raise RuntimeError(
f"Server error ({response.status_code}): {response.reason_phrase}. "
"This may be due to temporary service issues."
f"This may be due to temporary service issues. Response: {response_preview}"
)
raise RuntimeError(
f"Error recognizing locale: {response.reason_phrase}"
f"Error recognizing locale ({response.status_code}): {response.reason_phrase}. "
f"Response: {response_preview}"
)

json_response = response.json()
json_response = self._safe_parse_json(response)
return json_response.get("locale") or ""

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

if response.is_success:
payload = response.json()
payload = self._safe_parse_json(response)
if payload.get("email"):
return {"email": payload["email"], "id": payload["id"]}

if 500 <= response.status_code < 600:
response_preview = self._truncate_response(response.text)
raise RuntimeError(
f"Server error ({response.status_code}): {response.reason_phrase}. "
"This may be due to temporary service issues."
f"This may be due to temporary service issues. Response: {response_preview}"
)

return None
Expand Down
214 changes: 214 additions & 0 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import asyncio
from unittest.mock import Mock, patch, AsyncMock

import httpx

from lingodotdev import LingoDotDevEngine
from lingodotdev.engine import EngineConfig

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


class TestErrorHandling:
"""Test error handling utilities for non-JSON responses (e.g., 502 HTML errors)"""

def test_truncate_response_short_text(self):
"""Test that short responses are not truncated"""
short_text = "Short error message"
result = LingoDotDevEngine._truncate_response(short_text)
assert result == short_text

def test_truncate_response_long_text(self):
"""Test that long responses are truncated with ellipsis"""
long_text = "x" * 300
result = LingoDotDevEngine._truncate_response(long_text)
assert len(result) == 203 # 200 chars + "..."
assert result.endswith("...")

def test_truncate_response_custom_max_length(self):
"""Test truncation with custom max length"""
text = "x" * 100
result = LingoDotDevEngine._truncate_response(text, max_length=50)
assert len(result) == 53 # 50 chars + "..."
assert result.endswith("...")

def test_truncate_response_exact_length(self):
"""Test text exactly at max length is not truncated"""
text = "x" * 200
result = LingoDotDevEngine._truncate_response(text, max_length=200)
assert result == text
assert not result.endswith("...")

def test_safe_parse_json_valid_json(self):
"""Test parsing valid JSON response"""
mock_response = Mock(spec=httpx.Response)
mock_response.json.return_value = {"data": "test"}

result = LingoDotDevEngine._safe_parse_json(mock_response)
assert result == {"data": "test"}

def test_safe_parse_json_html_response(self):
"""Test handling HTML response (like 502 error page)"""
import json as json_module

# Use a large HTML body (>200 chars) to test truncation
html_body = """<!DOCTYPE html>
<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<p>The server encountered a temporary error and could not complete your request.</p>
<p>Please try again in a few moments. If the problem persists, contact support.</p>
<hr><center>nginx/1.18.0 (Ubuntu)</center>
</body>
</html>"""
mock_response = Mock(spec=httpx.Response)
mock_response.json.side_effect = json_module.JSONDecodeError(
"Expecting value", html_body, 0
)
mock_response.text = html_body
mock_response.status_code = 502

with pytest.raises(RuntimeError) as exc_info:
LingoDotDevEngine._safe_parse_json(mock_response)

error_msg = str(exc_info.value)
assert "Failed to parse API response as JSON" in error_msg
assert "status 502" in error_msg
assert "gateway or proxy error" in error_msg
# Verify HTML is truncated (original is ~400 chars, should be truncated to 200 + ...)
assert "..." in error_msg
assert len(error_msg) < len(html_body) + 150

def test_safe_parse_json_empty_response(self):
"""Test handling empty response body"""
import json as json_module

mock_response = Mock(spec=httpx.Response)
mock_response.json.side_effect = json_module.JSONDecodeError(
"Expecting value", "", 0
)
mock_response.text = ""
mock_response.status_code = 500

with pytest.raises(RuntimeError) as exc_info:
LingoDotDevEngine._safe_parse_json(mock_response)

assert "status 500" in str(exc_info.value)

def test_safe_parse_json_malformed_json(self):
"""Test handling malformed JSON response"""
import json as json_module

mock_response = Mock(spec=httpx.Response)
mock_response.json.side_effect = json_module.JSONDecodeError(
"Expecting value", '{"data": incomplete', 8
)
mock_response.text = '{"data": incomplete'
mock_response.status_code = 200

with pytest.raises(RuntimeError) as exc_info:
LingoDotDevEngine._safe_parse_json(mock_response)

assert "Failed to parse API response as JSON" in str(exc_info.value)


@pytest.mark.asyncio
class TestErrorHandlingIntegration:
"""Integration tests for error handling with mocked HTTP responses"""

def setup_method(self):
"""Set up test fixtures"""
self.config = {"api_key": "test_api_key", "api_url": "https://api.test.com"}
self.engine = LingoDotDevEngine(self.config)

@patch("lingodotdev.engine.httpx.AsyncClient.post")
async def test_localize_chunk_502_html_response(self, mock_post):
"""Test that 502 with HTML body raises clean RuntimeError"""
html_body = "<html><body><h1>502 Bad Gateway</h1></body></html>"
mock_response = Mock()
mock_response.is_success = False
mock_response.status_code = 502
mock_response.reason_phrase = "Bad Gateway"
mock_response.text = html_body
mock_post.return_value = mock_response

with pytest.raises(RuntimeError) as exc_info:
await self.engine._localize_chunk(
"en", "es", {"data": {"key": "value"}}, "workflow_id", False
)

error_msg = str(exc_info.value)
assert "Server error (502)" in error_msg
assert "Bad Gateway" in error_msg
assert "temporary service issues" in error_msg

@patch("lingodotdev.engine.httpx.AsyncClient.post")
async def test_localize_chunk_success_but_html_response(self, mock_post):
"""Test handling when server returns 200 but with HTML body (edge case)"""
import json as json_module

mock_response = Mock()
mock_response.is_success = True
mock_response.status_code = 200
mock_response.json.side_effect = json_module.JSONDecodeError(
"Expecting value", "<html>Unexpected HTML</html>", 0
)
mock_response.text = "<html>Unexpected HTML</html>"
mock_post.return_value = mock_response

with pytest.raises(RuntimeError) as exc_info:
await self.engine._localize_chunk(
"en", "es", {"data": {"key": "value"}}, "workflow_id", False
)

assert "Failed to parse API response as JSON" in str(exc_info.value)

@patch("lingodotdev.engine.httpx.AsyncClient.post")
async def test_recognize_locale_502_html_response(self, mock_post):
"""Test recognize_locale handles 502 HTML gracefully"""
mock_response = Mock()
mock_response.is_success = False
mock_response.status_code = 502
mock_response.reason_phrase = "Bad Gateway"
mock_response.text = "<html><body>502 Bad Gateway</body></html>"
mock_post.return_value = mock_response

with pytest.raises(RuntimeError) as exc_info:
await self.engine.recognize_locale("Hello world")

error_msg = str(exc_info.value)
assert "Server error (502)" in error_msg

@patch("lingodotdev.engine.httpx.AsyncClient.post")
async def test_whoami_502_html_response(self, mock_post):
"""Test whoami handles 502 HTML gracefully"""
mock_response = Mock()
mock_response.is_success = False
mock_response.status_code = 502
mock_response.reason_phrase = "Bad Gateway"
mock_response.text = "<html><body>502 Bad Gateway</body></html>"
mock_post.return_value = mock_response

with pytest.raises(RuntimeError) as exc_info:
await self.engine.whoami()

error_msg = str(exc_info.value)
assert "Server error (502)" in error_msg

@patch("lingodotdev.engine.httpx.AsyncClient.post")
async def test_error_message_truncation_in_api_call(self, mock_post):
"""Test that large HTML error pages are truncated in error messages"""
large_html = "<html>" + "x" * 1000 + "</html>"
mock_response = Mock()
mock_response.is_success = False
mock_response.status_code = 503
mock_response.reason_phrase = "Service Unavailable"
mock_response.text = large_html
mock_post.return_value = mock_response

with pytest.raises(RuntimeError) as exc_info:
await self.engine._localize_chunk(
"en", "es", {"data": {"key": "value"}}, "workflow_id", False
)

error_msg = str(exc_info.value)
# Error message should be much shorter than the full HTML
assert len(error_msg) < 500
assert "..." in error_msg # Truncation indicator


@pytest.mark.asyncio
class TestLingoDotDevEngine:
"""Test the LingoDotDevEngine class"""
Expand Down Expand Up @@ -164,6 +375,7 @@ async def test_localize_chunk_bad_request(self, mock_post):
mock_response.is_success = False
mock_response.status_code = 400
mock_response.reason_phrase = "Bad Request"
mock_response.text = "Invalid parameters"
mock_post.return_value = mock_response

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

with pytest.raises(RuntimeError, match="Server error"):
Expand Down Expand Up @@ -324,6 +537,7 @@ async def test_whoami_server_error(self, mock_post):
mock_response.is_success = False
mock_response.status_code = 500
mock_response.reason_phrase = "Internal Server Error"
mock_response.text = "Server error details"
mock_post.return_value = mock_response

with pytest.raises(RuntimeError, match="Server error"):
Expand Down
12 changes: 11 additions & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,25 @@

from lingodotdev import LingoDotDevEngine


# Skip integration tests if no API key is provided
pytestmark = pytest.mark.skipif(
not os.getenv("LINGODOTDEV_API_KEY"),
reason="Integration tests require LINGODOTDEV_API_KEY environment variable",
)

# Check if running in CI environment
IS_CI = (
os.getenv("CI", "false").lower() == "true"
or os.getenv("GITHUB_ACTIONS") is not None
)


@pytest.mark.asyncio
@pytest.mark.xfail(
IS_CI,
reason="Real API tests may fail in CI due to intermittent server errors (502)",
strict=False,
)
class TestRealAPIIntegration:
"""Integration tests against the real API"""

Expand Down