Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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 @@ -3,6 +3,7 @@ nylas-python Changelog

v6.10.0
----------------
* Added handling for non-JSON responses
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This entry is under v6.10.0, which has already been released. Please move it under an Unreleased section at the top so it lands in the next release notes.

* 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
17 changes: 16 additions & 1 deletion nylas/handler/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silently returning ({}, headers) for a 2xx non-JSON body could mask a real problem (e.g. a malformed JSON body that happens to come back with 200). At minimum add a debug log; better, consider raising for unexpected non-JSON success too, since every JSON endpoint in this SDK should return parseable JSON.

if response.status_code >= 400:
parsed_url = urlparse(response.url)
try:
Expand Down
52 changes: 48 additions & 4 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Per the earlier review thread — these shouldn't become Optional on the base class. Genuine API errors always have a status_code and headers. Keep them required here and let NylasNetworkError declare its own Optionals if needed.

super().__init__(message)


Expand Down Expand Up @@ -70,7 +70,7 @@ class NylasApiErrorResponse:
error: The error data.
"""

request_id: str
request_id: Optional[str]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't believe this is ever optional for our API effors

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, I'll correct this!

error: NylasApiErrorResponseData


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
Copy Markdown
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
Copy Markdown
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This class is defined but never instantiated. Either raise it from _validate_response instead of (or alongside) NylasApiError, or drop it from this PR and add it when the v7.0 work lands. Right now it's just dead code and a maintenance trap.

62 changes: 62 additions & 0 deletions tests/handler/test_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These assert str(e.value) == """...""" checks pin the exact whitespace of the error string and will all break once the multi-line f-string is fixed. Recommend asserting on substrings, e.g.:

assert "HTTP 500" in str(e.value)
assert "flow_id: fastly-123" in str(e.value)
assert "<h1>Internal Server Error</h1>" 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):
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