Fix: Webhook-Zustellung haertbar — Discord-Rate-Limit, Retries, Kappung, Logging
Audit-Befunde N5/RATELIMIT (HIGH), DEDUP-BEFORE-CONFIRM, SLICE-SURROGATE, BODY-UNCONSUMED, SET-1(a): - Alle Sends laufen jetzt seriell durch eine Queue mit 450ms Mindestabstand — Burst-Completions (viele Pakete gleichzeitig fertig) liefen sonst in Discords 5-pro-2s-Limit und die ueberzaehligen Benachrichtigungen waren weg. - 429 wird mit Discords retry_after (Sekunden -> ms, Header oder JSON-Body) wiederholt, 5xx/Netzwerkfehler mit Backoff (2 Retries); 4xx bleibt endgueltig. - Response-Body wird immer konsumiert (undici-Verbindung nicht bis zum GC halten). - 2000-Zeichen-Kappung surrogat-sicher (kein zerrissenes Emoji -> Discord 400). - Ungueltige (nicht-leere) Webhook-URL loggt jetzt eine Warnung statt still zu verwerfen. - Tests: 17 (Retry-Pfade 429/5xx/Netz, 4xx ohne Retry, Serialisierung, Kappung).
This commit is contained in:
parent
d8134ce74d
commit
f060d0238e
@ -8,6 +8,10 @@ export interface NotifyPayload {
|
||||
|
||||
const NOTIFY_TIMEOUT_MS = 5000;
|
||||
const WEBHOOK_USERNAME = "Real-Debrid Downloader";
|
||||
const MIN_SEND_GAP_MS = 450;
|
||||
const RETRY_DELAYS_MS = [1000, 2500];
|
||||
const RATE_LIMIT_MAX_WAIT_MS = 15_000;
|
||||
const CONTENT_MAX_CHARS = 2000;
|
||||
|
||||
export function isNotifyUrlValid(url: string): boolean {
|
||||
return /^https?:\/\/\S+$/i.test(String(url || "").trim());
|
||||
@ -26,9 +30,23 @@ export function normalizeDiscordMention(raw: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Discord counts the limit itself; slicing UTF-16 units can split a surrogate
|
||||
// pair at the boundary, which Discord rejects as invalid content.
|
||||
export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string {
|
||||
if (content.length <= maxChars) {
|
||||
return content;
|
||||
}
|
||||
let cut = content.slice(0, maxChars);
|
||||
const last = cut.charCodeAt(cut.length - 1);
|
||||
if (last >= 0xd800 && last <= 0xdbff) {
|
||||
cut = cut.slice(0, -1);
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
|
||||
export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } {
|
||||
const mention = normalizeDiscordMention(payload.mention || "");
|
||||
const content = `${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`.slice(0, 2000);
|
||||
const content = truncateContent(`${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`);
|
||||
return {
|
||||
url: String(url || "").trim(),
|
||||
init: {
|
||||
@ -39,20 +57,96 @@ export function buildNotifyRequest(url: string, payload: NotifyPayload): { url:
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendNotification(url: string, payload: NotifyPayload, fetchFn: typeof fetch = fetch): Promise<boolean> {
|
||||
if (!isNotifyUrlValid(url)) {
|
||||
return false;
|
||||
function delayMs(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function consumeBody(response: Response): Promise<string> {
|
||||
try {
|
||||
return await response.text();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(response: Response, bodyText: string): number {
|
||||
const headerSeconds = Number(response.headers.get("X-RateLimit-Reset-After") || response.headers.get("Retry-After") || "");
|
||||
if (Number.isFinite(headerSeconds) && headerSeconds > 0) {
|
||||
return Math.ceil(headerSeconds * 1000);
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as { retry_after?: number };
|
||||
if (typeof parsed.retry_after === "number" && parsed.retry_after > 0) {
|
||||
return Math.ceil(parsed.retry_after * 1000);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return 1500;
|
||||
}
|
||||
|
||||
async function sendOnce(url: string, payload: NotifyPayload, fetchFn: typeof fetch): Promise<{ ok: boolean; retryable: boolean; waitMs: number; detail: string }> {
|
||||
try {
|
||||
const request = buildNotifyRequest(url, payload);
|
||||
const response = await fetchFn(request.url, { ...request.init, signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS) });
|
||||
if (!response.ok) {
|
||||
logger.warn(`Benachrichtigung fehlgeschlagen (HTTP ${response.status}): ${payload.title}`);
|
||||
return false;
|
||||
const bodyText = await consumeBody(response);
|
||||
if (response.ok) {
|
||||
return { ok: true, retryable: false, waitMs: 0, detail: "" };
|
||||
}
|
||||
return true;
|
||||
if (response.status === 429) {
|
||||
const waitMs = Math.min(RATE_LIMIT_MAX_WAIT_MS, parseRetryAfterMs(response, bodyText));
|
||||
return { ok: false, retryable: true, waitMs, detail: `HTTP 429 (Rate-Limit, warte ${waitMs}ms)` };
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
return { ok: false, retryable: true, waitMs: 0, detail: `HTTP ${response.status}` };
|
||||
}
|
||||
return { ok: false, retryable: false, waitMs: 0, detail: `HTTP ${response.status}` };
|
||||
} catch (error) {
|
||||
logger.warn(`Benachrichtigung fehlgeschlagen: ${String(error)}`);
|
||||
return false;
|
||||
return { ok: false, retryable: true, waitMs: 0, detail: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
// All sends share one chain: serialized with a minimum gap so burst completions
|
||||
// (many packages finishing together) stay under Discord's 5-per-2s webhook
|
||||
// bucket instead of getting dropped as 429s.
|
||||
let sendChain: Promise<void> = Promise.resolve();
|
||||
let lastSendCompletedAt = 0;
|
||||
|
||||
export async function sendNotification(
|
||||
url: string,
|
||||
payload: NotifyPayload,
|
||||
fetchFn: typeof fetch = fetch,
|
||||
sleepFn: (ms: number) => Promise<void> = delayMs
|
||||
): Promise<boolean> {
|
||||
if (!isNotifyUrlValid(url)) {
|
||||
if (String(url || "").trim()) {
|
||||
logger.warn(`Benachrichtigung nicht gesendet: ungueltige Webhook-URL (muss mit http(s):// beginnen): ${payload.title}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const result = sendChain.then(async () => {
|
||||
const sinceLast = Date.now() - lastSendCompletedAt;
|
||||
if (sinceLast < MIN_SEND_GAP_MS) {
|
||||
await sleepFn(MIN_SEND_GAP_MS - sinceLast);
|
||||
}
|
||||
let lastDetail = "";
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const outcome = await sendOnce(url, payload, fetchFn);
|
||||
if (outcome.ok) {
|
||||
return true;
|
||||
}
|
||||
lastDetail = outcome.detail;
|
||||
if (!outcome.retryable || attempt >= RETRY_DELAYS_MS.length) {
|
||||
break;
|
||||
}
|
||||
await sleepFn(outcome.waitMs > 0 ? outcome.waitMs : RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
logger.warn(`Benachrichtigung fehlgeschlagen (${lastDetail}): ${payload.title}`);
|
||||
return false;
|
||||
});
|
||||
sendChain = result.then(() => {
|
||||
lastSendCompletedAt = Date.now();
|
||||
}, () => {
|
||||
lastSendCompletedAt = Date.now();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildNotifyRequest, isNotifyUrlValid, normalizeDiscordMention, sendNotification } from "../src/main/notify";
|
||||
import { buildNotifyRequest, isNotifyUrlValid, normalizeDiscordMention, sendNotification, truncateContent } from "../src/main/notify";
|
||||
|
||||
const noSleep = async (): Promise<void> => {};
|
||||
const WEBHOOK = "https://discord.com/api/webhooks/123/abc";
|
||||
|
||||
describe("normalizeDiscordMention", () => {
|
||||
it("wraps a bare user ID as a pinging mention", () => {
|
||||
@ -20,40 +23,52 @@ describe("normalizeDiscordMention", () => {
|
||||
|
||||
describe("isNotifyUrlValid", () => {
|
||||
it("accepts http/https URLs", () => {
|
||||
expect(isNotifyUrlValid("https://discord.com/api/webhooks/123/abc")).toBe(true);
|
||||
expect(isNotifyUrlValid(WEBHOOK)).toBe(true);
|
||||
expect(isNotifyUrlValid("http://192.168.1.10:8080/hook")).toBe(true);
|
||||
expect(isNotifyUrlValid(" https://discord.com/api/webhooks/123/abc ")).toBe(true);
|
||||
expect(isNotifyUrlValid(` ${WEBHOOK} `)).toBe(true);
|
||||
});
|
||||
it("rejects empty and non-http values", () => {
|
||||
expect(isNotifyUrlValid("")).toBe(false);
|
||||
expect(isNotifyUrlValid("discord.com/api/webhooks/123/abc")).toBe(false);
|
||||
expect(isNotifyUrlValid("ftp://x")).toBe(false);
|
||||
expect(isNotifyUrlValid("https:// mit leerzeichen")).toBe(false);
|
||||
expect(isNotifyUrlValid("***")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateContent", () => {
|
||||
it("leaves short content untouched", () => {
|
||||
expect(truncateContent("hallo")).toBe("hallo");
|
||||
});
|
||||
it("caps at the limit", () => {
|
||||
expect(truncateContent("x".repeat(3000)).length).toBe(2000);
|
||||
});
|
||||
it("never splits a surrogate pair at the boundary", () => {
|
||||
const emoji = "🏁";
|
||||
const content = "x".repeat(1999) + emoji;
|
||||
const cut = truncateContent(content);
|
||||
expect(cut.length).toBe(1999);
|
||||
expect(/[\uD800-\uDBFF]$/.test(cut)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNotifyRequest", () => {
|
||||
it("builds a Discord-compatible JSON webhook POST (bold title + message as content)", () => {
|
||||
const req = buildNotifyRequest(" https://discord.com/api/webhooks/123/abc ", { title: "✅ Paket fertig", message: "Show.S01\n5 Datei(en)" });
|
||||
expect(req.url).toBe("https://discord.com/api/webhooks/123/abc");
|
||||
const req = buildNotifyRequest(` ${WEBHOOK} `, { title: "✅ Paket fertig", message: "Show.S01\n5 Datei(en)" });
|
||||
expect(req.url).toBe(WEBHOOK);
|
||||
expect(req.init.method).toBe("POST");
|
||||
expect(req.init.headers).toMatchObject({ "Content-Type": "application/json" });
|
||||
const body = JSON.parse(String(req.init.body));
|
||||
expect(body.content).toBe("**✅ Paket fertig**\nShow.S01\n5 Datei(en)");
|
||||
expect(body.username).toBe("Real-Debrid Downloader");
|
||||
});
|
||||
it("caps the content at Discord's 2000-char limit", () => {
|
||||
const req = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { title: "T", message: "x".repeat(3000) });
|
||||
const body = JSON.parse(String(req.init.body));
|
||||
expect(body.content.length).toBe(2000);
|
||||
});
|
||||
it("prepends the mention so Discord pings (bare ID gets wrapped)", () => {
|
||||
const req = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M", mention: "123456789012345678" });
|
||||
const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "123456789012345678" });
|
||||
const body = JSON.parse(String(req.init.body));
|
||||
expect(body.content).toBe("<@123456789012345678> **T**\nM");
|
||||
});
|
||||
it("sends no mention prefix when the field is empty", () => {
|
||||
const req = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M", mention: "" });
|
||||
const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "" });
|
||||
const body = JSON.parse(String(req.init.body));
|
||||
expect(body.content).toBe("**T**\nM");
|
||||
});
|
||||
@ -62,21 +77,51 @@ describe("buildNotifyRequest", () => {
|
||||
describe("sendNotification", () => {
|
||||
it("returns true on HTTP ok (Discord answers 204 No Content)", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
await expect(sendNotification("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M" }, fetchFn)).resolves.toBe(true);
|
||||
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(true);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("returns false on HTTP error without throwing", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
|
||||
await expect(sendNotification("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false);
|
||||
it("retries a 429 using Discord's retry_after and then succeeds", async () => {
|
||||
const waits: number[] = [];
|
||||
const sleepSpy = async (ms: number): Promise<void> => { waits.push(ms); };
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ retry_after: 1.2 }), { status: 429 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, sleepSpy)).resolves.toBe(true);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
expect(waits).toContain(1200); // seconds -> ms
|
||||
});
|
||||
it("returns false on network error without throwing", async () => {
|
||||
const fetchFn = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
await expect(sendNotification("https://discord.com/api/webhooks/123/abc", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false);
|
||||
it("retries transient 5xx and network errors, then gives up", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 502 }));
|
||||
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3); // initial + 2 retries
|
||||
|
||||
const fetchErr = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchErr, noSleep)).resolves.toBe(false);
|
||||
expect(fetchErr).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
it("does not retry a permanent 4xx", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 404 }));
|
||||
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("serializes concurrent sends in order (burst protection)", async () => {
|
||||
const order: string[] = [];
|
||||
const fetchFn = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
order.push(JSON.parse(String(init.body)).content);
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
const sends = [
|
||||
sendNotification(WEBHOOK, { title: "1", message: "" }, fetchFn, noSleep),
|
||||
sendNotification(WEBHOOK, { title: "2", message: "" }, fetchFn, noSleep),
|
||||
sendNotification(WEBHOOK, { title: "3", message: "" }, fetchFn, noSleep)
|
||||
];
|
||||
await expect(Promise.all(sends)).resolves.toEqual([true, true, true]);
|
||||
expect(order).toEqual(["**1**\n", "**2**\n", "**3**\n"]);
|
||||
});
|
||||
it("does not call fetch for an invalid URL", async () => {
|
||||
const fetchFn = vi.fn();
|
||||
await expect(sendNotification("", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false);
|
||||
await expect(sendNotification("kein-url", { title: "T", message: "M" }, fetchFn)).resolves.toBe(false);
|
||||
await expect(sendNotification("", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
|
||||
await expect(sendNotification("kein-url", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user