idempotency (#282)

This commit is contained in:
KM Koushik
2025-11-17 11:42:09 +11:00
committed by GitHub
parent eacf231173
commit cb489654b5
14 changed files with 859 additions and 1118 deletions
+36 -6
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
from typing_extensions import TypedDict
from .types import (
APIError,
@@ -18,6 +19,17 @@ from .types import (
)
class EmailOptions(TypedDict, total=False):
"""Options for email operations."""
idempotency_key: Optional[str]
def _idem_headers(idempotency_key: Optional[str]) -> Optional[Dict[str, str]]:
if idempotency_key:
return {"Idempotency-Key": idempotency_key}
return None
class Emails:
"""Client for `/emails` endpoints."""
@@ -25,11 +37,19 @@ class Emails:
self.usesend = usesend
# Basic operations -------------------------------------------------
def send(self, payload: EmailCreate) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
def send(
self,
payload: EmailCreate,
options: Optional[EmailOptions] = None,
) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
"""Alias for :meth:`create`."""
return self.create(payload)
return self.create(payload, options)
def create(self, payload: Union[EmailCreate, Dict[str, Any]]) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
def create(
self,
payload: Union[EmailCreate, Dict[str, Any]],
options: Optional[EmailOptions] = None,
) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
if isinstance(payload, dict):
payload = dict(payload)
@@ -42,10 +62,17 @@ class Emails:
if isinstance(body.get("scheduledAt"), datetime):
body["scheduledAt"] = body["scheduledAt"].isoformat()
data, err = self.usesend.post("/emails", body)
idempotency_key = options.get("idempotency_key") if options else None
data, err = self.usesend.post(
"/emails", body, headers=_idem_headers(idempotency_key)
)
return (data, err) # type: ignore[return-value]
def batch(self, payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]]) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]:
def batch(
self,
payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]],
options: Optional[EmailOptions] = None,
) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]:
items: List[Dict[str, Any]] = []
for item in payload:
d = dict(item)
@@ -54,7 +81,10 @@ class Emails:
if isinstance(d.get("scheduledAt"), datetime):
d["scheduledAt"] = d["scheduledAt"].isoformat()
items.append(d)
data, err = self.usesend.post("/emails/batch", items)
idempotency_key = options.get("idempotency_key") if options else None
data, err = self.usesend.post(
"/emails/batch", items, headers=_idem_headers(idempotency_key)
)
return (data, err) # type: ignore[return-value]
def get(self, email_id: str) -> Tuple[Optional[Email], Optional[APIError]]:
+45 -12
View File
@@ -77,12 +77,25 @@ class UseSend:
# ------------------------------------------------------------------
# Internal request helper
# ------------------------------------------------------------------
def _build_headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
headers = dict(self.headers)
if extra:
headers.update({k: v for k, v in extra.items() if v is not None})
return headers
def _request(
self, method: str, path: str, json: Optional[Any] = None
self,
method: str,
path: str,
json: Optional[Any] = None,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Perform an HTTP request and return ``(data, error)``."""
resp = self._session.request(
method, f"{self.url}{path}", headers=self.headers, json=json
method,
f"{self.url}{path}",
headers=self._build_headers(headers),
json=json,
)
default_error = {"code": "INTERNAL_SERVER_ERROR", "message": resp.reason}
@@ -104,22 +117,42 @@ class UseSend:
# ------------------------------------------------------------------
# HTTP verb helpers
# ------------------------------------------------------------------
def post(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("POST", path, json=body)
def post(
self,
path: str,
body: Any,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("POST", path, json=body, headers=headers)
def get(self, path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("GET", path)
def get(
self, path: str, headers: Optional[Dict[str, str]] = None
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("GET", path, headers=headers)
def put(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("PUT", path, json=body)
def put(
self,
path: str,
body: Any,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("PUT", path, json=body, headers=headers)
def patch(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("PATCH", path, json=body)
def patch(
self,
path: str,
body: Any,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("PATCH", path, json=body, headers=headers)
def delete(
self, path: str, body: Optional[Any] = None
self,
path: str,
body: Optional[Any] = None,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("DELETE", path, json=body)
return self._request("DELETE", path, json=body, headers=headers)
# Import here to avoid circular dependency during type checking