Skip to content

Commit 4665e82

Browse files
committed
feat: init async support with httpx
1 parent 13255f6 commit 4665e82

8 files changed

Lines changed: 336 additions & 1 deletion

File tree

examples/simple_email_async.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import asyncio
2+
import os
3+
4+
import resend
5+
from resend import HTTPXClient
6+
7+
if not os.environ["RESEND_API_KEY"]:
8+
raise EnvironmentError("RESEND_API_KEY is missing")
9+
10+
# Set up async HTTP client
11+
resend.default_http_client = HTTPXClient()
12+
13+
params: resend.Emails.SendParams = {
14+
"from": "onboarding@resend.dev",
15+
"to": ["delivered@resend.dev"],
16+
"subject": "hi",
17+
"html": "<strong>hello, world!</strong>",
18+
"reply_to": "to@gmail.com",
19+
"bcc": "delivered@resend.dev",
20+
"cc": ["delivered@resend.dev"],
21+
"tags": [
22+
{"name": "tag1", "value": "tagvalue1"},
23+
{"name": "tag2", "value": "tagvalue2"},
24+
],
25+
}
26+
27+
28+
async def main():
29+
# Without Idempotency Key
30+
email_non_idempotent: resend.Email = await resend.Emails.send_async(params)
31+
print(f"Sent email without idempotency key: {email_non_idempotent['id']}")
32+
33+
# With Idempotency Key
34+
options: resend.Emails.SendOptions = {
35+
"idempotency_key": "44",
36+
}
37+
email_idempotent: resend.Email = await resend.Emails.send_async(params, options)
38+
print(f"Sent email with idempotency key: {email_idempotent['id']}")
39+
40+
email_resp: resend.Email = await resend.Emails.get_async(email_id=email_non_idempotent["id"])
41+
print(f"Retrieved email: {email_resp['id']}")
42+
print("Email ID: ", email_resp["id"])
43+
print("Email from: ", email_resp["from"])
44+
print("Email to: ", email_resp["to"])
45+
print("Email subject: ", email_resp["subject"])
46+
print("Email html: ", email_resp["html"])
47+
print("Email created_at: ", email_resp["created_at"])
48+
print("Email reply_to: ", email_resp["reply_to"])
49+
print("Email bcc: ", email_resp["bcc"])
50+
print("Email cc: ", email_resp["cc"])
51+
52+
53+
if __name__ == "__main__":
54+
asyncio.run(main())

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
httpx>=0.24.0

resend/__init__.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@
1818
from .http_client import HTTPClient
1919
from .http_client_requests import RequestsClient
2020
from .request import Request
21+
22+
# Async imports (optional - only available with pip install resend[async])
23+
try:
24+
from .http_client_async import AsyncHTTPClient
25+
from .http_client_httpx import HTTPXClient
26+
from .async_request import AsyncRequest
27+
except ImportError:
28+
# Async classes not available without httpx dependency
29+
pass
2130
from .version import __version__, get_version
2231

2332
# Config vars
@@ -50,6 +59,18 @@
5059
"Attachment",
5160
"Tag",
5261
"Broadcast",
53-
# Default HTTP Client
62+
# HTTP Clients
63+
"HTTPClient",
5464
"RequestsClient",
5565
]
66+
67+
# Add async exports if available
68+
try:
69+
from .http_client_httpx import HTTPXClient
70+
__all__.extend([
71+
"AsyncHTTPClient",
72+
"HTTPXClient",
73+
"AsyncRequest"
74+
])
75+
except ImportError:
76+
pass

resend/async_request.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import json
2+
from typing import Any, Dict, Generic, List, Optional, Union, cast
3+
4+
from typing_extensions import Literal, TypeVar
5+
6+
import resend
7+
from resend.exceptions import (NoContentError, ResendError,
8+
raise_for_code_and_type)
9+
from resend.version import get_version
10+
11+
RequestVerb = Literal["get", "post", "put", "patch", "delete"]
12+
T = TypeVar("T")
13+
14+
ParamsType = Union[Dict[str, Any], List[Dict[str, Any]]]
15+
HeadersType = Dict[str, str]
16+
17+
18+
class AsyncRequest(Generic[T]):
19+
def __init__(
20+
self,
21+
path: str,
22+
params: ParamsType,
23+
verb: RequestVerb,
24+
options: Optional[Dict[str, Any]] = None,
25+
):
26+
self.path = path
27+
self.params = params
28+
self.verb = verb
29+
self.options = options
30+
31+
async def perform(self) -> Union[T, None]:
32+
data = await self.make_request(url=f"{resend.api_url}{self.path}")
33+
34+
if isinstance(data, dict) and data.get("statusCode") not in (None, 200):
35+
raise_for_code_and_type(
36+
code=data.get("statusCode") or 500,
37+
message=data.get("message", "Unknown error"),
38+
error_type=data.get("name", "InternalServerError"),
39+
)
40+
41+
return cast(T, data)
42+
43+
async def perform_with_content(self) -> T:
44+
resp = await self.perform()
45+
if resp is None:
46+
raise NoContentError()
47+
return resp
48+
49+
def __get_headers(self) -> HeadersType:
50+
headers: HeadersType = {
51+
"Accept": "application/json",
52+
"Authorization": f"Bearer {resend.api_key}",
53+
"User-Agent": f"resend-python:{get_version()}",
54+
}
55+
56+
if self.verb == "post" and self.options and "idempotency_key" in self.options:
57+
headers["Idempotency-Key"] = str(self.options["idempotency_key"])
58+
59+
return headers
60+
61+
async def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]:
62+
headers = self.__get_headers()
63+
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
72+
73+
try:
74+
content, _status_code, resp_headers = await resend.default_http_client.request(
75+
method=self.verb,
76+
url=url,
77+
headers=headers,
78+
json=json_params,
79+
)
80+
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+
)
100+
101+
try:
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+
)

