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/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}")
diff --git a/resend/__init__.py b/resend/__init__.py
index 773817f..b1fcb7e 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
@@ -45,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
new file mode 100644
index 0000000..0863420
--- /dev/null
+++ b/resend/http_client.py
@@ -0,0 +1,20 @@
+from abc import ABC, abstractmethod
+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,
+ method: str,
+ url: str,
+ headers: Mapping[str, str],
+ 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
new file mode 100644
index 0000000..6c78a2c
--- /dev/null
+++ b/resend/http_client_requests.py
@@ -0,0 +1,35 @@
+from typing import Dict, List, Mapping, Optional, Tuple, Union
+
+import requests
+
+from resend.http_client import HTTPClient
+
+
+class RequestsClient(HTTPClient):
+ """
+ This is the default HTTP client implementation using the requests library.
+ """
+
+ def __init__(self, timeout: int = 30):
+ self._timeout = timeout
+
+ def request(
+ self,
+ method: str,
+ url: str,
+ headers: Mapping[str, str],
+ json: Optional[Union[Dict[str, object], List[object]]] = 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:
+ # 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 f0dfaa9..623dd25 100644
--- a/resend/request.py
+++ b/resend/request.py
@@ -1,23 +1,25 @@
+import json
from typing import Any, Dict, Generic, List, Optional, Union, cast
-import requests
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"]
-
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,
):
@@ -27,94 +29,80 @@ 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.
-
- 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}")
+ data = 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:
- 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") not in (None, 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:
- """
- 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) -> requests.Response:
- """make_request is a helper function that makes the actual
- HTTP request to the Resend API.
+ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]:
+ headers = self.__get_headers()
- Args:
- url (str): The URL to make the request to
+ 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
- Returns:
- requests.Response: The response object from the request
+ try:
+ content, _status_code, resp_headers = resend.default_http_client.request(
+ method=self.verb,
+ url=url,
+ headers=headers,
+ json=json_params,
+ )
- Raises:
- requests.HTTPError: If the request fails
- """
- headers = self.__get_headers()
- params = self.params
- verb = self.verb
+ # 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", ""
+ )
+
+ if "application/json" not in content_type:
+ raise_for_code_and_type(
+ code=500,
+ message=f"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 cast(Union[Dict[str, Any], List[Any]], json.loads(content))
+ except json.JSONDecodeError:
+ raise_for_code_and_type(
+ code=500,
+ message="Failed to decode JSON response",
+ error_type="InternalServerError",
+ )
diff --git a/tests/conftest.py b/tests/conftest.py
index 0684e6d..72b8bc3 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -10,25 +10,20 @@
class ResendBaseTest(TestCase):
def setUp(self) -> None:
resend.api_key = "re_123"
-
- self.patcher = patch("resend.Request.make_request")
+ resend.default_http_client = resend.RequestsClient()
+ 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/default_http_client_test.py b/tests/default_http_client_test.py
new file mode 100644
index 0000000..95007be
--- /dev/null
+++ b/tests/default_http_client_test.py
@@ -0,0 +1,64 @@
+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
+
+
+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!"
+
+ 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"
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 = {}