Skip to content

feat: Add support for custom http clients #146

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
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
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 62 additions & 0 deletions examples/with_custom_http_client.py
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"to": ["[email protected]"],
"subject": "hi",
"html": "<strong>hello, world!</strong>",
"reply_to": "[email protected]",
"bcc": "[email protected]",
"cc": ["[email protected]"],
"tags": [
{"name": "tag1", "value": "tagvalue1"},
{"name": "tag2", "value": "tagvalue2"},
],
}


email: resend.Email = resend.Emails.send(params)
print(f"{email}")
7 changes: 7 additions & 0 deletions resend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@
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

# Config vars
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

Expand All @@ -45,4 +50,6 @@
"Attachment",
"Tag",
"Broadcast",
# Default HTTP Client
"RequestsClient",
]
4 changes: 2 additions & 2 deletions resend/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions resend/http_client.py
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions resend/http_client_requests.py
Original file line number Diff line number Diff line change
@@ -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
134 changes: 61 additions & 73 deletions resend/request.py
Original file line number Diff line number Diff line change
@@ -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,
):
Expand All @@ -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",
)
13 changes: 4 additions & 9 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
Loading