diff --git a/app/server/core/__tests__/config.test.ts b/app/server/core/__tests__/config.test.ts index 43a56c8b..1373bbf6 100644 --- a/app/server/core/__tests__/config.test.ts +++ b/app/server/core/__tests__/config.test.ts @@ -45,6 +45,7 @@ describe("parseConfig", () => { ENABLE_DEV_PANEL: "true", SERVER_IP: "0.0.0.0", SERVER_IDLE_TIMEOUT: "120", + WEBHOOK_TIMEOUT: "90", PORT: "8080", APP_VERSION: "1.2.3", MIGRATIONS_PATH: "/tmp/migrations", @@ -57,6 +58,7 @@ describe("parseConfig", () => { environment: "development", serverIp: "0.0.0.0", serverIdleTimeout: 120, + webhookTimeout: 90, resticHostname: "configured-restic-host", port: 8080, migrationsPath: "/tmp/migrations", diff --git a/app/server/core/config.ts b/app/server/core/config.ts index c51f3777..526e5fa1 100644 --- a/app/server/core/config.ts +++ b/app/server/core/config.ts @@ -32,6 +32,7 @@ const envSchema = z NODE_ENV: z.enum(["development", "production", "test"]).default("production"), SERVER_IP: z.string().default("localhost"), SERVER_IDLE_TIMEOUT: z.coerce.number().int().default(60), + WEBHOOK_TIMEOUT: z.coerce.number().int().default(60), RESTIC_HOSTNAME: z.string().optional(), PORT: z.coerce.number().int().default(4096), MIGRATIONS_PATH: z.string().optional(), @@ -122,6 +123,7 @@ const envSchema = z environment: s.NODE_ENV, serverIp: s.SERVER_IP, serverIdleTimeout: s.SERVER_IDLE_TIMEOUT, + webhookTimeout: s.WEBHOOK_TIMEOUT, resticHostname: s.RESTIC_HOSTNAME || getResticHostname(), port: s.PORT, migrationsPath: s.MIGRATIONS_PATH, diff --git a/app/server/modules/agents/__tests__/session.test.ts b/app/server/modules/agents/__tests__/session.test.ts index de457664..b5c30ffd 100644 --- a/app/server/modules/agents/__tests__/session.test.ts +++ b/app/server/modules/agents/__tests__/session.test.ts @@ -146,6 +146,7 @@ test("close emits a synthetic backup.cancelled for a queued backup", () => { }, webhooks: { pre: null, post: null }, webhookAllowedOrigins: [], + webhookTimeoutMs: 60_000, }), ); diff --git a/app/server/modules/backups/backup-executor.ts b/app/server/modules/backups/backup-executor.ts index 8fb56432..7fce27ea 100644 --- a/app/server/modules/backups/backup-executor.ts +++ b/app/server/modules/backups/backup-executor.ts @@ -64,6 +64,7 @@ const createBackupRunPayload = async ({ }, webhooks: schedule.backupWebhooks ?? { pre: null, post: null }, webhookAllowedOrigins: config.webhookAllowedOrigins, + webhookTimeoutMs: config.webhookTimeout * 1000, }; }; @@ -82,6 +83,7 @@ const executeBackupWithoutAgent = async ( options: payload.options, webhooks: payload.webhooks, webhookAllowedOrigins: payload.webhookAllowedOrigins, + webhookTimeoutMs: payload.webhookTimeoutMs, signal, onProgress, formatError: toErrorDetails, diff --git a/apps/agent/src/__tests__/controller-session.test.ts b/apps/agent/src/__tests__/controller-session.test.ts index 04edd1ed..c6dea013 100644 --- a/apps/agent/src/__tests__/controller-session.test.ts +++ b/apps/agent/src/__tests__/controller-session.test.ts @@ -48,6 +48,7 @@ test("emits backup.failed when a backup command hits a restic error", async () = }, webhooks: { pre: null, post: null }, webhookAllowedOrigins: [], + webhookTimeoutMs: 60_000, }), ); diff --git a/apps/agent/src/commands/backup-run.test.ts b/apps/agent/src/commands/backup-run.test.ts index 6a2ef1f7..faa88755 100644 --- a/apps/agent/src/commands/backup-run.test.ts +++ b/apps/agent/src/commands/backup-run.test.ts @@ -54,6 +54,7 @@ const createRunPayload = (overrides: Partial = {}) => }, webhooks: { pre: null, post: null }, webhookAllowedOrigins: ["http://localhost:8080"], + webhookTimeoutMs: 60_000, ...overrides, }); @@ -337,6 +338,7 @@ test("waits for running-job registration before returning to the processor loop" }, webhooks: { pre: null, post: null }, webhookAllowedOrigins: [], + webhookTimeoutMs: 60_000, }); const cancelPayload = fromPartial({ jobId: "job-1", diff --git a/apps/agent/src/commands/backup-run.ts b/apps/agent/src/commands/backup-run.ts index 65796a7f..1112fe2b 100644 --- a/apps/agent/src/commands/backup-run.ts +++ b/apps/agent/src/commands/backup-run.ts @@ -67,6 +67,7 @@ export const handleBackupRunCommand = (context: ControllerCommandContext, payloa options: payload.options, webhooks: payload.webhooks, webhookAllowedOrigins: payload.webhookAllowedOrigins, + webhookTimeoutMs: payload.webhookTimeoutMs, signal: abortController.signal, onProgress: (progress) => { void Runtime.runPromise( diff --git a/packages/contracts/src/agent-protocol.ts b/packages/contracts/src/agent-protocol.ts index 92a601f8..2cbd1c75 100644 --- a/packages/contracts/src/agent-protocol.ts +++ b/packages/contracts/src/agent-protocol.ts @@ -42,6 +42,7 @@ const backupRunSchema = z.object({ runtime: backupRuntimeSchema, webhooks: backupWebhooksSchema, webhookAllowedOrigins: z.array(z.string()), + webhookTimeoutMs: z.number(), }), }); diff --git a/packages/core/src/backup-hooks/__tests__/hooks.test.ts b/packages/core/src/backup-hooks/__tests__/hooks.test.ts index bbf65209..6130f6f7 100644 --- a/packages/core/src/backup-hooks/__tests__/hooks.test.ts +++ b/packages/core/src/backup-hooks/__tests__/hooks.test.ts @@ -45,6 +45,7 @@ const runWithHooks = ( options: {}, webhooks: { pre: null, post: null }, webhookAllowedOrigins: ["http://localhost:8080"], + webhookTimeoutMs: 60_000, signal: defaultSignal(), ...options, }), diff --git a/packages/core/src/backup-hooks/index.ts b/packages/core/src/backup-hooks/index.ts index 4779e340..7e46b6e2 100644 --- a/packages/core/src/backup-hooks/index.ts +++ b/packages/core/src/backup-hooks/index.ts @@ -3,7 +3,6 @@ import { z } from "zod"; import type { CompressionMode, RepositoryConfig, ResticBackupProgressDto } from "../restic/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_HEADERS = 32; const MAX_BACKUP_WEBHOOK_HEADER_BYTES = 8 * 1024; @@ -82,6 +81,7 @@ type BackupLifecycleOptions = { options: BackupOptions; webhooks: BackupWebhooks; webhookAllowedOrigins: readonly string[]; + webhookTimeoutMs: number; signal: AbortSignal; onProgress?: (progress: ResticBackupProgressDto) => void; formatError?: (error: unknown) => string; @@ -190,7 +190,7 @@ const runBackupWebhook = ( formatError: (error: unknown) => string; allowedOrigins: readonly string[]; signal?: AbortSignal; - timeoutMs?: number; + timeoutMs: number; }, ) => Effect.suspend(() => { @@ -198,8 +198,7 @@ const runBackupWebhook = ( return Effect.succeed(null); } - const timeoutMs = options.timeoutMs ?? DEFAULT_BACKUP_WEBHOOK_TIMEOUT_MS; - const controller = createAbortController(timeoutMs, options.signal); + const controller = createAbortController(options.timeoutMs, options.signal); return Effect.tryPromise({ try: async () => { @@ -256,6 +255,7 @@ export const runBackupLifecycle = ({ options, webhooks, webhookAllowedOrigins, + webhookTimeoutMs, signal, onProgress, formatError = toErrorDetails, @@ -268,6 +268,7 @@ export const runBackupLifecycle = ({ { formatError, allowedOrigins: webhookAllowedOrigins, + timeoutMs: webhookTimeoutMs, signal, }, ); @@ -312,7 +313,7 @@ export const runBackupLifecycle = ({ status: backupResult.hookStatus, error: backupResult.hookError, }, - { formatError, allowedOrigins: webhookAllowedOrigins }, + { formatError, allowedOrigins: webhookAllowedOrigins, timeoutMs: webhookTimeoutMs }, ); if (signal.aborted) {