From e691cd27c8ebb9320488738fb5f126166ac0101e Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Tue, 17 Jun 2025 09:07:18 -0300 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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