From e691cd27c8ebb9320488738fb5f126166ac0101e Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Tue, 17 Jun 2025 09:07:18 -0300 Subject: [PATCH 1/7] CUST-4514 added handling for non-json responses and tests --- CHANGELOG.md | 1 + nylas/handler/http_client.py | 20 +++++++++- tests/handler/test_http_client.py | 64 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f7ecc..8953e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog Unreleased ---------------- +* Added handling for non-JSON responses * Added support for `earliest_message_date` query parameter for threads * Fixed `earliest_message_date` not being an optional response field * Added support for new message fields query parameter values: `include_tracking_options` and `raw_mime` diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index 76bcd7f..a143140 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -18,7 +18,25 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: - json = response.json() + try: + json = response.json() + except ValueError: + if response.status_code >= 400: + response_text = response.text[:500] if response.text else "" + raise NylasApiError( + NylasApiErrorResponse( + None, + NylasApiErrorResponseData( + type="server_error", + message=f"HTTP {response.status_code}: {response_text}", + ), + ), + status_code=response.status_code, + headers=response.headers, + ) + else: + return ({}, response.headers) + if response.status_code >= 400: parsed_url = urlparse(response.url) try: diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index 9fb0684..ae004b4 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -432,3 +432,67 @@ def test_execute_with_headers(self, http_client, patched_version_and_sys, patche timeout=30, data=None, ) + + def test_validate_response_500_error_html(self): + response = Mock() + response.status_code = 500 + response.json.side_effect = ValueError("No JSON object could be decoded") + response.text = "

Internal Server Error

