mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add webhook delivery module with retry and backoff
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
interface DeliveryOptions {
|
||||||
|
maxRetries?: number;
|
||||||
|
initialDelayMs?: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeliveryResult {
|
||||||
|
success: boolean;
|
||||||
|
statusCode?: number;
|
||||||
|
error?: string;
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deliverWebhook(
|
||||||
|
url: string,
|
||||||
|
authHeader: string,
|
||||||
|
events: Record<string, unknown>[],
|
||||||
|
options: DeliveryOptions = {},
|
||||||
|
): Promise<DeliveryResult> {
|
||||||
|
const { maxRetries = 3, initialDelayMs = 1000, timeoutMs = 30_000 } = options;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
source: "snapotter",
|
||||||
|
version: "1",
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (authHeader) headers["Authorization"] = authHeader;
|
||||||
|
|
||||||
|
let lastError: string | undefined;
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
const delay = initialDelayMs * 2 ** (attempt - 1);
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: payload,
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return { success: true, statusCode: response.status, attempts: attempt + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = `HTTP ${response.status}`;
|
||||||
|
// Don't retry 4xx errors (client errors = won't succeed on retry)
|
||||||
|
if (response.status >= 400 && response.status < 500) {
|
||||||
|
return { success: false, statusCode: response.status, error: lastError, attempts: attempt + 1 };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: lastError, attempts: maxRetries + 1 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
describe("webhook delivery", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers a batch of events", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
|
||||||
|
const result = await deliverWebhook(
|
||||||
|
"https://siem.example.com/input",
|
||||||
|
"Bearer test-token",
|
||||||
|
[{ event: "LOGIN_SUCCESS", timestamp: "2026-01-01T00:00:00Z" }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.attempts).toBe(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const [url, opts] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe("https://siem.example.com/input");
|
||||||
|
expect(opts.headers["Authorization"]).toBe("Bearer test-token");
|
||||||
|
const body = JSON.parse(opts.body);
|
||||||
|
expect(body.source).toBe("snapotter");
|
||||||
|
expect(body.version).toBe("1");
|
||||||
|
expect(body.events).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries on server error", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 502 })
|
||||||
|
.mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
|
||||||
|
const result = await deliverWebhook("https://siem.example.com/input", "", [{ event: "test" }], {
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelayMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry on 4xx client errors", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401 });
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
|
||||||
|
const result = await deliverWebhook("https://siem.example.com/input", "", [{ event: "test" }], {
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelayMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries on network errors", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("connection refused"))
|
||||||
|
.mockRejectedValueOnce(new Error("connection refused"))
|
||||||
|
.mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
|
||||||
|
const result = await deliverWebhook("https://siem.example.com/input", "", [{ event: "test" }], {
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelayMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails after exhausting retries", async () => {
|
||||||
|
const fetchMock = vi.fn().mockRejectedValue(new Error("down"));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
|
||||||
|
const result = await deliverWebhook("https://siem.example.com/input", "", [{ event: "test" }], {
|
||||||
|
maxRetries: 2,
|
||||||
|
initialDelayMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe("down");
|
||||||
|
expect(result.attempts).toBe(3); // initial + 2 retries
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits Authorization header when auth is empty", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
|
||||||
|
await deliverWebhook("https://example.com", "", [{ event: "test" }]);
|
||||||
|
|
||||||
|
const headers = fetchMock.mock.calls[0][1].headers;
|
||||||
|
expect(headers["Authorization"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user