Skip to content

Commit 9c0fd00

Browse files
authored
Repair idempotent dispatch failures (#51)
## Summary - re-dispatch stored `queued` and `scheduled` emails when an identical idempotent request is replayed - preserve the original email ID and stored schedule during recovery - rely on the existing atomic send lease and final-state checks to collapse duplicate SQS deliveries - document the ambiguous-acceptance recovery procedure and at-least-once boundary ## Root cause Email persistence and SQS/EventBridge dispatch are separate operations. If persistence succeeded and dispatch then failed, the API returned an error but retained the email and idempotency claim. An identical retry returned that stored record without restoring an immediate or short-delay job, so the email could remain queued forever. ## Impact Clients can safely retry the identical payload with the same idempotency key after an ambiguous API failure. HayaSend returns the same ID and restores dispatch for eligible non-final records. Replays never re-dispatch `sending`, canceled, suppressed, or terminal records. This remains an immediate risk-reduction bridge. The accepted #81/#84 direction replaces client-triggered repair with durable outbox reconciliation in the provider-neutral semantics series. ## Validation - `npm ci` - `npm run check` - `npm test` — 22 files, 170 tests - `npm run build` - `npm run lint:openapi` - `npm audit --omit=dev --audit-level=high` — 0 vulnerabilities - `npm outdated --json` — no direct dependency updates available - `git diff --check` Closes #50 Signed-off-by: Yusuke Hayashi <yusuke8h@gmail.com>
1 parent 6150709 commit 9c0fd00

6 files changed

Lines changed: 143 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ minor releases before v1.0.
88

99
- Stop retrying permanent SES request rejections while preserving retries for
1010
throttling, provider availability, network, timeout, and unknown failures.
11+
- Repair missing SQS or EventBridge dispatch when an identical idempotent
12+
replay finds a stored queued or scheduled email.
1113

1214
## 0.1.0 - 2026-07-26
1315

docs/architecture.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ Scheduler names are derived from email IDs. Rescheduling replaces the same
4141
one-time schedule, cancellation deletes it, and stale SQS deliveries reload
4242
the current DynamoDB record before doing any work.
4343

44+
If persistence succeeds but SQS or Scheduler dispatch fails, an identical
45+
request with the same idempotency key re-dispatches the stored non-final email
46+
using its stored schedule. This can create a duplicate SQS job when the first
47+
dispatch actually succeeded but its response was lost. The worker lease and
48+
final-state check are designed for that at-least-once condition.
49+
4450
There is still an unavoidable narrow failure window if SES accepts a message
4551
and the worker stops before recording the provider ID. A later retry can
4652
produce a duplicate. HayaSend documents this at-least-once boundary instead

docs/operations.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,19 @@ If a received email is visible in S3 but absent from the API:
142142
Never paste the raw MIME into logs or issue trackers. Use object identifiers
143143
and request IDs during diagnosis.
144144

145+
## Ambiguous send acceptance
146+
147+
If `POST /emails` or `POST /emails/batch` fails after the request reached
148+
HayaSend, retry the identical payload with the same idempotency key. HayaSend
149+
returns the original email ID and re-dispatches a stored `queued` or
150+
`scheduled` record, repairing a failure between persistence and SQS or
151+
Scheduler acceptance. A replay can create a duplicate SQS job, which the
152+
worker lease and final-state check normally collapse.
153+
154+
Do not change the payload or generate a new key during this recovery. A
155+
different payload conflicts with the existing claim, while a new key creates
156+
a different email and can result in two deliveries.
157+
145158
## Dead-letter queue
146159

147160
1. Pause the producer if the failure is systemic.

src/services/email-service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,9 @@ export class EmailService {
242242
await this.webhooks.publish("email.scheduled", record);
243243
}
244244
} else if (
245-
created.record.status === "scheduled" &&
246-
created.record.scheduled_at &&
247-
secondsUntil(created.record.scheduled_at, now) > 900
245+
created.record.status === "queued" ||
246+
(created.record.status === "scheduled" &&
247+
created.record.scheduled_at !== undefined)
248248
) {
249249
await this.scheduler.schedule(
250250
created.record.id,

tests/app.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ describe("HTTP API", () => {
504504
});
505505
});
506506