" + response.headers = {"Content-Type": "text/html"} + + with pytest.raises(NylasApiError) as e: + _validate_response(response) + assert e.value.type == "server_error" + assert "HTTP 500:" in str(e.value) + assert "" in str(e.value) + assert e.value.status_code == 500 + + def test_validate_response_502_error_plain_text(self): + response = Mock() + response.status_code = 502 + response.json.side_effect = ValueError("No JSON object could be decoded") + response.text = "Bad Gateway" + response.headers = {"Content-Type": "text/plain"} + + with pytest.raises(NylasApiError) as e: + _validate_response(response) + assert e.value.type == "server_error" + assert "HTTP 502: Bad Gateway" == str(e.value) + assert e.value.status_code == 502 + + def test_validate_response_200_success_non_json(self): + response = Mock() + response.status_code = 200 + response.json.side_effect = ValueError("No JSON object could be decoded") + response.headers = {"Content-Type": "text/plain"} + + response_json, response_headers = _validate_response(response) + assert response_json == {} + assert response_headers == {"Content-Type": "text/plain"} + + def test_validate_response_error_empty_response(self): + response = Mock() + response.status_code = 500 + response.json.side_effect = ValueError("No JSON object could be decoded") + response.text = "" + response.headers = {"Content-Type": "text/html"} + + with pytest.raises(NylasApiError) as e: + _validate_response(response) + assert e.value.type == "server_error" + assert str(e.value) == "HTTP 500: " + assert e.value.status_code == 500 + + def test_validate_response_error_long_response_truncated(self): + response = Mock() + response.status_code = 500 + response.json.side_effect = ValueError("No JSON object could be decoded") + response.text = "A" * 600 + response.headers = {"Content-Type": "text/html"} + + with pytest.raises(NylasApiError) as e: + _validate_response(response) + assert e.value.type == "server_error" + assert len(str(e.value)) == len("HTTP 500: ") + 500 + assert str(e.value).endswith("A" * 500) + assert e.value.status_code == 500 From ebedcce8ef9411ab17b406f6f80aa7de5632a83f Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Tue, 17 Jun 2025 09:29:20 -0300 Subject: [PATCH 2/7] CUST-4514 added handling for non-json responses and tests --- nylas/handler/http_client.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index a143140..f4d26fe 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -20,7 +20,7 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: try: json = response.json() - except ValueError: + except ValueError as exc: if response.status_code >= 400: response_text = response.text[:500] if response.text else "" raise NylasApiError( @@ -33,10 +33,8 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: ), status_code=response.status_code, headers=response.headers, - ) - else: - return ({}, response.headers) - + ) from exc + return ({}, response.headers) if response.status_code >= 400: parsed_url = urlparse(response.url) try: From 445901e213f0233a1f54d8b94338a5d4a6af5acc Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Fri, 20 Jun 2025 13:43:59 -0300 Subject: [PATCH 3/7] CUST-4514 Added new error class for non-json response handling, maintained backwards compatibility --- nylas/handler/http_client.py | 5 ++-- nylas/models/errors.py | 44 +++++++++++++++++++++++++++++++ tests/handler/test_http_client.py | 22 +++++++--------- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index f4d26fe..5ed57de 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -22,13 +22,12 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: json = response.json() except ValueError as exc: if response.status_code >= 400: - response_text = response.text[:500] if response.text else "" raise NylasApiError( NylasApiErrorResponse( None, NylasApiErrorResponseData( - type="server_error", - message=f"HTTP {response.status_code}: {response_text}", + type="network_error", + message=f"HTTP {response.status_code}: Non-JSON response received", ), ), status_code=response.status_code, diff --git a/nylas/models/errors.py b/nylas/models/errors.py index 43e02d4..10bcbd7 100644 --- a/nylas/models/errors.py +++ b/nylas/models/errors.py @@ -169,3 +169,47 @@ def __init__(self, url: str, timeout: int, headers: Optional[CaseInsensitiveDict self.url: str = url self.timeout: int = timeout self.headers: CaseInsensitiveDict = headers + + +class NylasNetworkError(AbstractNylasSdkError): + """ + Error thrown when the SDK receives a non-JSON response with an error status code. + This typically happens when the request never reaches the Nylas API due to + infrastructure issues (e.g., proxy errors, load balancer failures). + + Note: This error class will be used in v7.0 to replace NylasApiError for non-JSON + HTTP error responses. Currently, non-JSON errors still throw NylasApiError with + type="network_error" for backwards compatibility. + + Attributes: + request_id: The unique identifier of the request. + status_code: The HTTP status code of the error response. + raw_body: The non-JSON response body. + headers: The headers returned from the server. + flow_id: The value from x-fastly-id header if present. + """ + + def __init__( + self, + message: str, + request_id: Optional[str] = None, + status_code: Optional[int] = None, + raw_body: Optional[str] = None, + headers: Optional[CaseInsensitiveDict] = None, + flow_id: Optional[str] = None, + ): + """ + Args: + message: The error message. + request_id: The unique identifier of the request. + status_code: The HTTP status code of the error response. + raw_body: The non-JSON response body. + headers: The headers returned from the server. + flow_id: The value from x-fastly-id header if present. + """ + super().__init__(message) + self.request_id: Optional[str] = request_id + self.status_code: Optional[int] = status_code + self.raw_body: Optional[str] = raw_body + self.headers: Optional[CaseInsensitiveDict] = headers + self.flow_id: Optional[str] = flow_id diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index ae004b4..4601cc8 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -438,13 +438,12 @@ def test_validate_response_500_error_html(self): response.status_code = 500 response.json.side_effect = ValueError("No JSON object could be decoded") response.text = "

Internal Server Error

" - response.headers = {"Content-Type": "text/html"} + response.headers = {"Content-Type": "text/html", "x-fastly-id": "fastly-123"} with pytest.raises(NylasApiError) as e: _validate_response(response) - assert e.value.type == "server_error" - assert "HTTP 500:" in str(e.value) - assert "" in str(e.value) + assert e.value.type == "network_error" + assert str(e.value) == "HTTP 500: Non-JSON response received" assert e.value.status_code == 500 def test_validate_response_502_error_plain_text(self): @@ -456,8 +455,8 @@ def test_validate_response_502_error_plain_text(self): with pytest.raises(NylasApiError) as e: _validate_response(response) - assert e.value.type == "server_error" - assert "HTTP 502: Bad Gateway" == str(e.value) + assert e.value.type == "network_error" + assert str(e.value) == "HTTP 502: Non-JSON response received" assert e.value.status_code == 502 def test_validate_response_200_success_non_json(self): @@ -479,11 +478,11 @@ def test_validate_response_error_empty_response(self): with pytest.raises(NylasApiError) as e: _validate_response(response) - assert e.value.type == "server_error" - assert str(e.value) == "HTTP 500: " + assert e.value.type == "network_error" + assert str(e.value) == "HTTP 500: Non-JSON response received" assert e.value.status_code == 500 - def test_validate_response_error_long_response_truncated(self): + def test_validate_response_error_long_response_not_truncated(self): response = Mock() response.status_code = 500 response.json.side_effect = ValueError("No JSON object could be decoded") @@ -492,7 +491,6 @@ def test_validate_response_error_long_response_truncated(self): with pytest.raises(NylasApiError) as e: _validate_response(response) - assert e.value.type == "server_error" - assert len(str(e.value)) == len("HTTP 500: ") + 500 - assert str(e.value).endswith("A" * 500) + assert e.value.type == "network_error" + assert str(e.value) == "HTTP 500: Non-JSON response received" assert e.value.status_code == 500 From da421ada9371f4b62024c2c6186c56775326d0d7 Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Fri, 20 Jun 2025 14:35:51 -0300 Subject: [PATCH 4/7] CUST-4514 Added new error class for non-json response handling, maintained backwards compatibility --- nylas/models/errors.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nylas/models/errors.py b/nylas/models/errors.py index 10bcbd7..863fee7 100644 --- a/nylas/models/errors.py +++ b/nylas/models/errors.py @@ -28,9 +28,9 @@ def __init__( status_code: The HTTP status code of the error response. message: The error message. """ - self.request_id: str = request_id - self.status_code: int = status_code - self.headers: CaseInsensitiveDict = headers + self.request_id: Optional[str] = request_id + self.status_code: Optional[int] = status_code + self.headers: Optional[CaseInsensitiveDict] = headers super().__init__(message) @@ -70,7 +70,7 @@ class NylasApiErrorResponse: error: The error data. """ - request_id: str + request_id: Optional[str] error: NylasApiErrorResponseData From 338844e33f5784afe28ba90b0861b123a21bfee0 Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Thu, 28 Aug 2025 00:17:31 -0300 Subject: [PATCH 5/7] Added tests, corrected request_id not being optional, added more information to non-json error --- nylas/handler/http_client.py | 8 +++-- nylas/models/errors.py | 2 +- tests/handler/test_http_client.py | 56 ++++++++++++++++++++++++++++--- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index 5ed57de..7b484be 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -22,12 +22,16 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: json = response.json() except ValueError as exc: if response.status_code >= 400: + body_preview = response.text[:200] + "..." if len(response.text) > 200 else response.text + flow_id = response.headers.get("x-fastly-id", "") + flow_info = f" (flow_id: {flow_id})" if flow_id else "" + raise NylasApiError( NylasApiErrorResponse( - None, + "", NylasApiErrorResponseData( type="network_error", - message=f"HTTP {response.status_code}: Non-JSON response received", + message=f"HTTP {response.status_code}: Non-JSON response received{flow_info}. Body: {body_preview}", ), ), status_code=response.status_code, diff --git a/nylas/models/errors.py b/nylas/models/errors.py index 863fee7..47af304 100644 --- a/nylas/models/errors.py +++ b/nylas/models/errors.py @@ -70,7 +70,7 @@ class NylasApiErrorResponse: error: The error data. """ - request_id: Optional[str] + request_id: str error: NylasApiErrorResponseData diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index 4601cc8..dfc57b9 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -443,7 +443,7 @@ def test_validate_response_500_error_html(self): with pytest.raises(NylasApiError) as e: _validate_response(response) assert e.value.type == "network_error" - assert str(e.value) == "HTTP 500: Non-JSON response received" + assert str(e.value) == "HTTP 500: Non-JSON response received (flow_id: fastly-123). Body:

