Skip to content

Commit 6e9ba25

Browse files
committed
fix(notifications): harden resend delivery
1 parent fe3843f commit 6e9ba25

7 files changed

Lines changed: 47 additions & 20 deletions

File tree

src/lib/edge/scheduled-task-runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface ScheduledTaskContext {
3030
scheduledTime: number | null;
3131
startedAt: number;
3232
logger: ScheduledTaskLogger;
33+
externalFetch: typeof fetch;
3334
}
3435

3536
export interface ScheduledTaskDefinition {
@@ -236,6 +237,7 @@ export async function runScheduledTask(
236237
scheduledTime: scheduledAt,
237238
startedAt,
238239
logger,
240+
externalFetch: fetch.bind(globalThis),
239241
})) ?? { status: "success" as const };
240242
const finishedAt = Date.now();
241243
const status = outcome.status ?? "success";

src/lib/notifications/__tests__/delivery.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,8 @@ describe("notification delivery", () => {
568568
decryptNotificationSecret.mockResolvedValue("re_secret");
569569
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("offline"));
570570

571-
await deliverNotificationMessage(
571+
vi.useFakeTimers();
572+
const delivery = deliverNotificationMessage(
572573
{} as never,
573574
message(),
574575
{
@@ -578,13 +579,21 @@ describe("notification delivery", () => {
578579
},
579580
{},
580581
);
582+
await vi.runAllTimersAsync();
583+
await delivery;
584+
vi.useRealTimers();
581585

582586
expect(updateNotificationDeliveryResult).toHaveBeenCalledWith(
583587
expect.anything(),
584588
expect.objectContaining({
585589
status: "failed",
586-
errorMessage: "Unable to reach Resend email API",
590+
errorMessage: expect.stringContaining(
591+
"Unable to reach Resend email API",
592+
),
587593
}),
588594
);
595+
expect(
596+
updateNotificationDeliveryResult.mock.calls[0]?.[1].errorMessage,
597+
).toContain("TypeError: offline");
589598
});
590599
});

src/lib/notifications/__tests__/notification-task.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ function context(): ScheduledTaskContext & {
128128
scheduledTime: null,
129129
startedAt: Date.now(),
130130
logger,
131+
externalFetch: fetch.bind(globalThis),
131132
events,
132133
};
133134
}

src/lib/notifications/__tests__/resend-client.test.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ const payload = {
1010
html: "<p>Text</p>",
1111
};
1212

