From 63b1b2772cdc99662b72f895aa351f0aa2a8379b Mon Sep 17 00:00:00 2001 From: veradri Date: Tue, 25 Nov 2025 10:04:02 -0800 Subject: [PATCH 1/5] Add HTTP status context to XML parsing errors using PEP 678 notes When XML parsing fails due to empty or malformed responses, users now receive the actual HTTP error response from the service alongside the parsing error, providing useful information about what actually happened. Before: ResponseParserError: Unable to parse response (no element found: line 1, column 0), invalid XML received. Further retries may succeed: b'' After: ResponseParserError: Unable to parse response (no element found: line 1, column 0), invalid XML received. Further retries may succeed: b'' HTTP 413: Content Too Large This exposes the real service error (HTTP 413: Content Too Large) that was previously hidden behind cryptic XML parsing failures, giving users actionable information about why their request failed. --- botocore/parsers.py | 20 ++++++++++++++++-- tests/unit/test_parsers.py | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/botocore/parsers.py b/botocore/parsers.py index 9f1e6049d1..ee7bd59797 100644 --- a/botocore/parsers.py +++ b/botocore/parsers.py @@ -592,7 +592,15 @@ def _handle_blob(self, shape, text): class QueryParser(BaseXMLResponseParser): def _do_error_parse(self, response, shape): xml_contents = response['body'] - root = self._parse_xml_string_to_dom(xml_contents) + try: + root = self._parse_xml_string_to_dom(xml_contents) + except ResponseParserError as e: + status_message = http.client.responses.get( + response['status_code'], '' + ) + if status_message: + e.add_note(f"HTTP {response['status_code']}: {status_message}") + raise parsed = self._build_name_to_xml_node(root) self._replace_nodes(parsed) # Once we've converted xml->dict, we need to make one or two @@ -1447,7 +1455,15 @@ def _parse_error_from_http_status(self, response): def _parse_error_from_body(self, response): xml_contents = response['body'] - root = self._parse_xml_string_to_dom(xml_contents) + try: + root = self._parse_xml_string_to_dom(xml_contents) + except ResponseParserError as e: + status_message = http.client.responses.get( + response['status_code'], '' + ) + if status_message: + e.add_note(f"HTTP {response['status_code']}: {status_message}") + raise parsed = self._build_name_to_xml_node(root) self._replace_nodes(parsed) if root.tag == 'Error': diff --git a/tests/unit/test_parsers.py b/tests/unit/test_parsers.py index b2cb425805..daf3ff83be 100644 --- a/tests/unit/test_parsers.py +++ b/tests/unit/test_parsers.py @@ -1621,6 +1621,48 @@ def test_can_parse_route53_with_missing_message(self): # still populate an empty string. self.assertEqual(error['Message'], '') + def test_query_parser_empty_body_4xx_error_with_notes(self): + parser = parsers.QueryParser() + response = { + 'body': b'', + 'headers': { + 'Content-Length': '0', + 'Date': 'Fri, 21 Nov 2025 18:18:28 GMT', + 'Connection': 'close', + }, + 'status_code': 413, + } + + with self.assertRaises(parsers.ResponseParserError) as cm: + parser._do_error_parse(response, None) + + exception = cm.exception + + self.assertTrue(hasattr(exception, '__notes__')) + self.assertEqual(len(exception.__notes__), 1) + self.assertEqual(exception.__notes__[0], "HTTP 413: Content Too Large") + + def test_parse_error_from_body_empty_body_4xx_error_with_notes(self): + parser = parsers.RestXMLParser() + response = { + 'body': b'', + 'headers': { + 'Content-Length': '0', + 'Date': 'Fri, 21 Nov 2025 18:18:28 GMT', + 'Connection': 'close', + }, + 'status_code': 413, + } + + with self.assertRaises(parsers.ResponseParserError) as cm: + parser._parse_error_from_body(response) + + exception = cm.exception + + self.assertTrue(hasattr(exception, '__notes__')) + self.assertEqual(len(exception.__notes__), 1) + self.assertEqual(exception.__notes__[0], "HTTP 413: Content Too Large") + def _generic_test_bodies(): generic_html_body = ( From 94499f76820daf9fb9a18e0810632f8ca7b9f75e Mon Sep 17 00:00:00 2001 From: veradri Date: Tue, 25 Nov 2025 11:28:16 -0800 Subject: [PATCH 2/5] Fix for Fix test assertions to work across Python ver. 3.11 & 3.12 The HTTP 413 status message differs between Python versions: - Python 3.13: "Content Too Large" - Python 3.11: "Request Entity Too Large" Updated test assertions to use assertIn("HTTP 413:") instead of exact string matching to ensure tests pass across supported Python versions while still verifying that HTTP status context is properly added to XML parsing errors. --- tests/unit/test_parsers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_parsers.py b/tests/unit/test_parsers.py index daf3ff83be..986d481bf6 100644 --- a/tests/unit/test_parsers.py +++ b/tests/unit/test_parsers.py @@ -1640,7 +1640,7 @@ def test_query_parser_empty_body_4xx_error_with_notes(self): self.assertTrue(hasattr(exception, '__notes__')) self.assertEqual(len(exception.__notes__), 1) - self.assertEqual(exception.__notes__[0], "HTTP 413: Content Too Large") + self.assertIn("HTTP 413:", exception.__notes__[0]) def test_parse_error_from_body_empty_body_4xx_error_with_notes(self): parser = parsers.RestXMLParser() @@ -1661,7 +1661,7 @@ def test_parse_error_from_body_empty_body_4xx_error_with_notes(self): self.assertTrue(hasattr(exception, '__notes__')) self.assertEqual(len(exception.__notes__), 1) - self.assertEqual(exception.__notes__[0], "HTTP 413: Content Too Large") + self.assertIn("HTTP 413:", exception.__notes__[0]) def _generic_test_bodies(): From 6d3a89f2ddea4b1fdadae360cb24bbb957388656 Mon Sep 17 00:00:00 2001 From: veradri Date: Tue, 25 Nov 2025 11:47:01 -0800 Subject: [PATCH 3/5] Add Python 3.9-3.10 compatibility for PEP 678 exception notes Add hasattr(e, 'add_note') checks before calling add_note() to ensure compatibility with Python 3.9 and 3.10 where PEP 678 exception notes are not available. The enhanced HTTP status context will only be added in Python 3.11+ while maintaining backward compatibility. --- botocore/parsers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/botocore/parsers.py b/botocore/parsers.py index ee7bd59797..37ca73355e 100644 --- a/botocore/parsers.py +++ b/botocore/parsers.py @@ -598,7 +598,7 @@ def _do_error_parse(self, response, shape): status_message = http.client.responses.get( response['status_code'], '' ) - if status_message: + if status_message and hasattr(e, 'add_note'): e.add_note(f"HTTP {response['status_code']}: {status_message}") raise parsed = self._build_name_to_xml_node(root) @@ -1461,7 +1461,7 @@ def _parse_error_from_body(self, response): status_message = http.client.responses.get( response['status_code'], '' ) - if status_message: + if status_message and hasattr(e, 'add_note'): e.add_note(f"HTTP {response['status_code']}: {status_message}") raise parsed = self._build_name_to_xml_node(root) From bb839d732465753266093c0db54de2d58a534546 Mon Sep 17 00:00:00 2001 From: veradri Date: Wed, 26 Nov 2025 08:48:38 -0800 Subject: [PATCH 4/5] Add HTTP status context to XML parsing error messages Enhanced ResponseParserError messages to include HTTP status codes and messages when XML parsing fails, providing better debugging context for users while maintaining full backward compatibility. Changes: - Modified QueryParser._do_error_parse() to append HTTP status context - Modified RestXMLParser._parse_error_from_body() to append HTTP status context - Enhanced error messages format: "original error (HTTP 413: Content Too Large)" - Preserves original ResponseParserError exception type for compatibility - Added comprehensive tests for both parser classes - Tests handle Python version differences in HTTP status messages --- .python-version | 5 +++++ botocore/parsers.py | 23 +++++++++++++---------- tests/unit/test_parsers.py | 22 ++++++++++++++-------- 3 files changed, 32 insertions(+), 18 deletions(-) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000..8d64ba7a55 --- /dev/null +++ b/.python-version @@ -0,0 +1,5 @@ +3.9.20 +3.10.15 +3.11.10 +3.12.7 +3.13.9 diff --git a/botocore/parsers.py b/botocore/parsers.py index 37ca73355e..6cd480875c 100644 --- a/botocore/parsers.py +++ b/botocore/parsers.py @@ -595,11 +595,13 @@ def _do_error_parse(self, response, shape): try: root = self._parse_xml_string_to_dom(xml_contents) except ResponseParserError as e: - status_message = http.client.responses.get( - response['status_code'], '' - ) - if status_message and hasattr(e, 'add_note'): - e.add_note(f"HTTP {response['status_code']}: {status_message}") + status_code = response.get('status_code') + if status_code and status_code in http.client.responses: + status_message = http.client.responses[status_code] + error_msg_with_status = ( + f"{str(e)} (HTTP {status_code}: {status_message})" + ) + raise ResponseParserError(error_msg_with_status) raise parsed = self._build_name_to_xml_node(root) self._replace_nodes(parsed) @@ -1458,11 +1460,12 @@ def _parse_error_from_body(self, response): try: root = self._parse_xml_string_to_dom(xml_contents) except ResponseParserError as e: - status_message = http.client.responses.get( - response['status_code'], '' - ) - if status_message and hasattr(e, 'add_note'): - e.add_note(f"HTTP {response['status_code']}: {status_message}") + status_code = response.get('status_code') + if status_code and status_code in http.client.responses: + status_message = http.client.responses[status_code] + raise ResponseParserError( + f"{str(e)} (HTTP {status_code}: {status_message})" + ) raise parsed = self._build_name_to_xml_node(root) self._replace_nodes(parsed) diff --git a/tests/unit/test_parsers.py b/tests/unit/test_parsers.py index 986d481bf6..a0d2c7eab3 100644 --- a/tests/unit/test_parsers.py +++ b/tests/unit/test_parsers.py @@ -1637,10 +1637,13 @@ def test_query_parser_empty_body_4xx_error_with_notes(self): parser._do_error_parse(response, None) exception = cm.exception - - self.assertTrue(hasattr(exception, '__notes__')) - self.assertEqual(len(exception.__notes__), 1) - self.assertIn("HTTP 413:", exception.__notes__[0]) + self.assertIn("HTTP 413:", str(exception)) + error_msg = str(exception) + self.assertTrue( + "Request Entity Too Large" in error_msg + or "Content Too Large" in error_msg, + f"Expected HTTP 413 message not found in: {error_msg}", + ) def test_parse_error_from_body_empty_body_4xx_error_with_notes(self): parser = parsers.RestXMLParser() @@ -1658,10 +1661,13 @@ def test_parse_error_from_body_empty_body_4xx_error_with_notes(self): parser._parse_error_from_body(response) exception = cm.exception - - self.assertTrue(hasattr(exception, '__notes__')) - self.assertEqual(len(exception.__notes__), 1) - self.assertIn("HTTP 413:", exception.__notes__[0]) + self.assertIn("HTTP 413:", str(exception)) + error_msg = str(exception) + self.assertTrue( + "Request Entity Too Large" in error_msg + or "Content Too Large" in error_msg, + f"Expected HTTP 413 message not found in: {error_msg}", + ) def _generic_test_bodies(): From 854b868b2e585ff42f5454dccb2d382b414db993 Mon Sep 17 00:00:00 2001 From: veradri Date: Wed, 26 Nov 2025 09:42:17 -0800 Subject: [PATCH 5/5] Refactor QueryParser error handling for consistency and clarity - Improve code readability while maintaining identical functionality --- botocore/parsers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/botocore/parsers.py b/botocore/parsers.py index 6cd480875c..b3a5d5a2bf 100644 --- a/botocore/parsers.py +++ b/botocore/parsers.py @@ -598,10 +598,9 @@ def _do_error_parse(self, response, shape): status_code = response.get('status_code') if status_code and status_code in http.client.responses: status_message = http.client.responses[status_code] - error_msg_with_status = ( + raise ResponseParserError( f"{str(e)} (HTTP {status_code}: {status_message})" ) - raise ResponseParserError(error_msg_with_status) raise parsed = self._build_name_to_xml_node(root) self._replace_nodes(parsed)