feat(backups): configure backup webhook timeout

This commit is contained in:
Nicolas Meienberger 2026-05-04 07:52:21 +02:00
parent 38f5a669ae
commit 9ae8017df1
No known key found for this signature in database
10 changed files with 19 additions and 5 deletions

View file

@ -45,6 +45,7 @@ describe("parseConfig", () => {
ENABLE_DEV_PANEL: "true", ENABLE_DEV_PANEL: "true",
SERVER_IP: "0.0.0.0", SERVER_IP: "0.0.0.0",
SERVER_IDLE_TIMEOUT: "120", SERVER_IDLE_TIMEOUT: "120",
WEBHOOK_TIMEOUT: "90",
PORT: "8080", PORT: "8080",
APP_VERSION: "1.2.3", APP_VERSION: "1.2.3",
MIGRATIONS_PATH: "/tmp/migrations", MIGRATIONS_PATH: "/tmp/migrations",
@ -57,6 +58,7 @@ describe("parseConfig", () => {
environment: "development", environment: "development",
serverIp: "0.0.0.0", serverIp: "0.0.0.0",
serverIdleTimeout: 120, serverIdleTimeout: 120,
webhookTimeout: 90,
resticHostname: "configured-restic-host", resticHostname: "configured-restic-host",
port: 8080, port: 8080,
migrationsPath: "/tmp/migrations", migrationsPath: "/tmp/migrations",

View file

@ -32,6 +32,7 @@ const envSchema = z
NODE_ENV: z.enum(["development", "production", "test"]).default("production"), NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
SERVER_IP: z.string().default("localhost"), SERVER_IP: z.string().default("localhost"),
SERVER_IDLE_TIMEOUT: z.coerce.number().int().default(60), SERVER_IDLE_TIMEOUT: z.coerce.number().int().default(60),
WEBHOOK_TIMEOUT: z.coerce.number().int().default(60),
RESTIC_HOSTNAME: z.string().optional(), RESTIC_HOSTNAME: z.string().optional(),
PORT: z.coerce.number().int().default(4096), PORT: z.coerce.number().int().default(4096),
MIGRATIONS_PATH: z.string().optional(), MIGRATIONS_PATH: z.string().optional(),
@ -122,6 +123,7 @@ const envSchema = z
environment: s.NODE_ENV, environment: s.NODE_ENV,
serverIp: s.SERVER_IP, serverIp: s.SERVER_IP,
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT, serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
webhookTimeout: s.WEBHOOK_TIMEOUT,
resticHostname: s.RESTIC_HOSTNAME || getResticHostname(), resticHostname: s.RESTIC_HOSTNAME || getResticHostname(),
port: s.PORT, port: s.PORT,
migrationsPath: s.MIGRATIONS_PATH, migrationsPath: s.MIGRATIONS_PATH,

View file

@ -146,6 +146,7 @@ test("close emits a synthetic backup.cancelled for a queued backup", () => {
}, },
webhooks: { pre: null, post: null }, webhooks: { pre: null, post: null },
webhookAllowedOrigins: [], webhookAllowedOrigins: [],
webhookTimeoutMs: 60_000,
}), }),
); );

View file

@ -64,6 +64,7 @@ const createBackupRunPayload = async ({
}, },
webhooks: schedule.backupWebhooks ?? { pre: null, post: null }, webhooks: schedule.backupWebhooks ?? { pre: null, post: null },
webhookAllowedOrigins: config.webhookAllowedOrigins, webhookAllowedOrigins: config.webhookAllowedOrigins,
webhookTimeoutMs: config.webhookTimeout * 1000,
}; };
}; };
@ -82,6 +83,7 @@ const executeBackupWithoutAgent = async (
options: payload.options, options: payload.options,
webhooks: payload.webhooks, webhooks: payload.webhooks,
webhookAllowedOrigins: payload.webhookAllowedOrigins, webhookAllowedOrigins: payload.webhookAllowedOrigins,
webhookTimeoutMs: payload.webhookTimeoutMs,
signal, signal,
onProgress, onProgress,
formatError: toErrorDetails, formatError: toErrorDetails,

View file

@ -48,6 +48,7 @@ test("emits backup.failed when a backup command hits a restic error", async () =
}, },
webhooks: { pre: null, post: null }, webhooks: { pre: null, post: null },
webhookAllowedOrigins: [], webhookAllowedOrigins: [],
webhookTimeoutMs: 60_000,
}), }),
); );

View file

