Skip to content

Commit edfae84

Browse files
authored
feat: Add support for custom http clients (#146)
1 parent 2c63b0c commit edfae84

10 files changed

Lines changed: 262 additions & 89 deletions

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ ADD requirements.txt requirements.txt
77
RUN pip install -r requirements.txt
88

99
RUN pip install tox
10+
RUN pip install setuptools==68.2.2
11+
RUN pip install wheel
1012

1113
ENV APP_HOME /app
1214

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import os
2+
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
3+
4+
import requests
5+
6+
import resend
7+
from resend.http_client import HTTPClient
8+
9+
if not os.environ["RESEND_API_KEY"]:
10+
raise EnvironmentError("RESEND_API_KEY is missing")
11+
12+
13+
# Define a custom HTTP client using the requests library with a higher timeout val
14+
class CustomRequestsClient(HTTPClient):
15+
def __init__(self, timeout: int = 300):
16+
self.timeout = timeout
17+
18+
def request(
19+
self,
20+
method: str,
21+
url: str,
22+
headers: Mapping[str, str],
23+
json: Optional[Union[Dict[str, Any], List[Any]]] = None,
24+
) -> Tuple[bytes, int, Dict[str, str]]:
25+
print(f"[HTTP] {method.upper()} {url} with timeout={self.timeout}")
26+
try:
27+
response = requests.request(
28+
method=method,
29+
url=url,
30+
headers=headers,
31+
json=json,
32+
timeout=self.timeout,
33+
)
34+
return (
35+
response.content,
36+
response.status_code,
37+
dict(response.headers),
38+
)
39+
except requests.RequestException as e:
40+
raise RuntimeError(f"HTTP request failed: {e}") from e
41+
42+
43+
# use the custom HTTP client with a longer timeout
44+
resend.default_http_client = CustomRequestsClient(timeout=400)
45+
46+
params: resend.Emails.SendParams = {
47+
"from": "onboarding@resend.dev",
48+
"to": ["delivered@resend.dev"],
49+
"subject": "hi",
50+
"html": "<strong>hello, world!</strong>",
51+
"reply_to": "to@gmail.com",
52+
"bcc": "delivered@resend.dev",
53+
"cc": ["delivered@resend.dev"],
54+
"tags": [
55+
{"name": "tag1", "value": "tagvalue1"},
56+
{"name": "tag2", "value": "tagvalue2"},
57+
],
58+
}
59+
60+
61+
email: resend.Email = resend.Emails.send(params)
62+
print(f"{email}")

resend/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,18 @@
1515
from .emails._email import Email
1616
from .emails._emails import Emails
1717
from .emails._tag import Tag
18+
from .http_client import HTTPClient
19+
from .http_client_requests import RequestsClient
1820
from .request import Request
1921
from .version import __version__, get_version
2022

2123
# Config vars
2224
api_key = os.environ.get("RESEND_API_KEY")
2325
api_url = os.environ.get("RESEND_API_URL", "https://api.resend.com")
2426

27+
# HTTP Client
28+
default_http_client: HTTPClient = RequestsClient()
29+
2530
# API resources
2631
from .emails._emails import Emails # noqa
2732

@@ -45,4 +50,6 @@
4550
"Attachment",
4651
"Tag",
4752
"Broadcast",
53+
# Default HTTP Client
54+
"RequestsClient",
4855
]

resend/exceptions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
codes as outlined in https://resend.com/docs/api-reference/errors.
55
"""
66

7-
from typing import Any, Dict, Union
7+
from typing import Any, Dict, NoReturn, Union
88

99

1010
class ResendError(Exception):
@@ -175,7 +175,7 @@ def __init__(
175175

176176
def raise_for_code_and_type(
177177
code: Union[str, int], error_type: str, message: str
178-
) -> None:
178+
) -> NoReturn:
179179
"""Raise the appropriate error based on the code and type.
180180
181181
Args:

resend/http_client.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Dict, List, Mapping, Optional, Tuple, Union
3+
4+
5+
class HTTPClient(ABC):
6+
"""
7+
Abstract base class for HTTP clients.
8+
This class defines the interface for making HTTP requests.
9+
Subclasses should implement the `request` method.
10+
"""
11+
12+
@abstractmethod
13+
def request(
14+
self,
15+
method: str,
16+
url: str,
17+
headers: Mapping[str, str],
18+
json: Optional[Union[Dict[str, object], List[object]]] = None,
19+
) -> Tuple[bytes, int, Mapping[str, str]]:
20+
pass

