FN-7018: retry transient ntfy notification failures
Ntfy sends now retry bounded transient failures before giving up. - Add per-attempt timeouts and bounded retries for ntfy network, timeout, 5xx, and 429 failures.\n- Preserve best-effort abort behavior while retrying only retryable publish failures.\n- Cover retry, timeout, abort, payload, and non-retryable failure behavior in notifier tests.\n- Add a patch changeset for the published Fusion package.\n\nFiles changed:\n .changeset/fn-7018-ntfy-retry.md | 7 +\n packages/engine/src/__tests__/notifier.test.ts | 259 ++++++++++++++++++++++++-\n packages/engine/src/notifier.ts | 173 +++++++++++++----\n 3 files changed, 399 insertions(+), 40 deletions(-) Fusion-Task-Id: FN-7018 Fusion-Task-Lineage: 0d0b7313-532a-4583-8bb7-67fc4b2e98bf
This commit is contained in:
7
.changeset/fn-7018-ntfy-retry.md
Normal file
7
.changeset/fn-7018-ntfy-retry.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Retry transient ntfy publish failures so one-shot task notifications are less likely to be lost.
|
||||||
|
category: fix
|
||||||
|
dev: Adds bounded ntfy fetch retries for network, timeout, 5xx, and 429 failures with a per-attempt timeout.
|
||||||
@@ -133,6 +133,7 @@ describe("sendNtfyNotificationWithResult", () => {
|
|||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
||||||
new Headers(init?.headers);
|
new Headers(init?.headers);
|
||||||
return { ok: true, status: 200, statusText: "OK" } as Response;
|
return { ok: true, status: 200, statusText: "OK" } as Response;
|
||||||
@@ -140,6 +141,10 @@ describe("sendNtfyNotificationWithResult", () => {
|
|||||||
global.fetch = fetchMock;
|
global.fetch = fetchMock;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("calling with a unicode title does not throw and posts JSON with auth preserved", async () => {
|
it("calling with a unicode title does not throw and posts JSON with auth preserved", async () => {
|
||||||
const signal = new AbortController().signal;
|
const signal = new AbortController().signal;
|
||||||
|
|
||||||
@@ -157,7 +162,7 @@ describe("sendNtfyNotificationWithResult", () => {
|
|||||||
"https://ntfy.sh/",
|
"https://ntfy.sh/",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal: expect.any(AbortSignal),
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: "Bearer secret-token",
|
Authorization: "Bearer secret-token",
|
||||||
@@ -257,7 +262,7 @@ describe("sendNtfyNotificationWithResult", () => {
|
|||||||
"https://ntfy.sh/ascii-topic",
|
"https://ntfy.sh/ascii-topic",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal: expect.any(AbortSignal),
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
"Content-Type": "text/plain",
|
"Content-Type": "text/plain",
|
||||||
Title: "Task FN-1 merged",
|
Title: "Task FN-1 merged",
|
||||||
@@ -269,6 +274,256 @@ describe("sendNtfyNotificationWithResult", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not retry when the first ntfy publish succeeds", async () => {
|
||||||
|
const result = await sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
topic: "single-success",
|
||||||
|
title: "Task FN-1 merged",
|
||||||
|
message: "Task merged",
|
||||||
|
retryDelayMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, status: 200, statusText: "OK" });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries retryable network failures and returns the recovered success", async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockRejectedValueOnce(new Error("ECONNRESET"))
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200, statusText: "OK" } as Response);
|
||||||
|
|
||||||
|
const result = await sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
ntfyAccessToken: "secret-token",
|
||||||
|
topic: "retry-topic",
|
||||||
|
title: "ASCII title",
|
||||||
|
message: "ASCII message",
|
||||||
|
retryDelayMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, status: 200, statusText: "OK" });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
for (const call of fetchMock.mock.calls) {
|
||||||
|
const request = call[1] as RequestInit;
|
||||||
|
const headers = new Headers(request.headers as HeadersInit);
|
||||||
|
expect(headers.get("Authorization")).toBe("Bearer secret-token");
|
||||||
|
expect(call[0]).toBe("https://ntfy.sh/retry-topic");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ status: 503, statusText: "Service Unavailable" },
|
||||||
|
{ status: 429, statusText: "Too Many Requests" },
|
||||||
|
])("retries retryable HTTP status $status", async ({ status, statusText }) => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({ ok: false, status, statusText } as Response)
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200, statusText: "OK" } as Response);
|
||||||
|
|
||||||
|
const result = await sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
topic: "http-retry-topic",
|
||||||
|
title: "ASCII title",
|
||||||
|
message: "ASCII message",
|
||||||
|
retryDelayMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, status: 200, statusText: "OK" });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ status: 400, statusText: "Bad Request" },
|
||||||
|
{ status: 401, statusText: "Unauthorized" },
|
||||||
|
{ status: 403, statusText: "Forbidden" },
|
||||||
|
{ status: 404, statusText: "Not Found" },
|
||||||
|
])("does not retry non-429 client status $status", async ({ status, statusText }) => {
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: false, status, statusText } as Response);
|
||||||
|
|
||||||
|
const result = await sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
topic: "client-error-topic",
|
||||||
|
title: "ASCII title",
|
||||||
|
message: "ASCII message",
|
||||||
|
retryDelayMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, status, statusText });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the last HTTP failure after exhausting retryable responses", async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 503, statusText: "first" } as Response)
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 503, statusText: "second" } as Response);
|
||||||
|
|
||||||
|
const result = await sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
topic: "exhaust-http-topic",
|
||||||
|
title: "ASCII title",
|
||||||
|
message: "ASCII message",
|
||||||
|
retryDelayMs: 0,
|
||||||
|
maxAttempts: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, status: 503, statusText: "second" });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null without throwing after exhausting network retries", async () => {
|
||||||
|
fetchMock.mockRejectedValue(new Error("DNS failure"));
|
||||||
|
|
||||||
|
await expect(sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
topic: "network-fail-topic",
|
||||||
|
title: "ASCII title",
|
||||||
|
message: "ASCII message",
|
||||||
|
retryDelayMs: 0,
|
||||||
|
maxAttempts: 2,
|
||||||
|
})).resolves.toBeNull();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts a hung attempt with the per-attempt timeout and retries", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const abortError = Object.assign(new Error("aborted"), { name: "AbortError" });
|
||||||
|
fetchMock
|
||||||
|
.mockImplementationOnce((_input: string | URL, init?: RequestInit) => new Promise((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener("abort", () => reject(abortError), { once: true });
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200, statusText: "OK" } as Response);
|
||||||
|
|
||||||
|
const resultPromise = sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
topic: "timeout-topic",
|
||||||
|
title: "ASCII title",
|
||||||
|
message: "ASCII message",
|
||||||
|
attemptTimeoutMs: 5,
|
||||||
|
retryDelayMs: 0,
|
||||||
|
maxAttempts: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(5);
|
||||||
|
await expect(resultPromise).resolves.toEqual({ ok: true, status: 200, statusText: "OK" });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null after every attempt times out", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const abortError = Object.assign(new Error("aborted"), { name: "AbortError" });
|
||||||
|
fetchMock.mockImplementation((_input: string | URL, init?: RequestInit) => new Promise((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener("abort", () => reject(abortError), { once: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resultPromise = sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
topic: "timeout-null-topic",
|
||||||
|
title: "ASCII title",
|
||||||
|
message: "ASCII message",
|
||||||
|
attemptTimeoutMs: 5,
|
||||||
|
retryDelayMs: 0,
|
||||||
|
maxAttempts: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5);
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||||
|
await vi.advanceTimersByTimeAsync(5);
|
||||||
|
await expect(resultPromise).resolves.toBeNull();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short-circuits without fetch when the caller signal is already aborted", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const result = await sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
topic: "aborted-topic",
|
||||||
|
title: "ASCII title",
|
||||||
|
message: "ASCII message",
|
||||||
|
signal: controller.signal,
|
||||||
|
retryDelayMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry after a caller aborts mid-flight", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const abortError = Object.assign(new Error("aborted"), { name: "AbortError" });
|
||||||
|
fetchMock.mockImplementationOnce((_input: string | URL, init?: RequestInit) => new Promise((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener("abort", () => reject(abortError), { once: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resultPromise = sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
topic: "caller-abort-topic",
|
||||||
|
title: "ASCII title",
|
||||||
|
message: "ASCII message",
|
||||||
|
signal: controller.signal,
|
||||||
|
retryDelayMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toBeNull();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts unicode retries through the JSON publish path", async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 503, statusText: "Service Unavailable" } as Response)
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200, statusText: "OK" } as Response);
|
||||||
|
|
||||||
|
const result = await sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
topic: "unicode-topic",
|
||||||
|
title: "Triage Bot → Executor Bot",
|
||||||
|
message: "Triage Bot → you: preview text",
|
||||||
|
priority: "high",
|
||||||
|
retryDelayMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, status: 200, statusText: "OK" });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
for (const call of fetchMock.mock.calls) {
|
||||||
|
expect(call[0]).toBe("https://ntfy.sh/");
|
||||||
|
const request = call[1] as RequestInit;
|
||||||
|
const payload = JSON.parse(String(request.body)) as { topic: string; priority: number; message: string };
|
||||||
|
expect(new Headers(request.headers as HeadersInit).get("Content-Type")).toBe("application/json");
|
||||||
|
expect(payload).toEqual(expect.objectContaining({
|
||||||
|
topic: "unicode-topic",
|
||||||
|
priority: 4,
|
||||||
|
message: "Triage Bot → you: preview text",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the provider report success when the primitive retry recovers", async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockRejectedValueOnce(new Error("ECONNRESET"))
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200, statusText: "OK" } as Response);
|
||||||
|
const provider = new NtfyNotificationProvider();
|
||||||
|
await provider.initialize({
|
||||||
|
topic: "provider-retry-topic",
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
events: ["in-review"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await provider.sendNotification("in-review", {
|
||||||
|
taskId: "FN-1",
|
||||||
|
taskTitle: "Retry me",
|
||||||
|
event: "in-review",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, providerId: "ntfy" });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
await provider.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
it("truncates overlong titles to 250 characters with an ellipsis", async () => {
|
it("truncates overlong titles to 250 characters with an ellipsis", async () => {
|
||||||
await sendNtfyNotificationWithResult({
|
await sendNtfyNotificationWithResult({
|
||||||
ntfyBaseUrl: "https://ntfy.sh",
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const GRIDLOCK_NOTIFICATION_COOLDOWN_MS = 15 * 60 * 1000;
|
|||||||
const NTFY_TITLE_MAX = 250;
|
const NTFY_TITLE_MAX = 250;
|
||||||
// ntfy documents a 4 KiB message body limit; reserve room for an ellipsis when truncating UTF-8 payloads.
|
// ntfy documents a 4 KiB message body limit; reserve room for an ellipsis when truncating UTF-8 payloads.
|
||||||
const NTFY_MESSAGE_MAX = 4096;
|
const NTFY_MESSAGE_MAX = 4096;
|
||||||
|
const DEFAULT_NTFY_MAX_ATTEMPTS = 3;
|
||||||
|
const DEFAULT_NTFY_ATTEMPT_TIMEOUT_MS = 10_000;
|
||||||
|
const DEFAULT_NTFY_RETRY_DELAY_MS = 500;
|
||||||
|
|
||||||
export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
|
export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
|
||||||
"in-review",
|
"in-review",
|
||||||
@@ -57,6 +60,12 @@ export interface SendNtfyNotificationInput {
|
|||||||
priority?: NtfyNotificationPriority;
|
priority?: NtfyNotificationPriority;
|
||||||
clickUrl?: string;
|
clickUrl?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
/** @internal Test seam for exercising timeout behavior without slow wall-clock waits. */
|
||||||
|
attemptTimeoutMs?: number;
|
||||||
|
/** @internal Test seam for exercising retry behavior without slow wall-clock waits. */
|
||||||
|
retryDelayMs?: number;
|
||||||
|
/** @internal Test seam for keeping retry-bound assertions narrow. */
|
||||||
|
maxAttempts?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NtfyConfig {
|
interface NtfyConfig {
|
||||||
@@ -156,6 +165,40 @@ function ntfyPriorityToInt(priority: NtfyNotificationPriority): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRetryableNtfyStatus(status: number): boolean {
|
||||||
|
return status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return error instanceof Error && error.name === "AbortError";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number, signal?: AbortSignal): Promise<"slept" | "aborted"> {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return Promise.resolve("aborted");
|
||||||
|
}
|
||||||
|
if (ms <= 0) {
|
||||||
|
return Promise.resolve("slept");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
function cleanup() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
const onAbort = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve("aborted");
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve("slept");
|
||||||
|
}, ms);
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveNtfyEvents(events?: NtfyNotificationEvent[]): NtfyNotificationEvent[] {
|
export function resolveNtfyEvents(events?: NtfyNotificationEvent[]): NtfyNotificationEvent[] {
|
||||||
return events && events.length > 0 ? [...events] : [...DEFAULT_NTFY_EVENTS];
|
return events && events.length > 0 ? [...events] : [...DEFAULT_NTFY_EVENTS];
|
||||||
}
|
}
|
||||||
@@ -216,61 +259,115 @@ export async function sendNtfyNotificationWithResult({
|
|||||||
priority = "default",
|
priority = "default",
|
||||||
clickUrl,
|
clickUrl,
|
||||||
signal,
|
signal,
|
||||||
|
attemptTimeoutMs = DEFAULT_NTFY_ATTEMPT_TIMEOUT_MS,
|
||||||
|
retryDelayMs = DEFAULT_NTFY_RETRY_DELAY_MS,
|
||||||
|
maxAttempts = DEFAULT_NTFY_MAX_ATTEMPTS,
|
||||||
}: SendNtfyNotificationInput): Promise<{ ok: boolean; status: number; statusText: string } | null> {
|
}: SendNtfyNotificationInput): Promise<{ ok: boolean; status: number; statusText: string } | null> {
|
||||||
|
const resolvedMaxAttempts = Math.max(1, Math.floor(maxAttempts));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resolvedBaseUrl = resolveNtfyBaseUrl(ntfyBaseUrl);
|
const resolvedBaseUrl = resolveNtfyBaseUrl(ntfyBaseUrl);
|
||||||
const trimmedToken = ntfyAccessToken?.trim();
|
const trimmedToken = ntfyAccessToken?.trim();
|
||||||
const truncatedTitle = truncateNtfyTitle(title);
|
const truncatedTitle = truncateNtfyTitle(title);
|
||||||
const truncatedMessage = truncateNtfyMessage(message);
|
const truncatedMessage = truncateNtfyMessage(message);
|
||||||
const latin1Safe = isLatin1Safe(truncatedTitle) && isLatin1Safe(truncatedMessage);
|
const latin1Safe = isLatin1Safe(truncatedTitle) && isLatin1Safe(truncatedMessage);
|
||||||
|
const url = latin1Safe ? `${resolvedBaseUrl}/${topic}` : `${resolvedBaseUrl}/`;
|
||||||
|
const body = latin1Safe
|
||||||
|
? truncatedMessage
|
||||||
|
: JSON.stringify({
|
||||||
|
topic,
|
||||||
|
title: truncatedTitle,
|
||||||
|
message: truncatedMessage,
|
||||||
|
priority: ntfyPriorityToInt(priority),
|
||||||
|
...(clickUrl ? { click: clickUrl } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
/*
|
||||||
"Content-Type": latin1Safe ? "text/plain" : "application/json",
|
FNXC:Notifications 2026-06-25-18:25:
|
||||||
};
|
Task notifications are one-shot and a single transient ntfy network failure, timeout, 5xx, or 429 can permanently lose the user-facing event.
|
||||||
|
Keep ntfy best-effort and never-throwing, but bound each attempt with an internal timeout and retry only retryable failures while honoring caller lifecycle aborts immediately.
|
||||||
|
*/
|
||||||
|
for (let attempt = 1; attempt <= resolvedMaxAttempts; attempt += 1) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (latin1Safe) {
|
const attemptController = new AbortController();
|
||||||
headers.Priority = priority;
|
let timedOut = false;
|
||||||
headers.Title = truncatedTitle;
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
if (clickUrl) {
|
const onCallerAbort = () => attemptController.abort();
|
||||||
headers.Click = clickUrl;
|
signal?.addEventListener("abort", onCallerAbort, { once: true });
|
||||||
|
if (attemptTimeoutMs > 0) {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
attemptController.abort();
|
||||||
|
}, attemptTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": latin1Safe ? "text/plain" : "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (latin1Safe) {
|
||||||
|
headers.Priority = priority;
|
||||||
|
headers.Title = truncatedTitle;
|
||||||
|
if (clickUrl) {
|
||||||
|
headers.Click = clickUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedToken) {
|
||||||
|
headers.Authorization = `Bearer ${trimmedToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
signal: attemptController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
schedulerLog.log(`Ntfy notification failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
};
|
||||||
|
if (response.ok || !isRetryableNtfyStatus(response.status) || attempt === resolvedMaxAttempts) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (signal?.aborted || (isAbortError(err) && !timedOut)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (attempt === resolvedMaxAttempts) {
|
||||||
|
schedulerLog.log(`Failed to send ntfy notification: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
signal?.removeEventListener("abort", onCallerAbort);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slept = await sleep(retryDelayMs, signal);
|
||||||
|
if (slept === "aborted") {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmedToken) {
|
|
||||||
headers.Authorization = `Bearer ${trimmedToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(latin1Safe ? `${resolvedBaseUrl}/${topic}` : `${resolvedBaseUrl}/`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: latin1Safe
|
|
||||||
? truncatedMessage
|
|
||||||
: JSON.stringify({
|
|
||||||
topic,
|
|
||||||
title: truncatedTitle,
|
|
||||||
message: truncatedMessage,
|
|
||||||
priority: ntfyPriorityToInt(priority),
|
|
||||||
...(clickUrl ? { click: clickUrl } : {}),
|
|
||||||
}),
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
schedulerLog.log(`Ntfy notification failed: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: response.ok,
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name === "AbortError") {
|
if (isAbortError(err) || signal?.aborted) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
schedulerLog.log(`Failed to send ntfy notification: ${err}`);
|
schedulerLog.log(`Failed to send ntfy notification: ${err}`);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendNtfyNotification(input: SendNtfyNotificationInput): Promise<void> {
|
export async function sendNtfyNotification(input: SendNtfyNotificationInput): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user