@ -54,6 +54,7 @@ const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
}, },
webhooks: { pre: null, post: null }, webhooks: { pre: null, post: null },
webhookAllowedOrigins: ["http://localhost:8080"], webhookAllowedOrigins: ["http://localhost:8080"],
webhookTimeoutMs: 60_000,
...overrides, ...overrides,
}); });
@ -337,6 +338,7 @@ test("waits for running-job registration before returning to the processor loop"
}, },
webhooks: { pre: null, post: null }, webhooks: { pre: null, post: null },
webhookAllowedOrigins: [], webhookAllowedOrigins: [],
webhookTimeoutMs: 60_000,
}); });
const cancelPayload = fromPartial<BackupCancelPayload>({ const cancelPayload = fromPartial<BackupCancelPayload>({
jobId: "job-1", jobId: "job-1",

View file

@ -67,6 +67,7 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa
options: payload.options, options: payload.options,
webhooks: payload.webhooks, webhooks: payload.webhooks,
webhookAllowedOrigins: payload.webhookAllowedOrigins, webhookAllowedOrigins: payload.webhookAllowedOrigins,
webhookTimeoutMs: payload.webhookTimeoutMs,
signal: abortController.signal, signal: abortController.signal,
onProgress: (progress) => { onProgress: (progress) => {
void Runtime.runPromise( void Runtime.runPromise(

View file

@ -42,6 +42,7 @@ const backupRunSchema = z.object({
runtime: backupRuntimeSchema, runtime: backupRuntimeSchema,
webhooks: backupWebhooksSchema, webhooks: backupWebhooksSchema,
webhookAllowedOrigins: z.array(z.string()), webhookAllowedOrigins: z.array(z.string()),
webhookTimeoutMs: z.number(),
}), }),
}); });

View file

@ -45,6 +45,7 @@ const runWithHooks = <TResult>(
options: {}, options: {},
webhooks: { pre: null, post: null }, webhooks: { pre: null, post: null },
webhookAllowedOrigins: ["http://localhost:8080"], webhookAllowedOrigins: ["http://localhost:8080"],
webhookTimeoutMs: 60_000,
signal: defaultSignal(), signal: defaultSignal(),
...options, ...options,
}), }),

View file

@ -3,7 +3,6 @@ import { z } from "zod";
import type { CompressionMode, RepositoryConfig, ResticBackupProgressDto } from "../restic/index.js"; import type { CompressionMode, RepositoryConfig, ResticBackupProgressDto } from "../restic/index.js";
import { toErrorDetails, toMessage } from "../utils/index.js"; import { toErrorDetails, toMessage } from "../utils/index.js";
const DEFAULT_BACKUP_WEBHOOK_TIMEOUT_MS = 10_000;
const MAX_BACKUP_WEBHOOK_BODY_BYTES = 64 * 1024; const MAX_BACKUP_WEBHOOK_BODY_BYTES = 64 * 1024;
const MAX_BACKUP_WEBHOOK_HEADERS = 32; const MAX_BACKUP_WEBHOOK_HEADERS = 32;
const MAX_BACKUP_WEBHOOK_HEADER_BYTES = 8 * 1024; const MAX_BACKUP_WEBHOOK_HEADER_BYTES = 8 * 1024;
@ -82,6 +81,7 @@ type BackupLifecycleOptions<TResult> = {
options: BackupOptions; options: BackupOptions;
webhooks: BackupWebhooks; webhooks: BackupWebhooks;
webhookAllowedOrigins: readonly string[]; webhookAllowedOrigins: readonly string[];
webhookTimeoutMs: number;
signal: AbortSignal; signal: AbortSignal;
onProgress?: (progress: ResticBackupProgressDto) => void; onProgress?: (progress: ResticBackupProgressDto) => void;
formatError?: (error: unknown) => string; formatError?: (error: unknown) => string;
@ -190,7 +190,7 @@ const runBackupWebhook = (
formatError: (error: unknown) => string; formatError: (error: unknown) => string;
allowedOrigins: readonly string[]; allowedOrigins: readonly string[];
signal?: AbortSignal; signal?: AbortSignal;
timeoutMs?: number; timeoutMs: number;
}, },
) => ) =>
Effect.suspend(() => { Effect.suspend(() => {
@ -198,8 +198,7 @@ const runBackupWebhook = (
return Effect.succeed(null); return Effect.succeed(null);
} }
const timeoutMs = options.timeoutMs ?? DEFAULT_BACKUP_WEBHOOK_TIMEOUT_MS; const controller = createAbortController(options.timeoutMs, options.signal);
const controller = createAbortController(timeoutMs, options.signal);
return Effect.tryPromise({ return Effect.tryPromise({
try: async () => { try: async () => {
@ -256,6 +255,7 @@ export const runBackupLifecycle = <TResult>({
options, options,
webhooks, webhooks,
webhookAllowedOrigins, webhookAllowedOrigins,
webhookTimeoutMs,
signal, signal,
onProgress, onProgress,
formatError = toErrorDetails, formatError = toErrorDetails,
@ -268,6 +268,7 @@ export const runBackupLifecycle = <TResult>({
{ {
formatError, formatError,
allowedOrigins: webhookAllowedOrigins, allowedOrigins: webhookAllowedOrigins,
timeoutMs: webhookTimeoutMs,
signal, signal,
}, },
); );
@ -312,7 +313,7 @@ export const runBackupLifecycle = <TResult>({
status: backupResult.hookStatus, status: backupResult.hookStatus,
error: backupResult.hookError, error: backupResult.hookError,
}, },
{ formatError, allowedOrigins: webhookAllowedOrigins }, { formatError, allowedOrigins: webhookAllowedOrigins, timeoutMs: webhookTimeoutMs },
); );
if (signal.aborted) { if (signal.aborted) {