idempotency (#282)
This commit is contained in:
@@ -37,6 +37,19 @@ resp, _ = client.emails.send(payload={
|
||||
"html": "<strong>Hi!</strong>",
|
||||
})
|
||||
|
||||
# Idempotent retries: same payload + same key returns the original response
|
||||
resp, _ = client.emails.send(
|
||||
payload=payload,
|
||||
options={"idempotency_key": "signup-123"},
|
||||
)
|
||||
|
||||
# Works for batch requests as well
|
||||
resp, _ = client.emails.batch(
|
||||
payload=[payload],
|
||||
options={"idempotency_key": "bulk-welcome-1"},
|
||||
)
|
||||
# If the same key is reused with a different payload, the API responds with HTTP 409.
|
||||
|
||||
# 3) Campaigns
|
||||
campaign_payload: types.CampaignCreate = {
|
||||
"name": "Welcome Series",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "usesend"
|
||||
version = "0.2.7"
|
||||
version = "0.2.8"
|
||||
description = "Python SDK for the UseSend API"
|
||||
authors = ["UseSend"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,6 +48,37 @@ usesend.emails.send({
|
||||
html: "<p>useSend is the best open source product to send emails</p>",
|
||||
text: "useSend is the best open source product to send emails",
|
||||
});
|
||||
|
||||
// Safely retry sends with an idempotency key
|
||||
await usesend.emails.send(
|
||||
{
|
||||
to: "hello@acme.com",
|
||||
from: "hello@company.com",
|
||||
subject: "useSend email",
|
||||
html: "<p>useSend is the best open source product to send emails</p>",
|
||||
},
|
||||
{ idempotencyKey: "signup-123" },
|
||||
);
|
||||
|
||||
// Works for bulk sends too
|
||||
await usesend.emails.batch(
|
||||
[
|
||||
{
|
||||
to: "a@example.com",
|
||||
from: "hello@company.com",
|
||||
subject: "Welcome",
|
||||
html: "<p>Hello A</p>",
|
||||
},
|
||||
{
|
||||
to: "b@example.com",
|
||||
from: "hello@company.com",
|
||||
subject: "Welcome",
|
||||
html: "<p>Hello B</p>",
|
||||
},
|
||||
],
|
||||
{ idempotencyKey: "bulk-welcome-1" },
|
||||
);
|
||||
// Reusing the same key with a different payload returns HTTP 409.
|
||||
```
|
||||
|
||||
## Campaigns
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "usesend-js",
|
||||
"version": "1.5.6",
|
||||
"version": "1.5.7",
|
||||
"description": "",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
@@ -28,4 +28,4 @@
|
||||
"@react-email/render": "^1.0.6",
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,16 +67,23 @@ type BatchEmailResponse = {
|
||||
error: ErrorResponse | null;
|
||||
};
|
||||
|
||||
type EmailRequestOptions = {
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
export class Emails {
|
||||
constructor(private readonly usesend: UseSend) {
|
||||
this.usesend = usesend;
|
||||
}
|
||||
|
||||
async send(payload: SendEmailPayload) {
|
||||
return this.create(payload);
|
||||
async send(payload: SendEmailPayload, options?: EmailRequestOptions) {
|
||||
return this.create(payload, options);
|
||||
}
|
||||
|
||||
async create(payload: SendEmailPayload): Promise<CreateEmailResponse> {
|
||||
async create(
|
||||
payload: SendEmailPayload,
|
||||
options?: EmailRequestOptions,
|
||||
): Promise<CreateEmailResponse> {
|
||||
if (payload.react) {
|
||||
payload.html = await render(payload.react as React.ReactElement);
|
||||
delete payload.react;
|
||||
@@ -84,7 +91,10 @@ export class Emails {
|
||||
|
||||
const data = await this.usesend.post<CreateEmailResponseSuccess>(
|
||||
"/emails",
|
||||
payload
|
||||
payload,
|
||||
options?.idempotencyKey
|
||||
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
return data;
|
||||
@@ -96,11 +106,17 @@ export class Emails {
|
||||
* @param payload An array of email payloads. Max 100 emails.
|
||||
* @returns A promise that resolves to the list of created email IDs or an error.
|
||||
*/
|
||||
async batch(payload: BatchEmailPayload): Promise<BatchEmailResponse> {
|
||||
async batch(
|
||||
payload: BatchEmailPayload,
|
||||
options?: EmailRequestOptions,
|
||||
): Promise<BatchEmailResponse> {
|
||||
// Note: React element rendering is not supported in batch mode.
|
||||
const response = await this.usesend.post<BatchEmailResponseSuccess>(
|
||||
"/emails/batch",
|
||||
payload
|
||||
payload,
|
||||
options?.idempotencyKey
|
||||
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
||||
: undefined,
|
||||
);
|
||||
return {
|
||||
data: response.data ? response.data.data : null,
|
||||
|
||||
+61
-20
@@ -12,8 +12,12 @@ function isUseSendErrorResponse(error: { error: ErrorResponse }) {
|
||||
return error.error.code !== undefined;
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
headers?: HeadersInit;
|
||||
};
|
||||
|
||||
export class UseSend {
|
||||
private readonly headers: Headers;
|
||||
private readonly baseHeaders: Headers;
|
||||
|
||||
// readonly domains = new Domains(this);
|
||||
readonly emails = new Emails(this);
|
||||
@@ -42,17 +46,36 @@ export class UseSend {
|
||||
this.url = `${url}/api/v1`;
|
||||
}
|
||||
|
||||
this.headers = new Headers({
|
||||
this.baseHeaders = new Headers({
|
||||
Authorization: `Bearer ${this.key}`,
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
private mergeHeaders(extra?: HeadersInit) {
|
||||
const headers = new Headers(this.baseHeaders);
|
||||
if (!extra) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
const additional = new Headers(extra);
|
||||
additional.forEach((value, key) => {
|
||||
headers.set(key, value);
|
||||
});
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async fetchRequest<T>(
|
||||
path: string,
|
||||
options = {},
|
||||
options: RequestInit = {},
|
||||
): Promise<{ data: T | null; error: ErrorResponse | null }> {
|
||||
const response = await fetch(`${this.url}${path}`, options);
|
||||
const requestOptions: RequestInit = {
|
||||
...options,
|
||||
headers: this.mergeHeaders(options.headers),
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.url}${path}`, requestOptions);
|
||||
const defaultError = {
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: response.statusText,
|
||||
@@ -82,52 +105,70 @@ export class UseSend {
|
||||
return { data, error: null };
|
||||
}
|
||||
|
||||
async post<T>(path: string, body: unknown) {
|
||||
const requestOptions = {
|
||||
async post<T>(path: string, body: unknown, options?: RequestOptions) {
|
||||
const requestOptions: RequestInit = {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
|
||||
if (options?.headers) {
|
||||
requestOptions.headers = options.headers;
|
||||
}
|
||||
|
||||
return this.fetchRequest<T>(path, requestOptions);
|
||||
}
|
||||
|
||||
async get<T>(path: string) {
|
||||
const requestOptions = {
|
||||
async get<T>(path: string, options?: RequestOptions) {
|
||||
const requestOptions: RequestInit = {
|
||||
method: "GET",
|
||||
headers: this.headers,
|
||||
};
|
||||
|
||||
if (options?.headers) {
|
||||
requestOptions.headers = options.headers;
|
||||
}
|
||||
|
||||
return this.fetchRequest<T>(path, requestOptions);
|
||||
}
|
||||
|
||||
async put<T>(path: string, body: any) {
|
||||
const requestOptions = {
|
||||
async put<T>(path: string, body: any, options?: RequestOptions) {
|
||||
const requestOptions: RequestInit = {
|
||||
method: "PUT",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
|
||||
if (options?.headers) {
|
||||
requestOptions.headers = options.headers;
|
||||
}
|
||||
|
||||
return this.fetchRequest<T>(path, requestOptions);
|
||||
}
|
||||
|
||||
async patch<T>(path: string, body: any) {
|
||||
const requestOptions = {
|
||||
async patch<T>(path: string, body: any, options?: RequestOptions) {
|
||||
const requestOptions: RequestInit = {
|
||||
method: "PATCH",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
|
||||
if (options?.headers) {
|
||||
requestOptions.headers = options.headers;
|
||||
}
|
||||
|
||||
return this.fetchRequest<T>(path, requestOptions);
|
||||
}
|
||||
|
||||
async delete<T>(path: string, body?: unknown) {
|
||||
const requestOptions = {
|
||||
async delete<T>(path: string, body?: unknown, options?: RequestOptions) {
|
||||
const requestOptions: RequestInit = {
|
||||
method: "DELETE",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
requestOptions.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
if (options?.headers) {
|
||||
requestOptions.headers = options.headers;
|
||||
}
|
||||
|
||||
return this.fetchRequest<T>(path, requestOptions);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user