507-
it("replays identical idempotent requests without a second job", async () => {
507+
it("replays identical idempotent requests with the same id and a repair job", async () => {
508508
const { queue, request } = fixture();
509509
const init = {
510510
method: "POST",
@@ -518,7 +518,7 @@ describe("HTTP API", () => {
518518
id: string;
519519
};
520520
expect(second.id).toBe(first.id);
521-
expect(queue.jobs).toHaveLength(1);
521+
expect(queue.jobs).toHaveLength(2);
522522

523523
const conflict = await request("/emails", {
524524
...init,

tests/email-service.test.ts

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { MemoryStore } from "../src/adapters/memory-store.js";
33
import { MemoryAttachmentStorage } from "../src/adapters/attachment-storage.js";
44
import { QueueEmailScheduler } from "../src/adapters/email-scheduler.js";
55
import { CapturingJobQueue } from "../src/adapters/sqs-job-queue.js";
6-
import type { EmailRecord } from "../src/core/types.js";
6+
import type { EmailRecord, EmailStatus } from "../src/core/types.js";
77
import type {
88
MailTransport,
99
MailTransportResult,
@@ -35,6 +35,7 @@ class RecordingEmailScheduler implements EmailScheduler {
3535
emailId: string;
3636
scheduledAt: string;
3737
}> = [];
38+
scheduleFailures = 0;
3839
onFirstReschedule?: () => Promise<void>;
3940

4041
constructor(private readonly queueScheduler: QueueEmailScheduler) {}
@@ -44,6 +45,10 @@ class RecordingEmailScheduler implements EmailScheduler {
4445
scheduledAt?: string,
4546
now?: Date,
4647
): Promise<void> {
48+
if (this.scheduleFailures > 0) {
49+
this.scheduleFailures -= 1;
50+
throw new Error("temporary scheduler failure");
51+
}
4752
await this.queueScheduler.schedule(emailId, scheduledAt, now);
4853
}
4954

@@ -125,7 +130,7 @@ describe("EmailService", () => {
125130
});
126131

127132
it("keeps a template send idempotent across later publications", async () => {
128-
const { queue, service, templates } = fixture();
133+
const { queue, service, templates, transport } = fixture();
129134
const template = await templates.create({
130135
name: "Idempotent template",
131136
from: "sender@example.com",
@@ -154,6 +159,116 @@ describe("EmailService", () => {
154159
html: "<p>One</p>",
155160
},
156161
});
162+
expect(queue.jobs).toHaveLength(2);
163+
164+
await Promise.all([
165+
service.processSend(first.record.id),
166+
service.processSend(first.record.id),
167+
]);
168+
expect(transport.sent).toHaveLength(1);
169+
});
170+
171+
it("repairs an immediate dispatch failure through idempotent replay", async () => {
172+
const { queue, scheduler, service, store } = fixture();
173+
scheduler.scheduleFailures = 1;
174+
175+
await expect(
176+
service.create(input, "repair-immediate"),
177+
).rejects.toThrow("temporary scheduler failure");
178+
const storedAfterFailure = await store.listEmails(100);
179+
expect(storedAfterFailure.data).toHaveLength(1);
180+
expect(storedAfterFailure.data[0]).toMatchObject({
181+
status: "queued",
182+
attempts: 0,
183+
});
184+
expect(queue.jobs).toHaveLength(0);
185+
186+
const replay = await service.create(input, "repair-immediate");
187+
188+
expect(replay).toMatchObject({
189+
replayed: true,
190+
record: {
191+
id: storedAfterFailure.data[0]?.id,
192+
status: "queued",
193+
},
194+
});
195+
expect(queue.jobs).toEqual([
196+
{
197+
job: {
198+
type: "send_email",
199+
email_id: storedAfterFailure.data[0]?.id,
200+
},
201+
delaySeconds: 0,
202+
},
203+
]);
204+
await expect(store.listEmails(100)).resolves.toMatchObject({
205+
data: [{ id: storedAfterFailure.data[0]?.id }],
206+
});
207+
});
208+
209+
it("repairs a short scheduled dispatch with its stored delay", async () => {
210+
const { queue, scheduler, service, store } = fixture();
211+
const now = new Date("2026-07-26T00:00:00.000Z");
212+
const scheduledAt = "2026-07-26T00:10:00.000Z";
213+
const scheduledInput = { ...input, scheduled_at: scheduledAt };
214+
scheduler.scheduleFailures = 1;
215+
216+
await expect(
217+
service.create(scheduledInput, "repair-short-schedule", now),
218+
).rejects.toThrow("temporary scheduler failure");
219+
const storedAfterFailure = await store.listEmails(100);
220+
expect(storedAfterFailure.data).toHaveLength(1);
221+
expect(queue.jobs).toHaveLength(0);
222+
223+
const replay = await service.create(
224+
scheduledInput,
225+
"repair-short-schedule",
226+
now,
227+
);
228+
229+
expect(replay).toMatchObject({
230+
replayed: true,
231+
record: {
232+
id: storedAfterFailure.data[0]?.id,
233+
status: "scheduled",
234+
scheduled_at: scheduledAt,
235+
},
236+
});
237+
expect(queue.jobs).toEqual([
238+
{
239+
job: {
240+
type: "send_email",
241+
email_id: storedAfterFailure.data[0]?.id,
242+
},
243+
delaySeconds: 600,
244+
},
245+
]);
246+
});
247+
248+
it.each<EmailStatus>([
249+
"sending",
250+
"sent",
251+
"delivered",
252+
"delivery_delayed",
253+
"opened",
254+
"clicked",
255+
"bounced",
256+
"complained",
257+
"failed",
258+
"canceled",
259+
"suppressed",
260+
])("does not redispatch a replayed %s record", async (status) => {
261+
const { queue, service, store } = fixture();
262+
const created = await service.create(input, `final-${status}`);
263+
expect(queue.jobs).toHaveLength(1);
264+
await store.updateEmail(created.record.id, {
265+
status,
266+
last_event: status,
267+
});
268+
269+
const replay = await service.create(input, `final-${status}`);
270+
271+
expect(replay.replayed).toBe(true);
157272
expect(queue.jobs).toHaveLength(1);
158273
});
159274

0 commit comments

Comments
 (0)