|
6 | 6 | import asyncio |
7 | 7 | from unittest.mock import Mock, patch, AsyncMock |
8 | 8 |
|
| 9 | +import httpx |
| 10 | + |
9 | 11 | from lingodotdev import LingoDotDevEngine |
10 | 12 | from lingodotdev.engine import EngineConfig |
11 | 13 |
|
@@ -55,6 +57,215 @@ def test_invalid_ideal_batch_item_size(self): |
55 | 57 | EngineConfig(api_key="test_key", ideal_batch_item_size=3000) |
56 | 58 |
|
57 | 59 |
|
| 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 | + |
58 | 269 | @pytest.mark.asyncio |
59 | 270 | class TestLingoDotDevEngine: |
60 | 271 | """Test the LingoDotDevEngine class""" |
@@ -164,6 +375,7 @@ async def test_localize_chunk_bad_request(self, mock_post): |
164 | 375 | mock_response.is_success = False |
165 | 376 | mock_response.status_code = 400 |
166 | 377 | mock_response.reason_phrase = "Bad Request" |
| 378 | + mock_response.text = "Invalid parameters" |
167 | 379 | mock_post.return_value = mock_response |
168 | 380 |
|
169 | 381 | with pytest.raises(ValueError, match="Invalid request \\(400\\)"): |
@@ -284,6 +496,7 @@ async def test_recognize_locale_server_error(self, mock_post): |
284 | 496 | mock_response.is_success = False |
285 | 497 | mock_response.status_code = 500 |
286 | 498 | mock_response.reason_phrase = "Internal Server Error" |
| 499 | + mock_response.text = "Server error details" |
287 | 500 | mock_post.return_value = mock_response |
288 | 501 |
|
289 | 502 | with pytest.raises(RuntimeError, match="Server error"): |
@@ -324,6 +537,7 @@ async def test_whoami_server_error(self, mock_post): |
324 | 537 | mock_response.is_success = False |
325 | 538 | mock_response.status_code = 500 |
326 | 539 | mock_response.reason_phrase = "Internal Server Error" |
| 540 | + mock_response.text = "Server error details" |
327 | 541 | mock_post.return_value = mock_response |
328 | 542 |
|
329 | 543 | with pytest.raises(RuntimeError, match="Server error"): |
|
0 commit comments