13-
async function send(fetchImpl: typeof fetch, deadlineMs = 9_000) {
13+
async function send(fetchImpl: typeof fetch, deadlineMs?: number) {
1414
const promise = sendResendEmailWithRetry({
1515
apiKey: "re_secret",
1616
body: payload,
1717
fetchImpl,
18-
deadlineMs,
18+
...(deadlineMs === undefined ? {} : { deadlineMs }),
1919
});
2020
await vi.runAllTimersAsync();
2121
return promise;
@@ -24,7 +24,6 @@ async function send(fetchImpl: typeof fetch, deadlineMs = 9_000) {
2424
describe("sendResendEmailWithRetry", () => {
2525
beforeEach(() => {
2626
vi.useFakeTimers();
27-
vi.spyOn(Math, "random").mockReturnValue(0);
2827
});
2928

3029
afterEach(() => {
@@ -47,6 +46,7 @@ describe("sendResendEmailWithRetry", () => {
4746
attempts: 2,
4847
retryCount: 1,
4948
providerMessageId: "email-1",
49+
durationMs: 1_000,
5050
});
5151
});
5252

@@ -113,11 +113,13 @@ describe("sendResendEmailWithRetry", () => {
113113

114114
expect(result).toMatchObject({
115115
ok: false,
116-
attempts: 3,
117-
retryCount: 2,
116+
attempts: 15,
117+
retryCount: 14,
118118
reason: "network_failed",
119-
errorMessage: "Unable to reach Resend email API",
120119
});
120+
expect(result.durationMs).toBe(14_000);
121+
expect(result.errorMessage).toContain("Unable to reach Resend email API");
122+
expect(result.errorMessage).toContain("Error: network");
121123
});
122124

123125
it("stops when a short deadline leaves no retry budget", async () => {

src/lib/notifications/delivery.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export async function deliverNotificationMessage(
5454
env: Env,
5555
message: NotificationMessage,
5656
user: NotificationDeliveryUser,
57-
context: { logger?: NotificationDeliveryLogger },
57+
context: { logger?: NotificationDeliveryLogger; fetchImpl?: typeof fetch },
5858
): Promise<NotificationMessage | null> {
5959
const preferences = normalizeNotificationPreferences(user.preferencesJson);
6060
const channels = { inApp: true, email: preferences.email };
@@ -207,6 +207,7 @@ export async function deliverNotificationMessage(
207207

208208
const sendResult = await sendResendEmailWithRetry({
209209
apiKey,
210+
fetchImpl: context.fetchImpl,
210211
body: {
211212
from: buildResendFromAddress(config),
212213
to: [user.email],

src/lib/notifications/notification-task.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ async function createAndDeliverMessages(input: {
189189
);
190190
const delivered = await deliverNotificationMessage(env, message, user, {
191191
logger: context.logger,
192+
fetchImpl: context.externalFetch,
192193
});
193194
collectDeliveryStats(summary, delivered);
194195
if (delivered) messages.push(delivered);
@@ -479,6 +480,7 @@ export async function createManualTestNotification(input: {
479480
);
480481
const delivered = await deliverNotificationMessage(env, message, user, {
481482
logger: context.logger,
483+
fetchImpl: context.externalFetch,
482484
});
483485
collectDeliveryStats(summary, delivered);
484486
summary.durationMs = Date.now() - context.startedAt;

src/lib/notifications/resend-client.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { clampString } from "@/lib/edge/utils";
22

33
const RESEND_EMAILS_API_URL = "https://api.resend.com/emails";
44
const NETWORK_ERROR_MESSAGE = "Unable to reach Resend email API";
5+
const DEFAULT_RETRY_DEADLINE_MS = 15_000;
6+
const DEFAULT_MAX_ATTEMPTS = 15;
7+
const RETRY_INTERVAL_MS = 1_000;
58

69
export interface ResendEmailPayload {
710
from: string;
@@ -47,6 +50,15 @@ export function sanitizeProviderError(value: unknown): string {
4750
return clampString(message, 180);
4851
}
4952

53+
function sanitizeNetworkError(error: unknown): string {
54+
if (error instanceof Error) {
55+
const name = error.name || "Error";
56+
const message = error.message || "network_failed";
57+
return clampString(`${name}: ${message}`, 180);
58+
}
59+
return clampString(String(error || "network_failed"), 180);
60+
}
61+
5062
function isRetryableStatus(status: number): boolean {
5163
return status === 429 || status >= 500;
5264
}
@@ -55,12 +67,6 @@ function delay(ms: number): Promise<void> {
5567
return new Promise((resolve) => setTimeout(resolve, ms));
5668
}
5769

58-
function backoffMs(attempts: number): number {
59-
const base = attempts <= 1 ? 450 : 1_350;
60-
const jitter = Math.floor(Math.random() * (attempts <= 1 ? 151 : 451));
61-
return base + jitter;
62-
}
63-
6470
async function fetchWithTimeout(input: {
6571
apiKey: string;
6672
body: ResendEmailPayload;
@@ -92,8 +98,12 @@ export async function sendResendEmailWithRetry(input: {
9298
maxAttempts?: number;
9399
}): Promise<ResendSendResult> {
94100
const startedAt = Date.now();
95-
const deadlineAt = startedAt + Math.max(500, input.deadlineMs ?? 9_000);
96-
const maxAttempts = Math.max(1, Math.trunc(input.maxAttempts ?? 3));
101+
const deadlineAt =
102+
startedAt + Math.max(500, input.deadlineMs ?? DEFAULT_RETRY_DEADLINE_MS);
103+
const maxAttempts = Math.max(
104+
1,
105+
Math.trunc(input.maxAttempts ?? DEFAULT_MAX_ATTEMPTS),
106+
);
97107
const fetchImpl = input.fetchImpl ?? fetch;
98108
let attempts = 0;
99109
let status = 0;
@@ -145,15 +155,15 @@ export async function sendResendEmailWithRetry(input: {
145155
reason,
146156
};
147157
}
148-
} catch {
158+
} catch (error) {
149159
status = 0;
150160
payload = {};
151161
reason = "network_failed";
152-
errorMessage = NETWORK_ERROR_MESSAGE;
162+
errorMessage = `${NETWORK_ERROR_MESSAGE}: ${sanitizeNetworkError(error)}`;
153163
}
154164

155165
if (attempts >= maxAttempts) break;
156-
const waitMs = backoffMs(attempts);
166+
const waitMs = RETRY_INTERVAL_MS;
157167
if (Date.now() + waitMs >= deadlineAt) break;
158168
await delay(waitMs);
159169
}

0 commit comments

Comments
 (0)