Skip to content

Commit 9887345

Browse files
authored
Merge pull request #2 from ivanjun10r/bugfix/1-json-decode-error
🐛 Fix: Prevent JSONDecodeError from masking HTTP errors in non-JSON responses
2 parents ea69934 + fec701e commit 9887345

5 files changed

Lines changed: 239 additions & 9 deletions

File tree

.bumpversion.cfg

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[bumpversion]
2+
current_version = 0.1.0
3+
commit = True
4+
tag = True
5+
6+
[bumpversion:file:VERSION]
7+
8+
[bumpversion:file:pyproject.toml]
9+
10+
[bumpversion:file:src/api_pgd_client/__init__.py]

HISTORY.rst

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22
History
33
=======
44

5+
Unreleased
6+
----------
7+
8+
**Bug Fixes**
9+
10+
* Fixed JSONDecodeError masking HTTP errors in non-JSON responses
11+
* Improved error handling for 504 Gateway Timeout and 502 Bad Gateway errors
12+
* Added proper fallback to response.text when JSON parsing fails
13+
* Enhanced error messages to show actual HTTP status codes and response content
14+
515
0.1.0 (2025-04-24)
616
------------------
717

8-
* First release on PyPI.
18+
* First release.

VERSION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0.1.0

src/api_pgd_client/client.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,52 @@ def _do_request(self, method_name: str, url: str, **kwargs: Any) -> Any:
4949
try:
5050
response.raise_for_status()
5151
except requests.HTTPError as exc:
52-
content = json.dumps(response.json(), ensure_ascii=False, indent=4)
53-
raise self.get_error_class()(
54-
f"Error while trying to do a {method_name.upper()} request.\n"
55-
f"Status code: {response.status_code}\n"
56-
f"Response:\n{content}"
57-
) from exc
52+
error_message = self._build_error_message(method_name, response)
53+
raise self.get_error_class()(error_message) from exc
5854
return json.loads(response.content) if response.content else None
5955

56+
def _build_error_message(
57+
self, method_name: str, response: requests.Response
58+
) -> str:
59+
"""
60+
Constrói uma mensagem de erro detalhada a partir da resposta HTTP.
61+
62+
Tenta extrair o conteúdo JSON da resposta quando possível, mas trata
63+
adequadamente casos onde a resposta não é um JSON válido (como erros
64+
504 Gateway Timeout que retornam páginas HTML ou respostas vazias).
65+
66+
Args:
67+
method_name: Nome do método HTTP (GET, POST, PUT, DELETE)
68+
response: Objeto Response do requests
69+
70+
Returns:
71+
String formatada com informações do erro
72+
"""
73+
content = self._extract_response_content(response)
74+
return (
75+
f"Error while trying to do a {method_name.upper()} request.\n"
76+
f"Status code: {response.status_code}\n"
77+
f"Response:\n{content}"
78+
)
79+
80+
def _extract_response_content(self, response: requests.Response) -> str:
81+
"""
82+
Extrai o conteúdo da resposta HTTP de forma segura.
83+
84+
Tenta primeiro parsear como JSON (para APIs que retornam erros estruturados),
85+
mas faz fallback para texto bruto em caso de falha no parsing.
86+
87+
Args:
88+
response: Objeto Response do requests
89+
90+
Returns:
91+
Conteúdo da resposta formatado como string
92+
"""
93+
try:
94+
return json.dumps(response.json(), ensure_ascii=False, indent=4)
95+
except (ValueError, requests.exceptions.JSONDecodeError):
96+
return response.text or "<empty response body>"
97+
6098
@abc.abstractmethod
6199
def get_error_class(self) -> Any:
62100
pass

tests/test_client.py

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -972,5 +972,176 @@ def test_retry_on_expired_token_deveria_lancar_erro(self):
972972
self.api_client.retry_on_expired_token(mock_method)
973973
mock_method.assert_called_once()
974974

