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:
Sucukdeluxe 2026-06-10 00:17:57 +02:00
parent d8134ce74d
commit f060d0238e
2 changed files with 170 additions and 31 deletions

View File

@ -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;
}

View File

@ -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();
});
});