From 870b449930afb8d64b9348df13f0ffbdcfb3016b Mon Sep 17 00:00:00 2001 From: Derich Pacheco Date: Sun, 25 May 2025 21:51:23 -0300 Subject: [PATCH 1/7] feat: init custom http client support --- Dockerfile | 2 + resend/__init__.py | 5 +++ resend/http_client.py | 14 ++++++ resend/http_client_requests.py | 29 +++++++++++++ resend/request.py | 79 +++++++++++++++------------------- 5 files changed, 84 insertions(+), 45 deletions(-) create mode 100644 resend/http_client.py create mode 100644 resend/http_client_requests.py diff --git a/Dockerfile b/Dockerfile index 583ae9b..c92c98b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,8 @@ ADD requirements.txt requirements.txt RUN pip install -r requirements.txt RUN pip install tox +RUN pip install setuptools==68.2.2 +RUN pip install wheel ENV APP_HOME /app diff --git a/resend/__init__.py b/resend/__init__.py index 773817f..455b393 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -15,6 +15,8 @@ from .emails._email import Email from .emails._emails import Emails from .emails._tag import Tag +from .http_client import HTTPClient +from .http_client_requests import RequestsClient from .request import Request from .version import __version__, get_version @@ -22,6 +24,9 @@ api_key = os.environ.get("RESEND_API_KEY") api_url = os.environ.get("RESEND_API_URL", "https://api.resend.com") +# HTTP Client +default_http_client: HTTPClient = RequestsClient() + # API resources from .emails._emails import Emails # noqa diff --git a/resend/http_client.py b/resend/http_client.py new file mode 100644 index 0000000..7021b49 --- /dev/null +++ b/resend/http_client.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod +from typing import Any, Mapping, Optional, Tuple, Union + + +class HTTPClient(ABC): + @abstractmethod + def request( + self, + method: str, + url: str, + headers: Mapping[str, str], + json: Optional[Union[dict, list]] = None, + ) -> Tuple[bytes, int, Mapping[str, str]]: + pass diff --git a/resend/http_client_requests.py b/resend/http_client_requests.py new file mode 100644 index 0000000..40b55de --- /dev/null +++ b/resend/http_client_requests.py @@ -0,0 +1,29 @@ +from typing import Mapping, Optional, Tuple, Union + +import requests + +from resend.http_client import HTTPClient + + +class RequestsClient(HTTPClient): + def __init__(self, timeout: int = 30): + self._timeout = timeout + + def request( + self, + method: str, + url: str, + headers: Mapping[str, str], + json: Optional[Union[dict, list]] = None, + ) -> Tuple[bytes, int, Mapping[str, str]]: + try: + resp = requests.request( + method=method, + url=url, + headers=headers, + json=json, + timeout=self._timeout, + ) + return resp.content, resp.status_code, resp.headers + except requests.RequestException as e: + raise RuntimeError(f"Request failed: {e}") from e diff --git a/resend/request.py b/resend/request.py index f0dfaa9..b97b381 100644 --- a/resend/request.py +++ b/resend/request.py @@ -1,6 +1,7 @@ from typing import Any, Dict, Generic, List, Optional, Union, cast import requests +import json from typing_extensions import Literal, TypeVar import resend @@ -27,41 +28,23 @@ def __init__( self.options = options def perform(self) -> Union[T, None]: - """Is the main function that makes the HTTP request - to the Resend API. It uses the path, params, and verb attributes - to make the request. + data = self.make_request(url=f"{resend.api_url}{self.path}") - Returns: - Union[T, None]: A generic type of the Request class or None - - Raises: - requests.HTTPError: If the request fails - """ - resp = self.make_request(url=f"{resend.api_url}{self.path}") - - # delete calls do not return a body - if resp.text == "" and resp.status_code == 200: + if self.verb == "delete": return None - # this is a safety net, if we get here it means the Resend API is having issues - # and most likely the gateway is returning htmls - if "application/json" not in resp.headers["content-type"]: + if ( + isinstance(data, dict) + and data.get("statusCode") + and data.get("statusCode") != 200 + ): raise_for_code_and_type( - code=500, - message="Failed to parse Resend API response. Please try again.", - error_type="InternalServerError", + code=data.get("statusCode") or 500, + message=data.get("message", "Unknown error"), + error_type=data.get("name", "InternalServerError"), ) - # handle error in case there is a statusCode attr present - # and status != 200 and response is a json. - if resp.status_code != 200 and resp.json().get("statusCode"): - error = resp.json() - raise_for_code_and_type( - code=error.get("statusCode"), - message=error.get("message"), - error_type=error.get("name"), - ) - return cast(T, resp.json()) + return cast(T, data) def perform_with_content(self) -> T: """ @@ -97,24 +80,30 @@ def __get_headers(self) -> Dict[Any, Any]: headers["Idempotency-Key"] = self.options["idempotency_key"] return headers - def make_request(self, url: str) -> requests.Response: - """make_request is a helper function that makes the actual - HTTP request to the Resend API. + def make_request(self, url: str) -> Dict[str, Any]: + headers = self.__get_headers() - Args: - url (str): The URL to make the request to + content, status_code, resp_headers = resend.default_http_client.request( + method=self.verb, + url=url, + headers=headers, + json=self.params, + ) - Returns: - requests.Response: The response object from the request + content_type = resp_headers.get("Content-Type", "") - Raises: - requests.HTTPError: If the request fails - """ - headers = self.__get_headers() - params = self.params - verb = self.verb + if "application/json" not in content_type: + raise_for_code_and_type( + code=500, + message="Expected JSON response but got: " + content_type, + error_type="InternalServerError", + ) try: - return requests.request(verb, url, json=params, headers=headers) - except requests.HTTPError as e: - raise e + return json.loads(content) + except json.JSONDecodeError: + raise_for_code_and_type( + code=500, + message="Failed to decode JSON response", + error_type="InternalServerError", + ) From 708d60873c9c93e0847aedc205dead14bd2cc0f0 Mon Sep 17 00:00:00 2001 From: Derich Pacheco Date: Mon, 26 May 2025 12:25:41 -0300 Subject: [PATCH 2/7] feat: custom http client foundation --- resend/__init__.py | 2 + resend/exceptions.py | 4 +- resend/http_client.py | 4 +- resend/http_client_requests.py | 2 +- resend/request.py | 68 ++++++++++++++-------------------- tests/conftest.py | 12 ++---- tests/request_test.py | 10 ++--- 7 files changed, 43 insertions(+), 59 deletions(-) diff --git a/resend/__init__.py b/resend/__init__.py index 455b393..b1fcb7e 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -50,4 +50,6 @@ "Attachment", "Tag", "Broadcast", + # Default HTTP Client + "RequestsClient", ] diff --git a/resend/exceptions.py b/resend/exceptions.py index 92dcf7c..7afffb0 100644 --- a/resend/exceptions.py +++ b/resend/exceptions.py @@ -4,7 +4,7 @@ codes as outlined in https://resend.com/docs/api-reference/errors. """ -from typing import Any, Dict, Union +from typing import Any, Dict, NoReturn, Union class ResendError(Exception): @@ -175,7 +175,7 @@ def __init__( def raise_for_code_and_type( code: Union[str, int], error_type: str, message: str -) -> None: +) -> NoReturn: """Raise the appropriate error based on the code and type. Args: diff --git a/resend/http_client.py b/resend/http_client.py index 7021b49..51ef8ac 100644 --- a/resend/http_client.py +++ b/resend/http_client.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Any, Mapping, Optional, Tuple, Union +from typing import Mapping, Optional, Tuple, Union class HTTPClient(ABC): @@ -9,6 +9,6 @@ def request( method: str, url: str, headers: Mapping[str, str], - json: Optional[Union[dict, list]] = None, + json: Optional[Union[dict[str, object], list[object]]] = None, ) -> Tuple[bytes, int, Mapping[str, str]]: pass diff --git a/resend/http_client_requests.py b/resend/http_client_requests.py index 40b55de..2e9592a 100644 --- a/resend/http_client_requests.py +++ b/resend/http_client_requests.py @@ -14,7 +14,7 @@ def request( method: str, url: str, headers: Mapping[str, str], - json: Optional[Union[dict, list]] = None, + json: Optional[Union[dict[str, object], list[object]]] = None, ) -> Tuple[bytes, int, Mapping[str, str]]: try: resp = requests.request( diff --git a/resend/request.py b/resend/request.py index b97b381..e05f97e 100644 --- a/resend/request.py +++ b/resend/request.py @@ -1,7 +1,6 @@ +import json from typing import Any, Dict, Generic, List, Optional, Union, cast -import requests -import json from typing_extensions import Literal, TypeVar import resend @@ -9,16 +8,17 @@ from resend.version import get_version RequestVerb = Literal["get", "post", "put", "patch", "delete"] - T = TypeVar("T") +ParamsType = Union[Dict[str, Any], List[Dict[str, Any]]] +HeadersType = Dict[str, str] + -# This class wraps the HTTP request creation logic class Request(Generic[T]): def __init__( self, path: str, - params: Union[Dict[Any, Any], List[Dict[Any, Any]]], + params: ParamsType, verb: RequestVerb, options: Optional[Dict[str, Any]] = None, ): @@ -30,14 +30,7 @@ def __init__( def perform(self) -> Union[T, None]: data = self.make_request(url=f"{resend.api_url}{self.path}") - if self.verb == "delete": - return None - - if ( - isinstance(data, dict) - and data.get("statusCode") - and data.get("statusCode") != 200 - ): + if isinstance(data, dict) and data.get("statusCode") not in (None, 200): raise_for_code_and_type( code=data.get("statusCode") or 500, message=data.get("message", "Unknown error"), @@ -47,60 +40,55 @@ def perform(self) -> Union[T, None]: return cast(T, data) def perform_with_content(self) -> T: - """ - Perform an HTTP request and return the response content. - - Returns: - T: The content of the response - - Raises: - NoContentError: If the response content is `None`. - """ resp = self.perform() if resp is None: raise NoContentError() return resp - def __get_headers(self) -> Dict[Any, Any]: - """get_headers returns the HTTP headers that will be - used for every req. - - Returns: - Dict: configured HTTP Headers - """ - headers = { + def __get_headers(self) -> HeadersType: + headers: HeadersType = { "Accept": "application/json", "Authorization": f"Bearer {resend.api_key}", "User-Agent": f"resend-python:{get_version()}", } - # Add the Idempotency-Key header if the verb is POST - # and the options dict contains the key - if self.verb == "post" and (self.options and "idempotency_key" in self.options): - headers["Idempotency-Key"] = self.options["idempotency_key"] + if self.verb == "post" and self.options and "idempotency_key" in self.options: + headers["Idempotency-Key"] = str(self.options["idempotency_key"]) + return headers - def make_request(self, url: str) -> Dict[str, Any]: + def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: headers = self.__get_headers() - content, status_code, resp_headers = resend.default_http_client.request( + if isinstance(self.params, dict): + json_params: Optional[Union[Dict[str, Any], List[Any]]] = { + str(k): v for k, v in self.params.items() + } + elif isinstance(self.params, list): + json_params = [dict(item) for item in self.params] + else: + json_params = None + + content, _status_code, resp_headers = resend.default_http_client.request( method=self.verb, url=url, headers=headers, - json=self.params, + json=json_params, ) - content_type = resp_headers.get("Content-Type", "") + 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, - message="Expected JSON response but got: " + content_type, + message=f"Expected JSON response but got: {content_type}", error_type="InternalServerError", ) try: - return json.loads(content) + return cast(Union[Dict[str, Any], List[Any]], json.loads(content)) except json.JSONDecodeError: raise_for_code_and_type( code=500, diff --git a/tests/conftest.py b/tests/conftest.py index 0684e6d..ad1d352 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,25 +10,19 @@ class ResendBaseTest(TestCase): def setUp(self) -> None: resend.api_key = "re_123" - - self.patcher = patch("resend.Request.make_request") + self.patcher = patch("resend.request.Request.make_request") self.mock = self.patcher.start() - self.m = MagicMock( - status_code=200, - headers={"content-type": "application/json; charset=utf-8"}, - ) - self.mock.return_value = self.m def tearDown(self) -> None: self.patcher.stop() def set_mock_json(self, mock_json: Any) -> None: """Auxiliary function to set the mock json return value""" - self.m.json = lambda: mock_json + self.mock.return_value = mock_json def set_mock_text(self, mock_text: str) -> None: """Auxiliary function to set the mock text return value""" - self.m.text = mock_text + self.mock.text = mock_text def set_magic_mock_obj(self, magic_mock_obj: MagicMock) -> None: """Auxiliary function to set the mock object""" diff --git a/tests/request_test.py b/tests/request_test.py index b55c2a9..6369b1d 100644 --- a/tests/request_test.py +++ b/tests/request_test.py @@ -7,13 +7,13 @@ class TestResendRequest(unittest.TestCase): - @patch("resend.request.requests.request") + @patch("resend.http_client_requests.requests.request") @patch("resend.api_key", new="test_key") def test_request_idempotency_key_is_set(self, mock_requests: MagicMock) -> None: mock_response = Mock() - mock_response.text = "{}" + mock_response.content = b"{}" mock_response.status_code = 200 - mock_response.headers = {"content-type": "application/json"} + mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {} mock_requests.return_value = mock_response @@ -37,11 +37,11 @@ def test_request_idempotency_key_is_set(self, mock_requests: MagicMock) -> None: self.assertEqual(headers["User-Agent"], f"resend-python:{get_version()}") self.assertEqual(headers["Idempotency-Key"], "abc-123") - @patch("resend.request.requests.request") + @patch("resend.http_client_requests.requests.request") @patch("resend.api_key", new="test_key") def test_request_idempotency_key_is_not_set(self, mock_requests: MagicMock) -> None: mock_response = Mock() - mock_response.text = "{}" + mock_response.content = b"{}" mock_response.status_code = 200 mock_response.headers = {"content-type": "application/json"} mock_response.json.return_value = {} From dbbf76ec039991b220a961e89abc3ec674991f9b Mon Sep 17 00:00:00 2001 From: Derich Pacheco Date: Mon, 26 May 2025 12:33:26 -0300 Subject: [PATCH 3/7] fix: mypy types --- resend/http_client.py | 4 ++-- resend/http_client_requests.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/resend/http_client.py b/resend/http_client.py index 51ef8ac..8016d54 100644 --- a/resend/http_client.py +++ b/resend/http_client.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Mapping, Optional, Tuple, Union +from typing import Mapping, Optional, Tuple, Union, List class HTTPClient(ABC): @@ -9,6 +9,6 @@ def request( method: str, url: str, headers: Mapping[str, str], - json: Optional[Union[dict[str, object], list[object]]] = None, + json: Optional[Union[dict[str, object], List[object]]] = None, ) -> Tuple[bytes, int, Mapping[str, str]]: pass diff --git a/resend/http_client_requests.py b/resend/http_client_requests.py index 2e9592a..d689b2e 100644 --- a/resend/http_client_requests.py +++ b/resend/http_client_requests.py @@ -1,4 +1,4 @@ -from typing import Mapping, Optional, Tuple, Union +from typing import Mapping, Optional, Tuple, Union, List import requests @@ -14,7 +14,7 @@ def request( method: str, url: str, headers: Mapping[str, str], - json: Optional[Union[dict[str, object], list[object]]] = None, + json: Optional[Union[dict[str, object], List[object]]] = None, ) -> Tuple[bytes, int, Mapping[str, str]]: try: resp = requests.request( From 82326e490ca4912241d7d7307561e2627ea95d22 Mon Sep 17 00:00:00 2001 From: Derich Pacheco Date: Mon, 26 May 2025 12:36:39 -0300 Subject: [PATCH 4/7] fix: mypy types --- resend/http_client.py | 4 ++-- resend/http_client_requests.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/resend/http_client.py b/resend/http_client.py index 8016d54..21ee7e5 100644 --- a/resend/http_client.py +++ b/resend/http_client.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Mapping, Optional, Tuple, Union, List +from typing import Mapping, Optional, Tuple, Union, List, Dict class HTTPClient(ABC): @@ -9,6 +9,6 @@ def request( method: str, url: str, headers: Mapping[str, str], - json: Optional[Union[dict[str, object], List[object]]] = None, + json: Optional[Union[Dict[str, object], List[object]]] = None, ) -> Tuple[bytes, int, Mapping[str, str]]: pass diff --git a/resend/http_client_requests.py b/resend/http_client_requests.py index d689b2e..ae3f486 100644 --- a/resend/http_client_requests.py +++ b/resend/http_client_requests.py @@ -1,4 +1,4 @@ -from typing import Mapping, Optional, Tuple, Union, List +from typing import Mapping, Optional, Tuple, Union, List, Dict import requests @@ -14,7 +14,7 @@ def request( method: str, url: str, headers: Mapping[str, str], - json: Optional[Union[dict[str, object], List[object]]] = None, + json: Optional[Union[Dict[str, object], List[object]]] = None, ) -> Tuple[bytes, int, Mapping[str, str]]: try: resp = requests.request( From f1788161a96032a87aa19bd9b0fee87f8fdb25cc Mon Sep 17 00:00:00 2001 From: Derich Pacheco Date: Mon, 26 May 2025 18:15:31 -0300 Subject: [PATCH 5/7] fix: some mypy errors --- resend/http_client.py | 8 +++++- resend/http_client_requests.py | 2 +- tests/conftest.py | 1 + tests/default_http_client_test.py | 42 +++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 tests/default_http_client_test.py diff --git a/resend/http_client.py b/resend/http_client.py index 21ee7e5..0863420 100644 --- a/resend/http_client.py +++ b/resend/http_client.py @@ -1,8 +1,14 @@ from abc import ABC, abstractmethod -from typing import Mapping, Optional, Tuple, Union, List, Dict +from typing import Dict, List, Mapping, Optional, Tuple, Union class HTTPClient(ABC): + """ + Abstract base class for HTTP clients. + This class defines the interface for making HTTP requests. + Subclasses should implement the `request` method. + """ + @abstractmethod def request( self, diff --git a/resend/http_client_requests.py b/resend/http_client_requests.py index ae3f486..8dcbf9a 100644 --- a/resend/http_client_requests.py +++ b/resend/http_client_requests.py @@ -1,4 +1,4 @@ -from typing import Mapping, Optional, Tuple, Union, List, Dict +from typing import Dict, List, Mapping, Optional, Tuple, Union import requests diff --git a/tests/conftest.py b/tests/conftest.py index ad1d352..72b8bc3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ class ResendBaseTest(TestCase): def setUp(self) -> None: resend.api_key = "re_123" + resend.default_http_client = resend.RequestsClient() self.patcher = patch("resend.request.Request.make_request") self.mock = self.patcher.start() diff --git a/tests/default_http_client_test.py b/tests/default_http_client_test.py new file mode 100644 index 0000000..aa33c4a --- /dev/null +++ b/tests/default_http_client_test.py @@ -0,0 +1,42 @@ +from typing import cast +from unittest import TestCase +from unittest.mock import create_autospec + +import resend +from resend.http_client import HTTPClient + + +class TestDefaultHttpClientUsage(TestCase): + def setUp(self) -> None: + resend.api_key = "re_test" + resend.default_http_client = cast(HTTPClient, None) + + def tearDown(self) -> None: + resend.api_key = None + resend.default_http_client = cast(HTTPClient, None) + + def test_default_http_client_called_with_correct_payload(self) -> None: + mock_client = create_autospec(HTTPClient, instance=True) + mock_client.name = "mock" + mock_client.request.return_value = ( + b'{"id": "email_123"}', + 200, + {"Content-Type": "application/json"}, + ) + + resend.default_http_client = mock_client + + resend.Emails.send( + { + "from": "hello@example.com", + "to": ["world@example.com"], + "subject": "Hi!", + "html": "hi", + } + ) + + mock_client.request.assert_called_once() + args, kwargs = mock_client.request.call_args + assert kwargs["method"] == "post" + assert "/emails" in kwargs["url"] + assert kwargs["json"]["subject"] == "Hi!" From b168cd57affba02c60718f29c3f03701ac402249 Mon Sep 17 00:00:00 2001 From: Derich Pacheco Date: Mon, 26 May 2025 18:23:21 -0300 Subject: [PATCH 6/7] example: add custom http client example --- examples/with_custom_http_client.py | 62 +++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 examples/with_custom_http_client.py diff --git a/examples/with_custom_http_client.py b/examples/with_custom_http_client.py new file mode 100644 index 0000000..c18d121 --- /dev/null +++ b/examples/with_custom_http_client.py @@ -0,0 +1,62 @@ +import os +from typing import Any, Dict, List, Mapping, Optional, Tuple, Union + +import requests + +import resend +from resend.http_client import HTTPClient + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + + +# Define a custom HTTP client using the requests library with a higher timeout val +class CustomRequestsClient(HTTPClient): + def __init__(self, timeout: int = 300): + self.timeout = timeout + + def request( + self, + method: str, + url: str, + headers: Mapping[str, str], + json: Optional[Union[Dict[str, Any], List[Any]]] = None, + ) -> Tuple[bytes, int, Dict[str, str]]: + print(f"[HTTP] {method.upper()} {url} with timeout={self.timeout}") + try: + response = requests.request( + method=method, + url=url, + headers=headers, + json=json, + timeout=self.timeout, + ) + return ( + response.content, + response.status_code, + dict(response.headers), + ) + except requests.RequestException as e: + raise RuntimeError(f"HTTP request failed: {e}") from e + + +# use the custom HTTP client with a longer timeout +resend.default_http_client = CustomRequestsClient(timeout=400) + +params: resend.Emails.SendParams = { + "from": "onboarding@resend.dev", + "to": ["delivered@resend.dev"], + "subject": "hi", + "html": "hello, world!", + "reply_to": "to@gmail.com", + "bcc": "delivered@resend.dev", + "cc": ["delivered@resend.dev"], + "tags": [ + {"name": "tag1", "value": "tagvalue1"}, + {"name": "tag2", "value": "tagvalue2"}, + ], +} + + +email: resend.Email = resend.Emails.send(params) +print(f"{email}") From 13255f63f2c3a546f2f5347d8a23f93b948703c4 Mon Sep 17 00:00:00 2001 From: Derich Pacheco Date: Mon, 26 May 2025 20:58:50 -0300 Subject: [PATCH 7/7] feat: raise ResendError on custom client error --- resend/http_client_requests.py | 6 ++++++ resend/request.py | 25 ++++++++++++++++++------- tests/default_http_client_test.py | 24 +++++++++++++++++++++++- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/resend/http_client_requests.py b/resend/http_client_requests.py index 8dcbf9a..6c78a2c 100644 --- a/resend/http_client_requests.py +++ b/resend/http_client_requests.py @@ -6,6 +6,10 @@ class RequestsClient(HTTPClient): + """ + This is the default HTTP client implementation using the requests library. + """ + def __init__(self, timeout: int = 30): self._timeout = timeout @@ -26,4 +30,6 @@ def request( ) return resp.content, resp.status_code, resp.headers except requests.RequestException as e: + # This gets caught by the request.perform() method + # and raises a ResendError with the error type "HttpClientError" raise RuntimeError(f"Request failed: {e}") from e diff --git a/resend/request.py b/resend/request.py index e05f97e..623dd25 100644 --- a/resend/request.py +++ b/resend/request.py @@ -4,7 +4,8 @@ from typing_extensions import Literal, TypeVar import resend -from resend.exceptions import NoContentError, raise_for_code_and_type +from resend.exceptions import (NoContentError, ResendError, + raise_for_code_and_type) from resend.version import get_version RequestVerb = Literal["get", "post", "put", "patch", "delete"] @@ -69,12 +70,22 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: else: json_params = None - content, _status_code, resp_headers = resend.default_http_client.request( - method=self.verb, - url=url, - headers=headers, - json=json_params, - ) + try: + content, _status_code, resp_headers = resend.default_http_client.request( + method=self.verb, + url=url, + headers=headers, + json=json_params, + ) + + # Safety net around the HTTP Client + except Exception as e: + raise ResendError( + code=500, + message=str(e), + error_type="HttpClientError", + suggested_action="Request failed, please try again.", + ) content_type = {k.lower(): v for k, v in resp_headers.items()}.get( "content-type", "" diff --git a/tests/default_http_client_test.py b/tests/default_http_client_test.py index aa33c4a..95007be 100644 --- a/tests/default_http_client_test.py +++ b/tests/default_http_client_test.py @@ -1,8 +1,11 @@ -from typing import cast +from typing import Any, Dict, Mapping, Tuple, cast from unittest import TestCase from unittest.mock import create_autospec +import pytest + import resend +import resend.exceptions from resend.http_client import HTTPClient @@ -40,3 +43,22 @@ def test_default_http_client_called_with_correct_payload(self) -> None: assert kwargs["method"] == "post" assert "/emails" in kwargs["url"] assert kwargs["json"]["subject"] == "Hi!" + + def test_perform_raises_resend_error_on_runtime_error(self) -> None: + class RaisesClient(resend.http_client.HTTPClient): + def request( + self, *args: object, **kwargs: object + ) -> Tuple[bytes, int, Mapping[str, str]]: + raise RuntimeError("Connection broken") + + resend.default_http_client = RaisesClient() + + request: resend.Request[Dict[str, Any]] = resend.Request( + path="/emails", params={}, verb="post" + ) + + with pytest.raises(resend.exceptions.ResendError) as exc: + request.perform() + + assert "Connection broken" in str(exc.value) + assert exc.value.error_type == "HttpClientError"