From f060d0238e677f3c8c5480a8b54558c47a0b1367 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 10 Jun 2026 00:17:57 +0200 Subject: [PATCH] =?UTF-8?q?Fix:=20Webhook-Zustellung=20haertbar=20?= =?UTF-8?q?=E2=80=94=20Discord-Rate-Limit,=20Retries,=20Kappung,=20Logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/main/notify.ts | 114 +++++++++++++++++++++++++++++++++++++++---- tests/notify.test.ts | 87 +++++++++++++++++++++++++-------- 2 files changed, 170 insertions(+), 31 deletions(-) diff --git a/src/main/notify.ts b/src/main/notify.ts index 94ebda6..6b706ba 100644 --- a/src/main/notify.ts +++ b/src/main/notify.ts @@ -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 { - if (!isNotifyUrlValid(url)) { - return false; +function delayMs(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function consumeBody(response: Response): Promise { + 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 = Promise.resolve(); +let lastSendCompletedAt = 0; + +export async function sendNotification( + url: string, + payload: NotifyPayload, + fetchFn: typeof fetch = fetch, + sleepFn: (ms: number) => Promise = delayMs +): Promise { + 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; +} diff --git a/tests/notify.test.ts b/tests/notify.test.ts index 42acb87..167e1bb 100644 --- a/tests/notify.test.ts +++ b/tests/notify.test.ts @@ -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 => {}; +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 => { 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(); }); });