resend/http_client_requests.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from typing import Dict, List, Mapping, Optional, Tuple, Union
2+
3+
import requests
4+
5+
from resend.http_client import HTTPClient
6+
7+
8+
class RequestsClient(HTTPClient):
9+
"""
10+
This is the default HTTP client implementation using the requests library.
11+
"""
12+
13+
def __init__(self, timeout: int = 30):
14+
self._timeout = timeout
15+
16+
def request(
17+
self,
18+
method: str,
19+
url: str,
20+
headers: Mapping[str, str],
21+
json: Optional[Union[Dict[str, object], List[object]]] = None,
22+
) -> Tuple[bytes, int, Mapping[str, str]]:
23+
try:
24+
resp = requests.request(
25+
method=method,
26+
url=url,
27+
headers=headers,
28+
json=json,
29+
timeout=self._timeout,
30+
)
31+
return resp.content, resp.status_code, resp.headers
32+
except requests.RequestException as e:
33+
# This gets caught by the request.perform() method
34+
# and raises a ResendError with the error type "HttpClientError"
35+
raise RuntimeError(f"Request failed: {e}") from e

resend/request.py

Lines changed: 61 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
1+
import json
12
from typing import Any, Dict, Generic, List, Optional, Union, cast
23

3-
import requests
44
from typing_extensions import Literal, TypeVar
55

66
import resend
7-
from resend.exceptions import NoContentError, raise_for_code_and_type
7+
from resend.exceptions import (NoContentError, ResendError,
8+
raise_for_code_and_type)
89
from resend.version import get_version
910

1011
RequestVerb = Literal["get", "post", "put", "patch", "delete"]
11-
1212
T = TypeVar("T")
1313

14+
ParamsType = Union[Dict[str, Any], List[Dict[str, Any]]]
15+
HeadersType = Dict[str, str]
16+
1417