resend/emails/_emails.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
from resend.emails._email import Email
88
from resend.emails._tag import Tag
99

10+
# Async imports (optional - only available with pip install resend[async])
11+
try:
12+
from resend.async_request import AsyncRequest
13+
except ImportError:
14+
pass
15+
1016

1117
class _SendOptions(TypedDict):
1218
idempotency_key: NotRequired[str]
@@ -248,3 +254,85 @@ def update(cls, params: UpdateParams) -> UpdateEmailResponse:
248254
verb="patch",
249255
).perform_with_content()
250256
return resp
257+
258+
@classmethod
259+
async def send_async(cls, params: SendParams, options: Optional[SendOptions] = None) -> Email:
260+
"""
261+
Send an email through the Resend Email API (async version).
262+
see more: https://resend.com/docs/api-reference/emails/send-email
263+
264+
Args:
265+
params (SendParams): The email parameters
266+
options (SendOptions): The email options
267+
268+
Returns:
269+
Email: The email object that was sent
270+
"""
271+
path = "/emails"
272+
resp = await AsyncRequest[Email](
273+
path=path,
274+
params=cast(Dict[Any, Any], params),
275+
verb="post",
276+
options=cast(Dict[Any, Any], options),
277+
).perform_with_content()
278+
return resp
279+
280+
@classmethod
281+
async def get_async(cls, email_id: str) -> Email:
282+
"""
283+
Retrieve a single email (async version).
284+
see more: https://resend.com/docs/api-reference/emails/retrieve-email
285+
286+
Args:
287+
email_id (str): The ID of the email to retrieve
288+
289+
Returns:
290+
Email: The email object that was retrieved
291+
"""
292+
path = f"/emails/{email_id}"
293+
resp = await AsyncRequest[Email](
294+
path=path,
295+
params={},
296+
verb="get",
297+
).perform_with_content()
298+
return resp
299+
300+
@classmethod
301+
async def cancel_async(cls, email_id: str) -> CancelScheduledEmailResponse:
302+
"""
303+
Cancel a scheduled email (async version).
304+
see more: https://resend.com/docs/api-reference/emails/cancel-email
305+
306+
Args:
307+
email_id (str): The ID of the scheduled email to cancel
308+
309+
Returns:
310+
CancelScheduledEmailResponse: The response object that contains the ID of the scheduled email that was canceled
311+
"""
312+
path = f"/emails/{email_id}/cancel"
313+
resp = await AsyncRequest[_CancelScheduledEmailResponse](
314+
path=path,
315+
params={},
316+
verb="post",
317+
).perform_with_content()
318+
return resp
319+
320+
@classmethod
321+
async def update_async(cls, params: UpdateParams) -> UpdateEmailResponse:
322+
"""
323+
Update an email (async version).
324+
see more: https://resend.com/docs/api-reference/emails/update-email
325+
326+
Args:
327+
params (UpdateParams): The email parameters to update
328+
329+
Returns:
330+
Email: The email object that was updated
331+
"""
332+
path = f"/emails/{params['id']}"
333+
resp = await AsyncRequest[_UpdateEmailResponse](
334+
path=path,
335+
params=cast(Dict[Any, Any], params),
336+
verb="patch",
337+
).perform_with_content()
338+
return resp

resend/http_client_async.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 AsyncHTTPClient(ABC):
6+
"""
7+
Abstract base class for async HTTP clients.
8+
This class defines the interface for making async HTTP requests.
9+
Subclasses should implement the `request` method.
10+
"""
11+
12+
@abstractmethod
13+
async 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_httpx.py

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

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
packages=find_packages(exclude=["tests", "tests.*"]),
1919
package_data={"resend": ["py.typed"]},
2020
install_requires=install_requires,
21+
extras_require={
22+
"async": ["httpx>=0.24.0"],
23+
},
2124
zip_safe=False,
2225
python_requires=">=3.7",
2326
keywords=["email", "email platform"],

0 commit comments

Comments
 (0)