Internal Server Error

" assert e.value.status_code == 500 def test_validate_response_502_error_plain_text(self): @@ -456,7 +456,7 @@ def test_validate_response_502_error_plain_text(self): with pytest.raises(NylasApiError) as e: _validate_response(response) assert e.value.type == "network_error" - assert str(e.value) == "HTTP 502: Non-JSON response received" + assert str(e.value) == "HTTP 502: Non-JSON response received. Body: Bad Gateway" assert e.value.status_code == 502 def test_validate_response_200_success_non_json(self): @@ -479,7 +479,7 @@ def test_validate_response_error_empty_response(self): with pytest.raises(NylasApiError) as e: _validate_response(response) assert e.value.type == "network_error" - assert str(e.value) == "HTTP 500: Non-JSON response received" + assert str(e.value) == "HTTP 500: Non-JSON response received. Body: " assert e.value.status_code == 500 def test_validate_response_error_long_response_not_truncated(self): @@ -492,5 +492,53 @@ def test_validate_response_error_long_response_not_truncated(self): with pytest.raises(NylasApiError) as e: _validate_response(response) assert e.value.type == "network_error" - assert str(e.value) == "HTTP 500: Non-JSON response received" + expected_body = "A" * 200 + "..." + assert str(e.value) == f"HTTP 500: Non-JSON response received. Body: {expected_body}" assert e.value.status_code == 500 + + def test_validate_response_with_flow_id_header(self): + response = Mock() + response.status_code = 503 + response.json.side_effect = ValueError("No JSON object could be decoded") + response.text = "Service Unavailable" + response.headers = {"x-fastly-id": "ABC123DEF456"} + + with pytest.raises(NylasApiError) as e: + _validate_response(response) + assert e.value.type == "network_error" + assert str(e.value) == "HTTP 503: Non-JSON response received (flow_id: ABC123DEF456). Body: Service Unavailable" + assert e.value.status_code == 503 + + def test_validate_response_without_flow_id_header(self): + response = Mock() + response.status_code = 504 + response.json.side_effect = ValueError("No JSON object could be decoded") + response.text = "Gateway Timeout" + response.headers = {"Content-Type": "text/plain"} + + with pytest.raises(NylasApiError) as e: + _validate_response(response) + assert e.value.type == "network_error" + assert str(e.value) == "HTTP 504: Non-JSON response received. Body: Gateway Timeout" + assert e.value.status_code == 504 + + def test_validate_response_different_content_types(self): + content_types = [ + ("text/html", "

