Skip to content

Commit 0f0eb30

Browse files
dahliaclaude
andcommitted
Add idempotencyKey property to Message interface
Add optional idempotencyKey field to Message for request deduplication when retrying failed send operations. By including the key in the message itself, retries become simpler: just resend the same message object. Changes: - Add optional idempotencyKey field to Message and MessageConstructor - Update createMessage() to pass through idempotencyKey - Update ResendTransport to use message.idempotencyKey - For batch operations, use the first message's idempotencyKey - Add comprehensive tests for idempotency key functionality Closes #16 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent fa3b83a commit 0f0eb30

File tree

5 files changed

+296
-5
lines changed

5 files changed

+296
-5
lines changed

CHANGES.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ Version 0.4.0
66

77
To be released.
88

9+
### @upyo/core
10+
11+
- Added `idempotencyKey` property to `Message` interface. [[#16]]
12+
13+
This allows users to provide their own idempotency key for request
14+
deduplication when retrying failed send operations. By including the key
15+
in the message itself, retries become simpler—just resend the same message
16+
object. If not provided, transports may generate their own key internally
17+
(behavior varies by transport implementation).
18+
19+
### @upyo/resend
20+
21+
- Added support for user-provided idempotency keys via `Message.idempotencyKey`.
22+
[[#16]]
23+
24+
Each message can now include an `idempotencyKey` to ensure it is not sent
25+
multiple times during retries. For batch operations via `sendMany()`, the
26+
first message's key is used for the entire batch request. If not provided,
27+
a unique key is automatically generated for each request.
28+
929

1030
Version 0.3.4
1131
-------------

packages/core/src/message.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,25 @@ export interface Message {
8686
* the headers, such as `append`, `delete`, or `set`.
8787
*/
8888
readonly headers: ImmutableHeaders;
89+
90+
/**
91+
* An idempotency key to ensure that the same message is not sent multiple
92+
* times. This is useful for retrying failed send operations without
93+
* risking duplicate delivery.
94+
*
95+
* If provided, the transport will use this key to deduplicate requests.
96+
* If not provided, the transport may generate its own key internally
97+
* (behavior varies by transport implementation).
98+
*
99+
* The key should be unique for each distinct message you want to send.
100+
* When retrying the same message, use the same idempotency key.
101+
*
102+
* Note: Not all transports support idempotency keys. Check the specific
103+
* transport documentation for details.
104+
*
105+
* @since 0.4.0
106+
*/
107+
readonly idempotencyKey?: string;
89108
}
90109

91110
/**
@@ -207,6 +226,25 @@ export interface MessageConstructor {
207226
* @default `{}`
208227
*/
209228
readonly headers?: ImmutableHeaders | Record<string, string>;
229+
230+
/**
231+
* An idempotency key to ensure that the same message is not sent multiple
232+
* times. This is useful for retrying failed send operations without
233+
* risking duplicate delivery.
234+
*
235+
* If provided, the transport will use this key to deduplicate requests.
236+
* If not provided, the transport may generate its own key internally
237+
* (behavior varies by transport implementation).
238+
*
239+
* The key should be unique for each distinct message you want to send.
240+
* When retrying the same message, use the same idempotency key.
241+
*
242+
* Note: Not all transports support idempotency keys. Check the specific
243+
* transport documentation for details.
244+
*
245+
* @since 0.4.0
246+
*/
247+
readonly idempotencyKey?: string;
210248
}
211249

212250
/**
@@ -289,6 +327,7 @@ export function createMessage(constructor: MessageConstructor): Message {
289327
priority: constructor.priority ?? "normal",
290328
tags: ensureArray(constructor.tags),
291329
headers: new Headers(constructor.headers ?? {}),
330+
idempotencyKey: constructor.idempotencyKey,
292331
};
293332
}
294333

packages/core/src/transport.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,5 @@ export interface TransportOptions {
4444
/**
4545
* The abort signal to cancel the send operation if needed.
4646
*/
47-
signal?: AbortSignal;
47+
readonly signal?: AbortSignal;
4848
}

packages/resend/src/resend-transport.test.ts

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,3 +401,234 @@ describe("ResendTransport - Config Validation", () => {
401401
assert.equal(transport.config.retries, 5);
402402
});
403403
});
404+
405+
// Note: Idempotency key tests are split into separate describe blocks
406+
// because Deno runs tests within the same describe block concurrently,
407+
// which causes globalThis.fetch mocking to interfere between tests.
408+
409+
describe("ResendTransport - Idempotency Key Provided", () => {
410+
it("should use provided idempotency key in HTTP header", async () => {
411+
const originalFetch = globalThis.fetch;
412+
const captured: { headers: Headers | null } = { headers: null };
413+
414+
try {
415+
// Mock fetch to capture headers
416+
globalThis.fetch = (_url, init) => {
417+
captured.headers = new Headers(init?.headers);
418+
return Promise.resolve(
419+
new Response(
420+
JSON.stringify({ id: "test-message-id" }),
421+
{ status: 200, headers: { "Content-Type": "application/json" } },
422+
),
423+
);
424+
};
425+
426+
const transport = new ResendTransport({ apiKey: "test-key" });
427+
428+
const testKey = `test-key-${Date.now()}`;
429+
const message: Message = {
430+
sender: { address: "sender@example.com" },
431+
recipients: [{ address: "recipient@example.com" }],
432+
ccRecipients: [],
433+
bccRecipients: [],
434+
replyRecipients: [],
435+
subject: "Test Subject",
436+
content: { text: "Test content" },
437+
attachments: [],
438+
priority: "normal",
439+
tags: [],
440+
headers: new Headers(),
441+
idempotencyKey: testKey,
442+
};
443+
444+
await transport.send(message);
445+
446+
assert.ok(captured.headers !== null);
447+
assert.equal(captured.headers.get("Idempotency-Key"), testKey);
448+
} finally {
449+
globalThis.fetch = originalFetch;
450+
}
451+
});
452+
});
453+
454+
describe("ResendTransport - Idempotency Key Auto-generated", () => {
455+
it("should generate idempotency key when not provided", async () => {
456+
const originalFetch = globalThis.fetch;
457+
const captured: { headers: Headers | null } = { headers: null };
458+
459+
try {
460+
globalThis.fetch = (_url, init) => {
461+
captured.headers = new Headers(init?.headers);
462+
return Promise.resolve(
463+
new Response(
464+
JSON.stringify({ id: "test-message-id" }),
465+
{ status: 200, headers: { "Content-Type": "application/json" } },
466+
),
467+
);
468+
};
469+
470+
const transport = new ResendTransport({ apiKey: "test-key" });
471+
472+
const message: Message = {
473+
sender: { address: "sender@example.com" },
474+
recipients: [{ address: "recipient@example.com" }],
475+
ccRecipients: [],
476+
bccRecipients: [],
477+
replyRecipients: [],
478+
subject: "Test Subject",
479+
content: { text: "Test content" },
480+
attachments: [],
481+
priority: "normal",
482+
tags: [],
483+
headers: new Headers(),
484+
};
485+
486+
await transport.send(message);
487+
488+
assert.ok(captured.headers !== null);
489+
const idempotencyKey = captured.headers.get("Idempotency-Key");
490+
assert.ok(idempotencyKey, "Idempotency-Key header should be present");
491+
assert.ok(
492+
idempotencyKey.length > 10,
493+
"Auto-generated key should have reasonable length",
494+
);
495+
} finally {
496+
globalThis.fetch = originalFetch;
497+
}
498+
});
499+
});
500+
501+
describe("ResendTransport - Idempotency Key Retry", () => {
502+
it("should allow retry with same idempotency key", async () => {
503+
const originalFetch = globalThis.fetch;
504+
const capturedKeys: string[] = [];
505+
506+
try {
507+
let callCount = 0;
508+
globalThis.fetch = (_url, init) => {
509+
const headers = new Headers(init?.headers);
510+
const key = headers.get("Idempotency-Key");
511+
if (key) capturedKeys.push(key);
512+
513+
callCount++;
514+
if (callCount === 1) {
515+
// First call fails
516+
return Promise.resolve(
517+
new Response(
518+
JSON.stringify({ message: "Server error" }),
519+
{ status: 500, headers: { "Content-Type": "application/json" } },
520+
),
521+
);
522+
}
523+
// Second call succeeds
524+
return Promise.resolve(
525+
new Response(
526+
JSON.stringify({ id: "test-message-id" }),
527+
{ status: 200, headers: { "Content-Type": "application/json" } },
528+
),
529+
);
530+
};
531+
532+
const transport = new ResendTransport({ apiKey: "test-key", retries: 0 });
533+
534+
const idempotencyKey = `retry-key-${Date.now()}`;
535+
const message: Message = {
536+
sender: { address: "sender@example.com" },
537+
recipients: [{ address: "recipient@example.com" }],
538+
ccRecipients: [],
539+
bccRecipients: [],
540+
replyRecipients: [],
541+
subject: "Test Subject",
542+
content: { text: "Test content" },
543+
attachments: [],
544+
priority: "normal",
545+
tags: [],
546+
headers: new Headers(),
547+
idempotencyKey,
548+
};
549+
550+
// First attempt fails
551+
const receipt1 = await transport.send(message);
552+
assert.equal(receipt1.successful, false);
553+
554+
// Retry with same message (same idempotency key)
555+
const receipt2 = await transport.send(message);
556+
assert.equal(receipt2.successful, true);
557+
558+
// Both calls should use the same key
559+
assert.equal(capturedKeys.length, 2);
560+
assert.equal(capturedKeys[0], idempotencyKey);
561+
assert.equal(capturedKeys[1], idempotencyKey);
562+
} finally {
563+
globalThis.fetch = originalFetch;
564+
}
565+
});
566+
});
567+
568+
describe("ResendTransport - Idempotency Key Batch", () => {
569+
it("should use provided idempotency key in batch API", async () => {
570+
const originalFetch = globalThis.fetch;
571+
const captured: { headers: Headers | null } = { headers: null };
572+
573+
try {
574+
globalThis.fetch = (url, init) => {
575+
if (typeof url === "string" && url.includes("/emails/batch")) {
576+
captured.headers = new Headers(init?.headers);
577+
return Promise.resolve(
578+
new Response(
579+
JSON.stringify({
580+
data: [{ id: "message-1" }, { id: "message-2" }],
581+
}),
582+
{ status: 200, headers: { "Content-Type": "application/json" } },
583+
),
584+
);
585+
}
586+
return Promise.reject(new Error("Unexpected URL called"));
587+
};
588+
589+
const transport = new ResendTransport({ apiKey: "test-key" });
590+
591+
const batchKey = `batch-key-${Date.now()}`;
592+
const messages: Message[] = [
593+
{
594+
sender: { address: "sender@example.com" },
595+
recipients: [{ address: "recipient1@example.com" }],
596+
ccRecipients: [],
597+
bccRecipients: [],
598+
replyRecipients: [],
599+
subject: "Test Subject 1",
600+
content: { text: "Test content 1" },
601+
attachments: [],
602+
priority: "normal",
603+
tags: [],
604+
headers: new Headers(),
605+
idempotencyKey: batchKey, // First message's key is used for batch
606+
},
607+
{
608+
sender: { address: "sender@example.com" },
609+
recipients: [{ address: "recipient2@example.com" }],
610+
ccRecipients: [],
611+
bccRecipients: [],
612+
replyRecipients: [],
613+
subject: "Test Subject 2",
614+
content: { text: "Test content 2" },
615+
attachments: [],
616+
priority: "normal",
617+
tags: [],
618+
headers: new Headers(),
619+
},
620+
];
621+
622+
const receipts = [];
623+
for await (const receipt of transport.sendMany(messages)) {
624+
receipts.push(receipt);
625+
}
626+
627+
assert.ok(captured.headers !== null);
628+
assert.equal(captured.headers.get("Idempotency-Key"), batchKey);
629+
assert.equal(receipts.length, 2);
630+
} finally {
631+
globalThis.fetch = originalFetch;
632+
}
633+
});
634+
});

packages/resend/src/resend-transport.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ export class ResendTransport implements Transport {
8989
try {
9090
options?.signal?.throwIfAborted();
9191

92-
// Generate idempotency key for reliable delivery
93-
const idempotencyKey = generateIdempotencyKey();
92+
// Use provided idempotency key or generate one for reliable delivery
93+
const idempotencyKey = message.idempotencyKey ?? generateIdempotencyKey();
9494

9595
const emailData = await convertMessage(message, this.config);
9696

@@ -250,8 +250,9 @@ export class ResendTransport implements Transport {
250250
options?.signal?.throwIfAborted();
251251

252252
try {
253-
// Generate batch idempotency key
254-
const idempotencyKey = generateIdempotencyKey();
253+
// Use first message's idempotency key or generate one for reliable delivery
254+
const idempotencyKey = messages[0]?.idempotencyKey ??
255+
generateIdempotencyKey();
255256

256257
const batchData = await convertMessagesBatch(messages, this.config);
257258

0 commit comments

Comments
 (0)