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
@@ -1,3 +1,5 @@
---
openapi: post /v1/emails
---
Send a transactional email via the public API.
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,9 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth";
import { sendBulkEmails } from "~/server/service/email-service";
import { EmailContent } from "~/types";
import { emailSchema } from "../../schemas/email-schema"; // Corrected import path
import { emailSchema } from "../../schemas/email-schema";
import { IdempotencyService } from "~/server/service/idempotency-service";
// Define the schema for a single email within the bulk request
// This is similar to the schema in send-email.ts but without the top-level 'required'
@@ -13,6 +13,24 @@ const route = createRoute({
method: "post",
path: "/v1/emails/batch",
request: {
headers: z
.object({
"Idempotency-Key": z
.string()
.min(1)
.max(256)
.optional()
.openapi({
description: `Pass the optional Idempotency-Key header to make the request safe to retry. The key can be up to 256 characters. The server stores the canonical request body and behaves as follows:
- Same key + same request body → returns the original emailId with 200 OK without re-sending.
- Same key + different request body → returns 409 Conflict with code: NOT_UNIQUE so you can detect the mismatch.
- Same key while another request is still being processed → returns 409 Conflict; retry after a short delay or once the first request completes.
Entries expire after 24 hours. Use a unique key per logical send (for example, an order or signup ID).`,
}),
})
.partial(),
body: {
required: true,
content: {
@@ -47,27 +65,41 @@ function sendBatch(app: PublicAPIApp) {
const team = c.var.team;
const emailPayloads = c.req.valid("json");
// Add teamId and apiKeyId to each email payload
const emailsToSend: Array<
EmailContent & { teamId: number; apiKeyId?: number }
> = emailPayloads.map((payload) => ({
const normalizedPayloads = emailPayloads.map((payload) => ({
...payload,
text: payload.text ?? undefined,
html:
payload.html && payload.html !== "true" && payload.html !== "false"
? payload.html
: undefined,
}));
const idemKey = c.req.header("Idempotency-Key") ?? undefined;
const responseData = await IdempotencyService.withIdempotency({
teamId: team.id,
apiKeyId: team.apiKeyId,
}));
idemKey,
payload: normalizedPayloads,
operation: async () => {
const emailsToSend: Array<
EmailContent & { teamId: number; apiKeyId?: number }
> = normalizedPayloads.map((payload) => ({
...payload,
teamId: team.id,
apiKeyId: team.apiKeyId,
}));
// Call the service function to send emails in bulk
const createdEmails = await sendBulkEmails(emailsToSend);
const createdEmails = await sendBulkEmails(emailsToSend);
// Map the result to the response format
const responseData = createdEmails.map((email) => ({
emailId: email.id,
}));
return createdEmails.map((email) => ({
emailId: email.id,
}));
},
extractEmailIds: (data) => data.map((item) => item.emailId),
formatCachedResponse: (emailIds) =>
emailIds.map((id) => ({ emailId: id })),
logContext: "bulk email send",
});
return c.json({ data: responseData });
});
@@ -2,11 +2,30 @@ import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { sendEmail } from "~/server/service/email-service";
import { emailSchema } from "../../schemas/email-schema";
import { IdempotencyService } from "~/server/service/idempotency-service";
const route = createRoute({
method: "post",
path: "/v1/emails",
request: {
headers: z
.object({
"Idempotency-Key": z
.string()
.min(1)
.max(256)
.optional()
.openapi({
description: `Pass the optional Idempotency-Key header to make the request safe to retry. The key can be up to 256 characters. The server stores the canonical request body and behaves as follows:
- Same key + same request body → returns the original emailId with 200 OK without re-sending.
- Same key + different request body → returns 409 Conflict with code: NOT_UNIQUE so you can detect the mismatch.
- Same key while another request is still being processed → returns 409 Conflict; retry after a short delay or once the first request completes.
Entries expire after 24 hours. Use a unique key per logical send (for example, an order or signup ID).`,
}),
})
.partial(),
body: {
required: true,
content: {
@@ -31,24 +50,43 @@ const route = createRoute({
function send(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
const requestBody = c.req.valid("json");
let html = undefined;
const _html = c.req.valid("json")?.html?.toString();
if (_html && _html !== "true" && _html !== "false") {
html = _html;
let html: string | undefined;
const rawHtml = requestBody?.html?.toString();
if (rawHtml && rawHtml !== "true" && rawHtml !== "false") {
html = rawHtml;
}
const email = await sendEmail({
...c.req.valid("json"),
const clientPayload = {
...requestBody,
text: requestBody.text ?? undefined,
html,
};
const idemKey = c.req.header("Idempotency-Key") ?? undefined;
const result = await IdempotencyService.withIdempotency<
typeof clientPayload,
{ emailId?: string }
>({
teamId: team.id,
apiKeyId: team.apiKeyId,
text: c.req.valid("json").text ?? undefined,
html: html,
idemKey,
payload: clientPayload,
operation: async () => {
const email = await sendEmail({
...clientPayload,
teamId: team.id,
apiKeyId: team.apiKeyId,
});
return { emailId: email?.id };
},
extractEmailIds: (result) => (result.emailId ? [result.emailId] : []),
formatCachedResponse: (emailIds) => ({ emailId: emailIds[0] }),
logContext: "email send",
});
return c.json({ emailId: email?.id });
return c.json(result);
});
}
@@ -0,0 +1,177 @@
import { getRedis } from "~/server/redis";
import { canonicalizePayload } from "~/server/utils/idempotency";
import { UnsendApiError } from "~/server/public-api/api-error";
import { logger } from "~/server/logger/log";
const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h
const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s
export type IdempotencyRecord = {
bodyHash: string;
emailIds: string[];
};
export type IdempotencyHandlerOptions<TPayload, TResult> = {
teamId: number;
idemKey: string | undefined;
payload: TPayload;
operation: () => Promise<TResult>;
extractEmailIds: (result: TResult) => string[];
formatCachedResponse: (emailIds: string[]) => TResult;
logContext: string;
};
function resultKey(teamId: number, key: string) {
return `idem:${teamId}:${key}`;
}
function lockKey(teamId: number, key: string) {
return `idemlock:${teamId}:${key}`;
}
export const IdempotencyService = {
async getResult(
teamId: number,
key: string,
): Promise<IdempotencyRecord | null> {
const redis = getRedis();
const raw = await redis.get(resultKey(teamId, key));
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
typeof (parsed as any).bodyHash === "string" &&
Array.isArray((parsed as any).emailIds)
) {
return parsed as IdempotencyRecord;
}
return null;
} catch {
return null;
}
},
async setResult(
teamId: number,
key: string,
record: IdempotencyRecord,
): Promise<void> {
const redis = getRedis();
await redis.setex(
resultKey(teamId, key),
IDEMPOTENCY_RESULT_TTL_SECONDS,
JSON.stringify(record),
);
},
async acquireLock(teamId: number, key: string): Promise<boolean> {
const redis = getRedis();
const ok = await redis.set(
lockKey(teamId, key),
"1",
"EX",
IDEMPOTENCY_LOCK_TTL_SECONDS,
"NX",
);
return ok === "OK";
},
async releaseLock(teamId: number, key: string): Promise<void> {
const redis = getRedis();
await redis.del(lockKey(teamId, key));
},
async withIdempotency<TPayload, TResult>(
options: IdempotencyHandlerOptions<TPayload, TResult>,
): Promise<TResult> {
const {
teamId,
idemKey,
payload,
operation,
extractEmailIds,
formatCachedResponse,
logContext,
} = options;
// Validate idempotency key length
if (idemKey !== undefined && (idemKey.length < 1 || idemKey.length > 256)) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "Invalid Idempotency-Key length",
});
}
// If no idempotency key, just execute the operation
if (!idemKey) {
return await operation();
}
// Calculate payload hash
const { bodyHash: payloadHash } = canonicalizePayload(payload);
// Check for existing result
const existing = await this.getResult(teamId, idemKey);
if (existing) {
if (existing.bodyHash === payloadHash) {
logger.info({ teamId }, `Idempotency hit for ${logContext}`);
return formatCachedResponse(existing.emailIds);
}
throw new UnsendApiError({
code: "NOT_UNIQUE",
message: "Idempotency-Key already used with a different payload",
});
}
// Try to acquire lock
const lockAcquired = await this.acquireLock(teamId, idemKey);
if (!lockAcquired) {
// Check again in case another request completed
const again = await this.getResult(teamId, idemKey);
if (again) {
if (again.bodyHash === payloadHash) {
logger.info(
{ teamId },
`Idempotency hit after contention for ${logContext}`,
);
return formatCachedResponse(again.emailIds);
}
throw new UnsendApiError({
code: "NOT_UNIQUE",
message: "Idempotency-Key already used with a different payload",
});
}
throw new UnsendApiError({
code: "NOT_UNIQUE",
message:
"Request with same Idempotency-Key is in progress. Retry later.",
});
}
try {
// Execute the operation
const result = await operation();
// Store the result for future idempotency checks
await this.setResult(teamId, idemKey, {
bodyHash: payloadHash,
emailIds: extractEmailIds(result),
});
return result;
} finally {
// Always release the lock
await this.releaseLock(teamId, idemKey);
}
},
};
export const IDEMPOTENCY_CONSTANTS = {
RESULT_TTL_SECONDS: IDEMPOTENCY_RESULT_TTL_SECONDS,
LOCK_TTL_SECONDS: IDEMPOTENCY_LOCK_TTL_SECONDS,
};
+69
View File
@@ -0,0 +1,69 @@
import { createHash } from "crypto";
type CanonicalValue =
| string
| number
| boolean
| null
| CanonicalValue[]
| { [key: string]: CanonicalValue };
function normalize(value: unknown): CanonicalValue | undefined {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (Array.isArray(value)) {
return value.map((item) => normalize(item) ?? null);
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>).sort(
([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0)
);
const result: Record<string, CanonicalValue> = {};
for (const [key, val] of entries) {
const normalized = normalize(val);
if (normalized !== undefined) {
result[key] = normalized;
}
}
return result;
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return value;
}
if (typeof value === "boolean") {
return value;
}
if (typeof value === "bigint") {
return value.toString();
}
return String(value);
}
export function canonicalizePayload(payload: unknown) {
const normalized = normalize(payload);
const canonical = JSON.stringify(normalized ?? null);
const bodyHash = createHash("sha256").update(canonical).digest("hex");
return { canonical, bodyHash };
}
+13
View File
@@ -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 -1
View File
@@ -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"
+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
+31
View File
@@ -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
+2 -2
View File
@@ -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"
}
}
}
+22 -6
View File
@@ -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
View File
@@ -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);
}
}