Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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
Expand Down
33 changes: 30 additions & 3 deletions nylas/handler/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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():
Expand Down Expand Up @@ -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
Expand Down
50 changes: 47 additions & 3 deletions nylas/models/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where are we actually utilizing this new error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nowhere yet, the idea is that it will replace the proposed non-json handling implementation in the future, we currently raise a NylasApiError to keep backwards compatibility as a priority, let me know if we should just use this instead.

"""
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
124 changes: 124 additions & 0 deletions tests/handler/test_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<html><body><h1>Internal Server Error</h1></body></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 == "network_error"
assert str(e.value) == """
HTTP 500: Non-JSON response received (flow_id: fastly-123).
Body: <html><body><h1>Internal Server Error</h1></body></html>"""
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", "<h1>Error</h1>"),
("text/plain", "Plain text error"),
("application/xml", "<?xml version='1.0'?><error/>"),
("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