975-
def test_sss(self):
976-
pass
975+
976+
class ErrorHandlingTestCase(TestCase):
977+
"""Testes para os novos métodos de tratamento de erro implementados."""
978+
979+
def setUp(self):
980+
self.request = ConcreteRequest()
981+
982+
def test_extract_response_content_valid_json(self):
983+
"""Testa extração de conteúdo quando response contém JSON válido."""
984+
mock_response = mock.MagicMock()
985+
mock_response.json.return_value = {"error": "Not found", "code": 404}
986+
987+
content = self.request._extract_response_content(mock_response)
988+
989+
expected_content = json.dumps(
990+
{"error": "Not found", "code": 404}, ensure_ascii=False, indent=4
991+
)
992+
self.assertEqual(content, expected_content)
993+
mock_response.json.assert_called_once()
994+
995+
def test_extract_response_content_json_decode_error(self):
996+
"""Testa extração quando response.json() lança JSONDecodeError."""
997+
mock_response = mock.MagicMock()
998+
mock_response.json.side_effect = requests.exceptions.JSONDecodeError(
999+
"Expecting value", "", 0
1000+
)
1001+
mock_response.text = "Gateway Timeout Error"
1002+
1003+
content = self.request._extract_response_content(mock_response)
1004+
1005+
self.assertEqual(content, "Gateway Timeout Error")
1006+
mock_response.json.assert_called_once()
1007+
1008+
def test_extract_response_content_value_error(self):
1009+
"""Testa extração quando response.json() lança ValueError."""
1010+
mock_response = mock.MagicMock()
1011+
mock_response.json.side_effect = ValueError("Invalid JSON")
1012+
mock_response.text = "<html><body>502 Bad Gateway</body></html>"
1013+
1014+
content = self.request._extract_response_content(mock_response)
1015+
1016+
self.assertEqual(content, "<html><body>502 Bad Gateway</body></html>")
1017+
mock_response.json.assert_called_once()
1018+
1019+
def test_extract_response_content_empty_response(self):
1020+
"""Testa extração quando response está vazio (caso típico de 504)."""
1021+
mock_response = mock.MagicMock()
1022+
mock_response.json.side_effect = requests.exceptions.JSONDecodeError(
1023+
"Expecting value", "", 0
1024+
)
1025+
mock_response.text = ""
1026+
1027+
content = self.request._extract_response_content(mock_response)
1028+
1029+
self.assertEqual(content, "<empty response body>")
1030+
1031+
def test_extract_response_content_none_text(self):
1032+
"""Testa extração quando response.text é None."""
1033+
mock_response = mock.MagicMock()
1034+
mock_response.json.side_effect = requests.exceptions.JSONDecodeError(
1035+
"Expecting value", "", 0
1036+
)
1037+
mock_response.text = None
1038+
1039+
content = self.request._extract_response_content(mock_response)
1040+
1041+
self.assertEqual(content, "<empty response body>")
1042+
1043+
def test_build_error_message_complete(self):
1044+
"""Testa construção completa da mensagem de erro."""
1045+
mock_response = mock.MagicMock()
1046+
mock_response.status_code = 504
1047+
mock_response.json.side_effect = requests.exceptions.JSONDecodeError(
1048+
"Expecting value", "", 0
1049+
)
1050+
mock_response.text = "Gateway Timeout"
1051+
1052+
error_message = self.request._build_error_message("PUT", mock_response)
1053+
1054+
expected_message = (
1055+
"Error while trying to do a PUT request.\n"
1056+
"Status code: 504\n"
1057+
"Response:\nGateway Timeout"
1058+
)
1059+
self.assertEqual(error_message, expected_message)
1060+
1061+
def test_build_error_message_with_json_response(self):
1062+
"""Testa construção da mensagem quando response tem JSON válido."""
1063+
mock_response = mock.MagicMock()
1064+
mock_response.status_code = 400
1065+
mock_response.json.return_value = {
1066+
"detail": "Invalid parameter",
1067+
"field": "email",
1068+
}
1069+
1070+
error_message = self.request._build_error_message("POST", mock_response)
1071+
1072+
expected_json = json.dumps(
1073+
{"detail": "Invalid parameter", "field": "email"},
1074+
ensure_ascii=False,
1075+
indent=4,
1076+
)
1077+
expected_message = (
1078+
"Error while trying to do a POST request.\n"
1079+
"Status code: 400\n"
1080+
f"Response:\n{expected_json}"
1081+
)
1082+
self.assertEqual(error_message, expected_message)
1083+
1084+
@mock.patch("api_pgd_client.client.requests.put")
1085+
def test_do_request_http_error_empty_response_integration(self, mock_put):
1086+
"""Teste de integração: erro HTTP com resposta vazia (cenário 504)."""
1087+
mock_response = mock.MagicMock()
1088+
mock_response.status_code = 504
1089+
mock_response.raise_for_status.side_effect = requests.HTTPError(
1090+
"504 Gateway Timeout"
1091+
)
1092+
mock_response.json.side_effect = requests.exceptions.JSONDecodeError(
1093+
"Expecting value", "", 0
1094+
)
1095+
mock_response.text = ""
1096+
mock_put.return_value = mock_response
1097+
1098+
with self.assertRaises(ConcreteRequest.Error) as context:
1099+
self.request.do_put("http://example.com", {"data": "test"}, {})
1100+
1101+
error_message = str(context.exception)
1102+
self.assertIn("Error while trying to do a PUT request", error_message)
1103+
self.assertIn("Status code: 504", error_message)
1104+
self.assertIn("<empty response body>", error_message)
1105+
1106+
@mock.patch("api_pgd_client.client.requests.get")
1107+
def test_do_request_http_error_html_response_integration(self, mock_get):
1108+
"""Teste de integração: erro HTTP com resposta HTML (cenário 502)."""
1109+
mock_response = mock.MagicMock()
1110+
mock_response.status_code = 502
1111+
mock_response.raise_for_status.side_effect = requests.HTTPError(
1112+
"502 Bad Gateway"
1113+
)
1114+
mock_response.json.side_effect = requests.exceptions.JSONDecodeError(
1115+
"Expecting value", "", 0
1116+
)
1117+
mock_response.text = "<html><body><h1>502 Bad Gateway</h1></body></html>"
1118+
mock_get.return_value = mock_response
1119+
1120+
with self.assertRaises(ConcreteRequest.Error) as context:
1121+
self.request.do_get("http://example.com", {}, {})
1122+
1123+
error_message = str(context.exception)
1124+
self.assertIn("Error while trying to do a GET request", error_message)
1125+
self.assertIn("Status code: 502", error_message)
1126+
self.assertIn(
1127+
"<html><body><h1>502 Bad Gateway</h1></body></html>", error_message
1128+
)
1129+
1130+
@mock.patch("api_pgd_client.client.requests.post")
1131+
def test_do_request_http_error_json_response_integration(self, mock_post):
1132+
"""Teste de integração: erro HTTP com resposta JSON válida (comportamento original)."""
1133+
mock_response = mock.MagicMock()
1134+
mock_response.status_code = 400
1135+
mock_response.raise_for_status.side_effect = requests.HTTPError(
1136+
"400 Bad Request"
1137+
)
1138+
mock_response.json.return_value = {"detail": "Invalid email format"}
1139+
mock_post.return_value = mock_response
1140+
1141+
with self.assertRaises(ConcreteRequest.Error) as context:
1142+
self.request.do_post("http://example.com", {"email": "invalid"}, {})
1143+
1144+
error_message = str(context.exception)
1145+
self.assertIn("Error while trying to do a POST request", error_message)
1146+
self.assertIn("Status code: 400", error_message)
1147+
self.assertIn('"detail": "Invalid email format"', error_message)

0 commit comments

Comments
 (0)