Skip to content

Commit df6a7aa

Browse files
authored
feat: Add support for Idempotecy key header (#138)
1 parent 8ce6f96 commit df6a7aa

4 files changed

Lines changed: 114 additions & 8 deletions

File tree

examples/simple_email.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,19 @@
1919
],
2020
}
2121

22-
email: resend.Email = resend.Emails.send(params)
23-
print(f"Sent email")
24-
print("Email ID: ", email["id"])
2522

26-
email_resp: resend.Email = resend.Emails.get(email_id=email["id"])
23+
# Without Idempotency Key
24+
email_non_idempotent: resend.Email = resend.Emails.send(params)
25+
print(f"Sent email without idempotency key: {email_non_idempotent['id']}")
26+
27+
# With Idempotency Key
28+
options: resend.Emails.SendOptions = {
29+
"idempotency_key": "44",
30+
}
31+
email_idempotent: resend.Email = resend.Emails.send(params, options)
32+
print(f"Sent email with idempotency key: {email_idempotent['id']}")
33+
34+
email_resp: resend.Email = resend.Emails.get(email_id=email_non_idempotent["id"])
2735
print(f"Retrieved email: {email_resp['id']}")
2836
print("Email ID: ", email_resp["id"])
2937
print("Email from: ", email_resp["from"])

resend/emails/_emails.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict, List, Union, cast
1+
from typing import Any, Dict, List, Optional, Union, cast
22

33
from typing_extensions import NotRequired, TypedDict
44

@@ -8,6 +8,15 @@
88
from resend.emails._tag import Tag
99

1010

11+
class _SendOptions(TypedDict):
12+
idempotency_key: NotRequired[str]
13+
"""
14+
Unique key that ensures the same operation is not processed multiple times.
15+
Allows for safe retries without duplicating operations.
16+
If provided, will be sent as the `Idempotency-Key` header.
17+
"""
18+
19+
1120
class _UpdateParams(TypedDict):
1221
id: str
1322
"""
@@ -148,14 +157,25 @@ class SendParams(_SendParamsDefault):
148157
tags (NotRequired[List[Tag]]): List of tags to be added to the email.
149158
"""
150159

160+
class SendOptions(_SendOptions):
161+
"""
162+
SendOptions is the class that wraps the options for the send method.
163+
164+
Attributes:
165+
idempotency_key (NotRequired[str]): Unique key that ensures the same operation is not processed multiple times.
166+
Allows for safe retries without duplicating operations.
167+
If provided, will be sent as the `Idempotency-Key` header.
168+
"""
169+
151170
@classmethod
152-
def send(cls, params: SendParams) -> Email:
171+
def send(cls, params: SendParams, options: Optional[SendOptions] = None) -> Email:
153172
"""
154173
Send an email through the Resend Email API.
155174
see more: https://resend.com/docs/api-reference/emails/send-email
156175
157176
Args:
158177
params (SendParams): The email parameters
178+
options (SendOptions): The email options
159179
160180
Returns:
161181
Email: The email object that was sent
@@ -165,6 +185,7 @@ def send(cls, params: SendParams) -> Email:
165185
path=path,
166186
params=cast(Dict[Any, Any], params),
167187
verb="post",
188+
options=cast(Dict[Any, Any], options),
168189
).perform_with_content()
169190
return resp
170191

resend/request.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict, Generic, List, Union, cast
1+
from typing import Any, Dict, Generic, List, Optional, Union, cast
22

33
import requests
44
from typing_extensions import Literal, TypeVar
@@ -19,10 +19,12 @@ def __init__(
1919
path: str,
2020
params: Union[Dict[Any, Any], List[Dict[Any, Any]]],
2121
verb: RequestVerb,
22+
options: Optional[Dict[str, Any]] = None,
2223
):
2324
self.path = path
2425
self.params = params
2526
self.verb = verb
27+
self.options = options
2628

2729
def perform(self) -> Union[T, None]:
2830
"""Is the main function that makes the HTTP request
@@ -83,12 +85,18 @@ def __get_headers(self) -> Dict[Any, Any]:
8385
Returns:
8486
Dict: configured HTTP Headers
8587
"""
86-
return {
88+
headers = {
8789
"Accept": "application/json",
8890
"Authorization": f"Bearer {resend.api_key}",
8991
"User-Agent": f"resend-python:{get_version()}",
9092
}
9193

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"]
98+
return headers
99+
92100
def make_request(self, url: str) -> requests.Response:
93101
"""make_request is a helper function that makes the actual
94102
HTTP request to the Resend API.

tests/request_test.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import unittest
2+
from typing import Any, Dict
3+
from unittest.mock import MagicMock, Mock, patch
4+
5+
from resend import request
6+
from resend.version import get_version
7+
8+
9+
class TestResendRequest(unittest.TestCase):
10+
@patch("resend.request.requests.request")
11+
@patch("resend.api_key", new="test_key")
12+
def test_request_idempotency_key_is_set(self, mock_requests: MagicMock) -> None:
13+
mock_response = Mock()
14+
mock_response.text = "{}"
15+
mock_response.status_code = 200
16+
mock_response.headers = {"content-type": "application/json"}
17+
mock_response.json.return_value = {}
18+
19+
mock_requests.return_value = mock_response
20+
21+
req = request.Request[Dict[str, Any]](
22+
path="/test",
23+
params={},
24+
verb="post",
25+
options={"idempotency_key": "abc-123"},
26+
)
27+
28+
req.perform()
29+
30+
self.assertTrue(mock_requests.called, "Expected requests.request to be called")
31+
32+
_, kwargs = mock_requests.call_args
33+
headers = kwargs["headers"]
34+
35+
self.assertEqual(headers["Accept"], "application/json")
36+
self.assertEqual(headers["Authorization"], "Bearer test_key")
37+
self.assertEqual(headers["User-Agent"], f"resend-python:{get_version()}")
38+
self.assertEqual(headers["Idempotency-Key"], "abc-123")
39+
40+
@patch("resend.request.requests.request")
41+
@patch("resend.api_key", new="test_key")
42+
def test_request_idempotency_key_is_not_set(self, mock_requests: MagicMock) -> None:
43+
mock_response = Mock()
44+
mock_response.text = "{}"
45+
mock_response.status_code = 200
46+
mock_response.headers = {"content-type": "application/json"}
47+
mock_response.json.return_value = {}
48+
49+
mock_requests.return_value = mock_response
50+
51+
req = request.Request[Dict[str, Any]](
52+
path="/test",
53+
params={},
54+
verb="post",
55+
)
56+
57+
req.perform()
58+
59+
self.assertTrue(mock_requests.called, "Expected requests.request to be called")
60+
61+
_, kwargs = mock_requests.call_args
62+
headers = kwargs["headers"]
63+
64+
self.assertEqual(headers["Accept"], "application/json")
65+
self.assertEqual(headers["Authorization"], "Bearer test_key")
66+
self.assertEqual(headers["User-Agent"], f"resend-python:{get_version()}")
67+
self.assertNotIn(
68+
"Idempotency-Key", headers, "Idempotency-Key should not be set"
69+
)

0 commit comments

Comments
 (0)