From 43bd958b23e7f732cf40e75818db0635ae84b21c Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 17 Jun 2026 20:50:21 -0700 Subject: [PATCH] fix: handle missing error element in server error responses Fixes #1083 Co-Authored-By: Claude Sonnet 4.6 --- .../server/endpoint/exceptions.py | 23 +++++-- test/test_endpoint.py | 68 +++++++++++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 9d30ce331..684b03dd6 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -29,13 +29,22 @@ def from_response(cls, resp, ns, url): # Check elements exist before .text parsed_response = fromstring(resp) try: - error_response = cls( - parsed_response.find("t:error", namespaces=ns).get("code", ""), - parsed_response.find(".//t:summary", namespaces=ns).text, - parsed_response.find(".//t:detail", namespaces=ns).text, - url, - ) - except Exception as e: + error_element = parsed_response.find("t:error", namespaces=ns) + summary_element = parsed_response.find(".//t:summary", namespaces=ns) + detail_element = parsed_response.find(".//t:detail", namespaces=ns) + + # Guard against responses that don't contain a t:error element + if error_element is None: + raw = resp.decode("utf-8", errors="replace") if isinstance(resp, bytes) else str(resp) + error_response = cls("", raw, raw, url) + else: + error_response = cls( + error_element.get("code", ""), + summary_element.text if summary_element is not None else "", + detail_element.text if detail_element is not None else "", + url, + ) + except Exception: raise NonXMLResponseError(resp) return error_response diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 0b852ab0e..e4670d1fc 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -4,6 +4,11 @@ import tableauserverclient as TSC from tableauserverclient.server.endpoint import Endpoint +from tableauserverclient.server.endpoint.exceptions import ( + FailedSignInError, + NonXMLResponseError, + ServerResponseError, +) import requests_mock @@ -91,3 +96,66 @@ def test_set_user_agent_when_blank(server: TSC.Server) -> None: params = {"headers": {}} # type: ignore result = Endpoint.set_user_agent(params) assert result["headers"]["User-Agent"].startswith("Tableau Server Client") + + +# --- ServerResponseError / FailedSignInError exception parsing (issue #1083) --- + +NS = {"t": "http://tableau.com/api"} + +STANDARD_ERROR_XML = b""" + + + Unauthorized Access + Invalid credentials were provided. + +""" + +NO_ERROR_ELEMENT_XML = b""" + + Something went wrong but with no error element +""" + +NOT_XML_CONTENT = b"Internal Server Error (not XML at all)" + + +def test_server_response_error_standard_xml(): + """Standard XML with a t:error element parses code/summary/detail correctly.""" + err = ServerResponseError.from_response(STANDARD_ERROR_XML, NS, "http://test/") + assert err.code == "401002" + assert "Unauthorized" in err.summary + assert "Invalid credentials" in err.detail + + +def test_server_response_error_no_error_element_does_not_raise(): + """XML without a t:error element must not raise AttributeError (issue #1083).""" + err = ServerResponseError.from_response(NO_ERROR_ELEMENT_XML, NS, "http://test/") + assert err.code == "" + # The raw XML content should appear in summary/detail as the fallback + assert "Something went wrong" in err.summary or len(err.summary) > 0 + + +def test_server_response_error_not_xml_raises_parse_error(): + """Non-XML content causes fromstring to raise a ParseError (not AttributeError).""" + import xml.etree.ElementTree as ET + + with pytest.raises(ET.ParseError): + ServerResponseError.from_response(NOT_XML_CONTENT, NS, "http://test/") + + +def test_failed_sign_in_error_no_error_element_does_not_raise(): + """FailedSignInError shares from_response — same None guard must apply.""" + err = FailedSignInError.from_response(NO_ERROR_ELEMENT_XML, NS, "http://test/") + assert err.code == "" + assert isinstance(err, FailedSignInError) + + +def test_server_response_error_missing_summary_and_detail(): + """XML with t:error but missing summary/detail children falls back gracefully.""" + xml = b""" + + +""" + err = ServerResponseError.from_response(xml, NS, "http://test/") + assert err.code == "500001" + assert err.summary == "" + assert err.detail == ""