Error

"), + ("text/plain", "Plain text error"), + ("application/xml", ""), + ("text/css", "body { color: red; }"), + ] + + for content_type, body in content_types: + response = Mock() + response.status_code = 500 + response.json.side_effect = ValueError("No JSON object could be decoded") + response.text = body + response.headers = {"Content-Type": content_type} + + with pytest.raises(NylasApiError) as e: + _validate_response(response) + assert e.value.type == "network_error" + assert str(e.value) == f"HTTP 500: Non-JSON response received. Body: {body}" + assert e.value.status_code == 500 From 24ca2867acbc9b9e0b0918ed0dbbdd092af7a487 Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Thu, 28 Aug 2025 00:21:00 -0300 Subject: [PATCH 6/7] fixed linter errors --- nylas/handler/http_client.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index 7b484be..c111dec 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -22,16 +22,21 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: json = response.json() except ValueError as exc: if response.status_code >= 400: - body_preview = response.text[:200] + "..." if len(response.text) > 200 else response.text + body_preview = ( + response.text[:200] + "..." + if len(response.text) > 200 + else response.text + ) flow_id = response.headers.get("x-fastly-id", "") flow_info = f" (flow_id: {flow_id})" if flow_id else "" - raise NylasApiError( NylasApiErrorResponse( "", NylasApiErrorResponseData( type="network_error", - message=f"HTTP {response.status_code}: Non-JSON response received{flow_info}. Body: {body_preview}", + message=f""" + HTTP {response.status_code}: Non-JSON response received{flow_info}. + Body: {body_preview}""", ), ), status_code=response.status_code, @@ -46,7 +51,9 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: or "connect/revoke" in parsed_url.path ): parsed_error = NylasOAuthErrorResponse.from_dict(json) - raise NylasOAuthError(parsed_error, response.status_code, response.headers) + raise NylasOAuthError( + parsed_error, response.status_code, response.headers + ) parsed_error = NylasApiErrorResponse.from_dict(json) raise NylasApiError(parsed_error, response.status_code, response.headers) @@ -65,6 +72,7 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: ) from exc return (json, response.headers) + def _build_query_params(base_url: str, query_params: dict = None) -> str: query_param_parts = [] for key, value in query_params.items(): @@ -128,7 +136,7 @@ def _execute_download_request( query_params=None, stream=False, overrides=None, - ) -> Union[bytes, Response,dict]: + ) -> Union[bytes, Response, dict]: request = self._build_request("GET", path, headers, query_params, overrides) timeout = self.timeout From 1bfc7d7e823637b26d409aad790d630b30fc2a0d Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Thu, 28 Aug 2025 00:24:48 -0300 Subject: [PATCH 7/7] fixed linter errors --- tests/handler/test_http_client.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index dfc57b9..dd63079 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -443,7 +443,9 @@ def test_validate_response_500_error_html(self): with pytest.raises(NylasApiError) as e: _validate_response(response) assert e.value.type == "network_error" - assert str(e.value) == "HTTP 500: Non-JSON response received (flow_id: fastly-123). Body:

Internal Server Error

" + assert str(e.value) == """ + HTTP 500: Non-JSON response received (flow_id: fastly-123). + Body:

