From 8451c2d3c4ed82d46bd6c2fe780da08bf4bc4ec1 Mon Sep 17 00:00:00 2001 From: mayankbohradev Date: Sat, 11 Jul 2026 17:39:30 +0530 Subject: [PATCH] fix: preserve HTTP status for non-JSON API errors When the response body is not usable JSON (CDN HTML, empty 5xx, proxies), use the real HTTP status for 4xx/5xx instead of always raising a fake 500. Mirror sync and async request paths. --- resend/async_request.py | 15 +++-- resend/request.py | 15 +++-- tests/emails_test.py | 24 +------ tests/request_test.py | 140 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 161 insertions(+), 33 deletions(-) diff --git a/resend/async_request.py b/resend/async_request.py index 3b792ed..9989529 100644 --- a/resend/async_request.py +++ b/resend/async_request.py @@ -112,7 +112,7 @@ async def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: if self.data is not None: kwargs["data"] = self.data - content, _status_code, resp_headers = await async_client.request(**kwargs) + content, status_code, resp_headers = await async_client.request(**kwargs) # Safety net around the HTTP Client except ResendError: @@ -128,15 +128,20 @@ async def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: # Store response headers for later access self._response_headers = dict(resp_headers) + # When the body is not usable JSON (CDN HTML, empty 5xx, proxies), the + # HTTP status is the only trustworthy signal. Keep it for 4xx/5xx; + # fall back to 500 if the status looks successful but the body is not. + error_code = status_code if status_code >= 400 else 500 + content_type = {k.lower(): v for k, v in resp_headers.items()}.get( "content-type", "" ) if "application/json" not in content_type: raise_for_code_and_type( - code=500, + code=error_code, message=f"Expected JSON response but got: {content_type}", - error_type="InternalServerError", + error_type="application_error", headers=self._response_headers, ) @@ -149,8 +154,8 @@ async def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: return parsed_data except json.JSONDecodeError: raise_for_code_and_type( - code=500, + code=error_code, message="Failed to decode JSON response", - error_type="InternalServerError", + error_type="application_error", headers=self._response_headers, ) diff --git a/resend/request.py b/resend/request.py index 7e3bcef..ae3d5b3 100644 --- a/resend/request.py +++ b/resend/request.py @@ -99,7 +99,7 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: if self.data is not None: kwargs["data"] = self.data - content, _status_code, resp_headers = sync_client.request(**kwargs) + content, status_code, resp_headers = sync_client.request(**kwargs) # Safety net around the HTTP Client except Exception as e: @@ -113,15 +113,20 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: # Store response headers for later access self._response_headers = dict(resp_headers) + # When the body is not usable JSON (CDN HTML, empty 5xx, proxies), the + # HTTP status is the only trustworthy signal. Keep it for 4xx/5xx; + # fall back to 500 if the status looks successful but the body is not. + error_code = status_code if status_code >= 400 else 500 + content_type = {k.lower(): v for k, v in resp_headers.items()}.get( "content-type", "" ) if "application/json" not in content_type: raise_for_code_and_type( - code=500, + code=error_code, message=f"Expected JSON response but got: {content_type}", - error_type="InternalServerError", + error_type="application_error", headers=self._response_headers, ) @@ -134,8 +139,8 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: return parsed_data except json.JSONDecodeError: raise_for_code_and_type( - code=500, + code=error_code, message="Failed to decode JSON response", - error_type="InternalServerError", + error_type="application_error", headers=self._response_headers, ) diff --git a/tests/emails_test.py b/tests/emails_test.py index f1363bc..cc94caa 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -1,15 +1,14 @@ -from unittest.mock import MagicMock, Mock +from unittest.mock import Mock import resend from resend import EmailsReceiving -from resend.exceptions import NoContentError, ResendError +from resend.exceptions import NoContentError from tests.conftest import ResendBaseTest # flake8: noqa class TestResendEmail(ResendBaseTest): - def test_email_send_with_from(self) -> None: self.set_mock_json( { @@ -68,25 +67,6 @@ def test_should_get_email_raise_exception_when_no_content(self) -> None: email_id="4ef9a417-02e9-4d39-ad75-9611e0fcc33c", ) - def test_email_response_html(self) -> None: - self.set_magic_mock_obj( - MagicMock( - status_code=200, - headers={"content-type": "text/html; charset=utf-8"}, - text="hello, world!", - ) - ) - params: resend.Emails.SendParams = { - "to": "to@email.com", - "from": "from@email.com", - "subject": "subject", - "html": "html", - } - try: - _ = resend.Emails.send(params) - except ResendError as e: - assert e.message == "Failed to parse Resend API response. Please try again." - def test_update_email(self) -> None: self.set_mock_json( { diff --git a/tests/request_test.py b/tests/request_test.py index 6369b1d..571cda3 100644 --- a/tests/request_test.py +++ b/tests/request_test.py @@ -1,8 +1,11 @@ import unittest from typing import Any, Dict -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest from resend import request +from resend.exceptions import ApplicationError, ResendError from resend.version import get_version @@ -67,3 +70,138 @@ def test_request_idempotency_key_is_not_set(self, mock_requests: MagicMock) -> N self.assertNotIn( "Idempotency-Key", headers, "Idempotency-Key should not be set" ) + + @patch("resend.http_client_requests.requests.request") + @patch("resend.api_key", new="test_key") + def test_non_json_preserves_http_status_when_client_error( + self, mock_requests: MagicMock + ) -> None: + mock_response = Mock() + mock_response.content = b"rate limited" + mock_response.status_code = 429 + mock_response.headers = { + "Content-Type": "text/html", + "retry-after": "2", + } + mock_requests.return_value = mock_response + + req = request.Request[Dict[str, Any]]( + path="/emails", + params={}, + verb="post", + ) + + with self.assertRaises(ResendError) as ctx: + req.perform() + + err = ctx.exception + self.assertEqual(err.code, 429) + self.assertEqual(err.error_type, "application_error") + self.assertIn("text/html", err.message) + self.assertEqual(err.headers.get("retry-after"), "2") + + @patch("resend.http_client_requests.requests.request") + @patch("resend.api_key", new="test_key") + def test_non_json_preserves_http_status_when_server_error( + self, mock_requests: MagicMock + ) -> None: + mock_response = Mock() + mock_response.content = b"" + mock_response.status_code = 503 + mock_response.headers = {"content-type": "text/plain"} + mock_requests.return_value = mock_response + + req = request.Request[Dict[str, Any]]( + path="/emails", + params={}, + verb="get", + ) + + with self.assertRaises(ResendError) as ctx: + req.perform() + + err = ctx.exception + self.assertEqual(err.code, 503) + self.assertEqual(err.error_type, "application_error") + + @patch("resend.http_client_requests.requests.request") + @patch("resend.api_key", new="test_key") + def test_non_json_falls_back_to_500_when_status_is_success( + self, mock_requests: MagicMock + ) -> None: + mock_response = Mock() + mock_response.content = b"ok?" + mock_response.status_code = 200 + mock_response.headers = {"content-type": "text/html; charset=utf-8"} + mock_requests.return_value = mock_response + + req = request.Request[Dict[str, Any]]( + path="/emails", + params={}, + verb="post", + ) + + with self.assertRaises(ApplicationError) as ctx: + req.perform() + + err = ctx.exception + self.assertEqual(err.code, 500) + self.assertEqual(err.error_type, "application_error") + self.assertIn("text/html", err.message) + + @patch("resend.http_client_requests.requests.request") + @patch("resend.api_key", new="test_key") + def test_invalid_json_preserves_http_status(self, mock_requests: MagicMock) -> None: + mock_response = Mock() + mock_response.content = b"not-json" + mock_response.status_code = 502 + mock_response.headers = {"content-type": "application/json"} + mock_requests.return_value = mock_response + + req = request.Request[Dict[str, Any]]( + path="/emails", + params={}, + verb="get", + ) + + with self.assertRaises(ResendError) as ctx: + req.perform() + + err = ctx.exception + self.assertEqual(err.code, 502) + self.assertEqual(err.error_type, "application_error") + self.assertEqual(err.message, "Failed to decode JSON response") + + +@pytest.mark.asyncio +class TestResendAsyncRequestNonJson: + async def test_async_non_json_preserves_http_status(self) -> None: + + import resend + from resend import async_request + + mock_client = AsyncMock() + mock_client.request.return_value = ( + b"unauthorized", + 401, + {"content-type": "text/html"}, + ) + + original = resend.default_async_http_client + resend.api_key = "test_key" + resend.default_async_http_client = mock_client + try: + req = async_request.AsyncRequest[Dict[str, Any]]( + path="/emails", + params={}, + verb="get", + ) + with pytest.raises(ResendError) as ctx: + await req.perform() + + err = ctx.value + assert err.code == 401 + assert err.error_type == "application_error" + assert "text/html" in err.message + finally: + resend.default_async_http_client = original