diff --git a/resend/async_request.py b/resend/async_request.py index 3b792ed..2634c15 100644 --- a/resend/async_request.py +++ b/resend/async_request.py @@ -34,15 +34,32 @@ def __init__( self.files = files self.data = data self._response_headers: Dict[str, str] = {} + self._response_status_code: Optional[int] = None async def perform(self) -> Union[T, None]: data = await self.make_request(url=f"{resend.api_url}{self.path}") - if isinstance(data, dict) and data.get("statusCode") not in (None, 200): + body_status_code = data.get("statusCode") if isinstance(data, dict) else None + error_code = ( + self._response_status_code + if self._response_status_code is not None + and self._response_status_code >= 400 + else body_status_code + ) + + if error_code not in (None, 200): raise_for_code_and_type( - code=data.get("statusCode") or 500, - message=data.get("message", "Unknown error"), - error_type=data.get("name", "InternalServerError"), + code=error_code or 500, + message=( + data.get("message", "Unknown error") + if isinstance(data, dict) + else "Unknown error" + ), + error_type=( + data.get("name", "InternalServerError") + if isinstance(data, dict) + else "InternalServerError" + ), headers=self._response_headers, ) @@ -112,7 +129,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: @@ -127,6 +144,12 @@ 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) + self._response_status_code = status_code + + # 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", "" @@ -134,9 +157,9 @@ async def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: 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 +172,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..e7e2783 100644 --- a/resend/request.py +++ b/resend/request.py @@ -33,15 +33,32 @@ def __init__( self.files = files self.data = data self._response_headers: Dict[str, str] = {} + self._response_status_code: Optional[int] = None def perform(self) -> Union[T, None]: data = self.make_request(url=f"{resend.api_url}{self.path}") - if isinstance(data, dict) and data.get("statusCode") not in (None, 200): + body_status_code = data.get("statusCode") if isinstance(data, dict) else None + error_code = ( + self._response_status_code + if self._response_status_code is not None + and self._response_status_code >= 400 + else body_status_code + ) + + if error_code not in (None, 200): raise_for_code_and_type( - code=data.get("statusCode") or 500, - message=data.get("message", "Unknown error"), - error_type=data.get("name", "InternalServerError"), + code=error_code or 500, + message=( + data.get("message", "Unknown error") + if isinstance(data, dict) + else "Unknown error" + ), + error_type=( + data.get("name", "InternalServerError") + if isinstance(data, dict) + else "InternalServerError" + ), headers=self._response_headers, ) @@ -99,7 +116,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: @@ -112,6 +129,12 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: # Store response headers for later access self._response_headers = dict(resp_headers) + self._response_status_code = status_code + + # 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", "" @@ -119,9 +142,9 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: 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 +157,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..56e2b3d 100644 --- a/tests/request_test.py +++ b/tests/request_test.py @@ -1,8 +1,12 @@ 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, RateLimitError, ResendError, + ValidationError) from resend.version import get_version @@ -67,3 +71,201 @@ 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") + + @patch("resend.http_client_requests.requests.request") + @patch("resend.api_key", new="test_key") + def test_json_error_uses_http_status_when_body_omits_status_code( + self, mock_requests: MagicMock + ) -> None: + mock_response = Mock() + mock_response.content = ( + b'{"name":"rate_limit_exceeded","message":"Too many requests"}' + ) + mock_response.status_code = 429 + mock_response.headers = { + "content-type": "application/json", + "retry-after": "2", + } + mock_requests.return_value = mock_response + + req = request.Request[Dict[str, Any]]( + path="/emails", + params={}, + verb="post", + ) + + with self.assertRaises(RateLimitError) as ctx: + req.perform() + + err = ctx.exception + self.assertEqual(err.code, 429) + self.assertEqual(err.error_type, "rate_limit_exceeded") + self.assertEqual(err.message, "Too many requests") + self.assertEqual(err.headers.get("retry-after"), "2") + + +@pytest.mark.asyncio +class TestResendAsyncRequestHttpStatusErrors: + 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 + + async def test_async_json_error_uses_http_status_when_body_omits_status_code( + self, + ) -> None: + import resend + from resend import async_request + + mock_client = AsyncMock() + mock_client.request.return_value = ( + b'{"name":"validation_error","message":"Invalid event payload"}', + 422, + {"content-type": "application/json"}, + ) + + 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="/events/send", + params={}, + verb="post", + ) + with pytest.raises(ValidationError) as ctx: + await req.perform() + + err = ctx.value + assert err.code == 422 + assert err.error_type == "validation_error" + assert err.message == "Invalid event payload" + finally: + resend.default_async_http_client = original