Internal Server Error

""" assert e.value.status_code == 500 def test_validate_response_502_error_plain_text(self): @@ -456,7 +458,9 @@ def test_validate_response_502_error_plain_text(self): with pytest.raises(NylasApiError) as e: _validate_response(response) assert e.value.type == "network_error" - assert str(e.value) == "HTTP 502: Non-JSON response received. Body: Bad Gateway" + assert str(e.value) == """ + HTTP 502: Non-JSON response received. + Body: Bad Gateway""" assert e.value.status_code == 502 def test_validate_response_200_success_non_json(self): @@ -479,7 +483,9 @@ def test_validate_response_error_empty_response(self): with pytest.raises(NylasApiError) as e: _validate_response(response) assert e.value.type == "network_error" - assert str(e.value) == "HTTP 500: Non-JSON response received. Body: " + assert str(e.value) == """ + HTTP 500: Non-JSON response received. + Body: """ assert e.value.status_code == 500 def test_validate_response_error_long_response_not_truncated(self): @@ -493,7 +499,9 @@ def test_validate_response_error_long_response_not_truncated(self): _validate_response(response) assert e.value.type == "network_error" expected_body = "A" * 200 + "..." - assert str(e.value) == f"HTTP 500: Non-JSON response received. Body: {expected_body}" + assert str(e.value) == f""" + HTTP 500: Non-JSON response received. + Body: {expected_body}""" assert e.value.status_code == 500 def test_validate_response_with_flow_id_header(self): @@ -506,7 +514,9 @@ def test_validate_response_with_flow_id_header(self): with pytest.raises(NylasApiError) as e: _validate_response(response) assert e.value.type == "network_error" - assert str(e.value) == "HTTP 503: Non-JSON response received (flow_id: ABC123DEF456). Body: Service Unavailable" + assert str(e.value) == """ + HTTP 503: Non-JSON response received (flow_id: ABC123DEF456). + Body: Service Unavailable""" assert e.value.status_code == 503 def test_validate_response_without_flow_id_header(self): @@ -519,7 +529,9 @@ def test_validate_response_without_flow_id_header(self): with pytest.raises(NylasApiError) as e: _validate_response(response) assert e.value.type == "network_error" - assert str(e.value) == "HTTP 504: Non-JSON response received. Body: Gateway Timeout" + assert str(e.value) == """ + HTTP 504: Non-JSON response received. + Body: Gateway Timeout""" assert e.value.status_code == 504 def test_validate_response_different_content_types(self): @@ -540,5 +552,7 @@ def test_validate_response_different_content_types(self): with pytest.raises(NylasApiError) as e: _validate_response(response) assert e.value.type == "network_error" - assert str(e.value) == f"HTTP 500: Non-JSON response received. Body: {body}" + assert str(e.value) == f""" + HTTP 500: Non-JSON response received. + Body: {body}""" assert e.value.status_code == 500