fix(02-11): bound APNs provider delivery

- Time out stalled provider sends without unhandled late rejections\n- Record a transient timeout outcome for cascade fallback\n- Cover timeout fallback with unit and integration tests
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 14:10:28 +02:00
1 parent c56d1ca50b
commit 2ae3a66816
4 files changed
+170 -9

No files matched your search

@@ -44,6 +44,7 @@ vi.mock("@/lib/logging/context", () => ({
const sendViaTelegramMock = vi.fn();
const sendViaNtfyMock = vi.fn();
const sendViaWebPushMock = vi.fn();
const sendViaApnsMock = vi.fn();
vi.mock("@/lib/notifications/senders/telegram", () => ({
sendViaTelegram: (...args: unknown[]) => sendViaTelegramMock(...args),
@@ -54,6 +55,9 @@ vi.mock("@/lib/notifications/senders/ntfy", () => ({
vi.mock("@/lib/notifications/senders/web-push", () => ({
sendViaWebPush: (...args: unknown[]) => sendViaWebPushMock(...args),
}));
vi.mock("@/lib/notifications/senders/apns", () => ({
sendViaApns: (...args: unknown[]) => sendViaApnsMock(...args),
}));
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { prisma } from "@/lib/db";
@@ -62,7 +66,7 @@ import { auditLog } from "@/lib/auth/audit";
type MockChannel = {
id: string;
userId: string;
type: "TELEGRAM" | "NTFY" | "WEB_PUSH";
type: "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS";
enabled: boolean;
config: string;
consecutiveFailures: number;
@@ -203,6 +207,36 @@ describe("dispatchNotification — soft reject (web-push 429)", () => {
});
});
describe("dispatchNotification — bounded APNs fallback", () => {
it("continues to the next channel after an APNs timeout", async () => {
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
makeChannel({ id: "apns", type: "APNS" }),
makeChannel({ id: "ntfy", type: "NTFY" }),
] as never);
sendViaApnsMock.mockResolvedValueOnce({
ok: false,
hardReject: false,
reason: "apns_timeout",
});
sendViaNtfyMock.mockResolvedValueOnce({ ok: true });
const result = await dispatchNotification({
eventType: "MEDICATION_REMINDER",
userId: "u-1",
title: "t",
message: "m",
});
expect(sendViaApnsMock).toHaveBeenCalledTimes(1);
expect(sendViaNtfyMock).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
dispatched: true,
channelsAttempted: 2,
channelsSucceeded: 1,
});
});
});
describe("dispatchNotification — give-up after 5 transient failures", () => {
it("auto-disables on the 5th in-a-row failure", async () => {
const channel = makeChannel({ type: "NTFY", consecutiveFailures: 4 });
@@ -128,6 +128,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
setEnv();
resetApnsForTesting();
});
@@ -367,6 +368,25 @@ describe("sendApnsPush — failure classification", () => {
expect(r.reason).toBe("apns_network_error");
expect(r.shouldDisable).toBeFalsy();
});
it("bounds a provider send that never settles", async () => {
vi.useFakeTimers();
sendMock.mockImplementationOnce(() => new Promise(() => undefined));
const result = sendApnsPush({
deviceToken: "abc",
environment: "sandbox",
payload: { alert: { title: "t", body: "b" } },
});
await vi.advanceTimersByTimeAsync(5_000);
await expect(result).resolves.toMatchObject({
ok: false,
reason: "apns_timeout",
shouldDisable: false,
});
});
});
describe("sendApnsPush — config disabled paths", () => {
+79 -8
View File
@@ -146,6 +146,53 @@ const PERMANENT_APNS_REASONS = new Set<string>([
*/
const APNS_COLLAPSE_ID_MAX_BYTES = 64;
/**
* node-apn owns an HTTP/2 connection pool but exposes no AbortSignal on
* Provider.send(). Bound the promise at this boundary so a stuck Apple
* connection cannot prevent the dispatcher's later fallback channels.
*/
const APNS_PROVIDER_SEND_TIMEOUT_MS = 5_000;
class ApnsProviderTimeoutError extends Error {
constructor() {
super(
`APNs provider send timed out after ${APNS_PROVIDER_SEND_TIMEOUT_MS}ms`,
);
this.name = "ApnsProviderTimeoutError";
}
}
/**
* Observe both branches of the provider promise even after the timeout wins.
* That prevents a late rejection from becoming an unhandled rejection while
* retaining a hard upper bound for the dispatcher.
*/
function awaitBoundedApnsProviderSend<T>(operation: Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
reject(new ApnsProviderTimeoutError());
}, APNS_PROVIDER_SEND_TIMEOUT_MS);
operation.then(
(result) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(result);
},
(error: unknown) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(error);
},
);
});
}
let cachedConfig: ApnsConfig | null | undefined;
const providers = new Map<"sandbox" | "production", apn.Provider>();
@@ -467,16 +514,28 @@ export async function sendApnsPush(
const start = performance.now();
let result: Awaited<ReturnType<apn.Provider["send"]>>;
try {
result = await provider.send(note, input.deviceToken);
result = await awaitBoundedApnsProviderSend(
provider.send(note, input.deviceToken),
);
} catch (err) {
const message = err instanceof Error ? err.message : "send_failed";
const timedOut = err instanceof ApnsProviderTimeoutError;
const message = timedOut
? `APNs provider send timed out after ${APNS_PROVIDER_SEND_TIMEOUT_MS}ms`
: err instanceof Error
? err.message
: "send_failed";
getEvent()?.addExternalCall({
service: "apns",
method: "send",
duration_ms: Math.round(performance.now() - start),
error: message,
error: timedOut ? "timeout" : message,
});
return { ok: false, reason: "apns_network_error", message };
return {
ok: false,
reason: timedOut ? "apns_timeout" : "apns_network_error",
shouldDisable: false,
message,
};
}
const duration = Math.round(performance.now() - start);
@@ -864,16 +923,28 @@ export async function sendApnsRawPush(
const start = performance.now();
let result: Awaited<ReturnType<apn.Provider["send"]>>;
try {
result = await provider.send(note, input.deviceToken);
result = await awaitBoundedApnsProviderSend(
provider.send(note, input.deviceToken),
);
} catch (err) {
const message = err instanceof Error ? err.message : "send_failed";
const timedOut = err instanceof ApnsProviderTimeoutError;
const message = timedOut
? `APNs provider send timed out after ${APNS_PROVIDER_SEND_TIMEOUT_MS}ms`
: err instanceof Error
? err.message
: "send_failed";
getEvent()?.addExternalCall({
service: "apns",
method: `send_${input.pushType}`,
duration_ms: Math.round(performance.now() - start),
error: message,
error: timedOut ? "timeout" : message,
});
return { ok: false, reason: "apns_network_error", message };
return {
ok: false,
reason: timedOut ? "apns_timeout" : "apns_network_error",
shouldDisable: false,
message,
};
}
const duration = Math.round(performance.now() - start);
+36
View File
@@ -235,6 +235,42 @@ describe("APNs dispatcher integration", () => {
expect(audit).toBeTruthy();
});
it("bounds a hung APNs provider and falls through to Telegram", async () => {
const { encrypt } = await import("@/lib/crypto");
await seedApnsChannel();
await seedDevice({ apnsToken: "hung-apns-token" });
await getPrismaClient().notificationChannel.create({
data: {
userId: TEST_USER_ID,
type: "TELEGRAM",
enabled: true,
config: encrypt(
JSON.stringify({ botToken: "telegram-token", chatId: "chat-id" }),
),
},
});
apnsSendMock.mockImplementationOnce(() => new Promise(() => undefined));
telegramSendMock.mockResolvedValueOnce({ ok: true, messageId: 42 });
const { dispatchNotification } =
await import("@/lib/notifications/dispatcher");
const result = await dispatchNotification({
userId: TEST_USER_ID,
eventType: "MEDICATION_REMINDER",
title: "fallback",
message: "fallback",
});
expect(apnsSendMock).toHaveBeenCalledTimes(1);
expect(telegramSendMock).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
dispatched: true,
channelsAttempted: 2,
channelsSucceeded: 1,
});
}, 7_000);
it("falls through to Telegram when APNS is disabled", async () => {
const { encrypt } = await import("@/lib/crypto");
// Pre-existing disabled APNS channel from a previous failure run.