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
15 changes: 10 additions & 5 deletions resend/async_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
)

Expand All @@ -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,
)
15 changes: 10 additions & 5 deletions resend/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
)

Expand All @@ -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,
)
24 changes: 2 additions & 22 deletions tests/emails_test.py
Original file line number Diff line number Diff line change
@@ -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(
{
Expand Down Expand Up @@ -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="<strong>hello, world!</strong>",
)
)
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(
{
Expand Down
140 changes: 139 additions & 1 deletion tests/request_test.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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"<html>rate limited</html>"
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"<html>ok?</html>"
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"<html>unauthorized</html>",
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
Loading