15-
# This class wraps the HTTP request creation logic
1618
class Request(Generic[T]):
1719
def __init__(
1820
self,
1921
path: str,
20-
params: Union[Dict[Any, Any], List[Dict[Any, Any]]],
22+
params: ParamsType,
2123
verb: RequestVerb,
2224
options: Optional[Dict[str, Any]] = None,
2325
):
@@ -27,94 +29,80 @@ def __init__(
2729
self.options = options
2830

2931
def perform(self) -> Union[T, None]:
30-
"""Is the main function that makes the HTTP request
31-
to the Resend API. It uses the path, params, and verb attributes
32-
to make the request.
33-
34-
Returns:
35-
Union[T, None]: A generic type of the Request class or None
36-
37-
Raises:
38-
requests.HTTPError: If the request fails
39-
"""
40-
resp = self.make_request(url=f"{resend.api_url}{self.path}")
32+
data = self.make_request(url=f"{resend.api_url}{self.path}")
4133

42-
# delete calls do not return a body
43-
if resp.text == "" and resp.status_code == 200:
44-
return None
45-
46-
# this is a safety net, if we get here it means the Resend API is having issues
47-
# and most likely the gateway is returning htmls
48-
if "application/json" not in resp.headers["content-type"]:
34+
if isinstance(data, dict) and data.get("statusCode") not in (None, 200):
4935
raise_for_code_and_type(
50-
code=500,
51-
message="Failed to parse Resend API response. Please try again.",
52-
error_type="InternalServerError",
36+
code=data.get("statusCode") or 500,
37+
message=data.get("message", "Unknown error"),
38+
error_type=data.get("name", "InternalServerError"),
5339
)
5440

55-
# handle error in case there is a statusCode attr present
56-
# and status != 200 and response is a json.
57-
if resp.status_code != 200 and resp.json().get("statusCode"):
58-
error = resp.json()
59-
raise_for_code_and_type(
60-
code=error.get("statusCode"),
61-
message=error.get("message"),
62-
error_type=error.get("name"),
63-
)
64-
return cast(T, resp.json())
41+
return cast(T, data)
6542

6643
def perform_with_content(self) -> T:
67-
"""
68-
Perform an HTTP request and return the response content.
69-
70-
Returns:
71-
T: The content of the response
72-
73-
Raises:
74-
NoContentError: If the response content is `None`.
75-
"""
7644
resp = self.perform()
7745
if resp is None:
7846
raise NoContentError()
7947
return resp
8048

81-
def __get_headers(self) -> Dict[Any, Any]:
82-
"""get_headers returns the HTTP headers that will be
83-
used for every req.
84-
85-
Returns:
86-
Dict: configured HTTP Headers
87-
"""
88-
headers = {
49+
def __get_headers(self) -> HeadersType:
50+
headers: HeadersType = {
8951
"Accept": "application/json",
9052
"Authorization": f"Bearer {resend.api_key}",
9153
"User-Agent": f"resend-python:{get_version()}",
9254
}
9355

94-
# Add the Idempotency-Key header if the verb is POST
95-
# and the options dict contains the key
96-
if self.verb == "post" and (self.options and "idempotency_key" in self.options):
97-
headers["Idempotency-Key"] = self.options["idempotency_key"]
56+
if self.verb == "post" and self.options and "idempotency_key" in self.options:
57+
headers["Idempotency-Key"] = str(self.options["idempotency_key"])
58+
9859
return headers
9960

100-
def make_request(self, url: str) -> requests.Response:
101-
"""make_request is a helper function that makes the actual
102-
HTTP request to the Resend API.
61+
def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]:
62+
headers = self.__get_headers()
10363

104-
Args:
105-
url (str): The URL to make the request to
64+
if isinstance(self.params, dict):
65+
json_params: Optional[Union[Dict[str, Any], List[Any]]] = {
66+
str(k): v for k, v in self.params.items()
67+
}
68+
elif isinstance(self.params, list):
69+
json_params = [dict(item) for item in self.params]
70+
else:
71+
json_params = None
10672

107-
Returns:
108-
requests.Response: The response object from the request
73+
try:
74+
content, _status_code, resp_headers = resend.default_http_client.request(
75+
method=self.verb,
76+
url=url,
77+
headers=headers,
78+
json=json_params,
79+
)
10980

110-
Raises:
111-
requests.HTTPError: If the request fails
112-
"""
113-
headers = self.__get_headers()
114-
params = self.params
115-
verb = self.verb
81+
# Safety net around the HTTP Client
82+
except Exception as e:
83+
raise ResendError(
84+
code=500,
85+
message=str(e),
86+
error_type="HttpClientError",
87+
suggested_action="Request failed, please try again.",
88+
)
89+
90+
content_type = {k.lower(): v for k, v in resp_headers.items()}.get(
91+
"content-type", ""
92+
)
93+
94+
if "application/json" not in content_type:
95+
raise_for_code_and_type(
96+
code=500,
97+
message=f"Expected JSON response but got: {content_type}",
98+
error_type="InternalServerError",
99+
)
116100

117101
try:
118-
return requests.request(verb, url, json=params, headers=headers)
119-
except requests.HTTPError as e:
120-
raise e
102+
return cast(Union[Dict[str, Any], List[Any]], json.loads(content))
103+
except json.JSONDecodeError:
104+
raise_for_code_and_type(
105+
code=500,
106+
message="Failed to decode JSON response",
107+
error_type="InternalServerError",
108+
)

tests/conftest.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,20 @@
1010
class ResendBaseTest(TestCase):
1111
def setUp(self) -> None:
1212
resend.api_key = "re_123"
13-
14-
self.patcher = patch("resend.Request.make_request")
13+
resend.default_http_client = resend.RequestsClient()
14+
self.patcher = patch("resend.request.Request.make_request")
1515
self.mock = self.patcher.start()
16-
self.m = MagicMock(
17-
status_code=200,
18-
headers={"content-type": "application/json; charset=utf-8"},
19-
)
20-
self.mock.return_value = self.m
2116

2217
def tearDown(self) -> None:
2318
self.patcher.stop()
2419

2520
def set_mock_json(self, mock_json: Any) -> None:
2621
"""Auxiliary function to set the mock json return value"""
27-
self.m.json = lambda: mock_json
22+
self.mock.return_value = mock_json
2823

2924
def set_mock_text(self, mock_text: str) -> None:
3025
"""Auxiliary function to set the mock text return value"""
31-
self.m.text = mock_text
26+
self.mock.text = mock_text
3227

3328
def set_magic_mock_obj(self, magic_mock_obj: MagicMock) -> None:
3429
"""Auxiliary function to set the mock object"""

0 commit comments

Comments
 (0)