mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user