diff --git a/CHANGELOG.md b/CHANGELOG.md index a34e818..4a10b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog v6.10.0 ---------------- +* Added handling for non-JSON responses * Added support for `single_level` query parameter in `ListFolderQueryParams` for Microsoft accounts to control folder hierarchy traversal * Added support for `earliest_message_date` query parameter for threads * Fixed `earliest_message_date` not being an optional response field diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index 76bcd7f..5ed57de 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -18,7 +18,22 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: - json = response.json() + try: + json = response.json() + except ValueError as exc: + if response.status_code >= 400: + raise NylasApiError( + NylasApiErrorResponse( + None, + NylasApiErrorResponseData( + type="network_error", + message=f"HTTP {response.status_code}: Non-JSON response received", + ), + ), + status_code=response.status_code, + headers=response.headers, + ) from exc + return ({}, response.headers) if response.status_code >= 400: parsed_url = urlparse(response.url) try: diff --git a/nylas/models/errors.py b/nylas/models/errors.py index 43e02d4..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 @@ -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 9fb0684..4601cc8 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -432,3 +432,65 @@ 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", "x-fastly-id": "fastly-123"} + + 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 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 == "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): + 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 == "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_not_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 == "network_error" + assert str(e.value) == "HTTP 500: Non-JSON response received" + assert e.value.status_code == 500