diff --git a/CHANGELOG.md b/CHANGELOG.md index e5935a9..4a46246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ v6.11.0 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..c111dec 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -18,7 +18,31 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: - json = response.json() + try: + 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( + "", + NylasApiErrorResponseData( + type="network_error", + message=f""" + HTTP {response.status_code}: Non-JSON response received{flow_info}. + Body: {body_preview}""", + ), + ), + status_code=response.status_code, + headers=response.headers, + ) from exc + return ({}, response.headers) if response.status_code >= 400: parsed_url = urlparse(response.url) try: @@ -27,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) @@ -46,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(): @@ -109,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 diff --git a/nylas/models/errors.py b/nylas/models/errors.py index 43e02d4..47af304 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) @@ -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..dd63079 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -432,3 +432,127 @@ 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 (flow_id: fastly-123). + Body:

Internal Server Error

""" + 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. + Body: Bad Gateway""" + 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. + Body: """ + 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" + 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