From 04be516926da8c44caa402df09d998c72fcb9a7d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 07:42:02 +0100 Subject: [PATCH] fix(gateway): keep liveness probes independent of config load --- src/gateway/server-http.probe.test.ts | 21 +++++++++ src/gateway/server-http.ts | 44 +++++++++++++------ src/gateway/server-startup-log.test.ts | 10 +++-- src/gateway/server-startup-log.ts | 4 +- .../server-startup-post-attach.test.ts | 3 ++ src/gateway/server-startup-post-attach.ts | 1 + 6 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/gateway/server-http.probe.test.ts b/src/gateway/server-http.probe.test.ts index 5a42f1fa54a..8d6bd165c99 100644 --- a/src/gateway/server-http.probe.test.ts +++ b/src/gateway/server-http.probe.test.ts @@ -265,6 +265,27 @@ describe("gateway probe endpoints", () => { }); }); + it("serves /healthz before loading gateway config", async () => { + const loadConfig = vi.fn(() => { + throw new Error("config load blocked"); + }); + + await withGatewayServer({ + prefix: "probe-healthz-before-config", + resolvedAuth: AUTH_NONE, + overrides: { loadConfig }, + run: async (server) => { + const req = createRequest({ path: "/healthz" }); + const { res, getBody } = createResponse(); + await dispatchRequest(server, req, res); + + expect(res.statusCode).toBe(200); + expect(getBody()).toBe(JSON.stringify({ ok: true, status: "live" })); + expect(loadConfig).not.toHaveBeenCalled(); + }, + }); + }); + it("serves probes before stalled request stages", async () => { const handleHooksRequest = vi.fn((): Promise => new Promise(() => {})); const getReadiness = vi.fn(() => ({ diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 0eecd50bf77..b7f61637eb0 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -900,6 +900,7 @@ export function createGatewayHttpServer(opts: { /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; getReadiness?: ReadinessChecker; + loadConfig?: () => OpenClawConfig; tlsOptions?: TlsOptions; }): HttpServer { const { @@ -921,6 +922,7 @@ export function createGatewayHttpServer(opts: { getReadiness, } = opts; const getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth); + const loadGatewayConfig = opts.loadConfig ?? loadConfig; const openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled; const httpServer: HttpServer = opts.tlsOptions ? createHttpsServer(opts.tlsOptions, (req, res) => { @@ -947,7 +949,21 @@ export function createGatewayHttpServer(opts: { } try { - const configSnapshot = loadConfig(); + const requestPath = new URL(req.url ?? "/", "http://localhost").pathname; + if (GATEWAY_PROBE_STATUS_BY_PATH.get(requestPath) === "live") { + await handleGatewayProbeRequest( + req, + res, + requestPath, + getResolvedAuth(), + [], + false, + getReadiness, + ); + return; + } + + const configSnapshot = loadGatewayConfig(); const trustedProxies = configSnapshot.gateway?.trustedProxies ?? []; const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true; const scopedCanvas = normalizeCanvasScopedUrl(req.url ?? "/"); @@ -958,9 +974,9 @@ export function createGatewayHttpServer(opts: { if (scopedCanvas.rewrittenUrl) { req.url = scopedCanvas.rewrittenUrl; } - const requestPath = new URL(req.url ?? "/", "http://localhost").pathname; + const scopedRequestPath = new URL(req.url ?? "/", "http://localhost").pathname; const pluginPathContext = handlePluginRequest - ? resolvePluginRoutePathContext(requestPath) + ? resolvePluginRoutePathContext(scopedRequestPath) : null; const resolvedAuth = getResolvedAuth(); const requestStages: GatewayHttpRequestStage[] = [ @@ -970,7 +986,7 @@ export function createGatewayHttpServer(opts: { handleGatewayProbeRequest( req, res, - requestPath, + scopedRequestPath, resolvedAuth, trustedProxies, allowRealIpFallback, @@ -982,7 +998,7 @@ export function createGatewayHttpServer(opts: { run: () => handleHooksRequest(req, res), }, ]; - if (openAiCompatEnabled && isOpenAiModelsPath(requestPath)) { + if (openAiCompatEnabled && isOpenAiModelsPath(scopedRequestPath)) { requestStages.push({ name: "models", run: async () => @@ -994,7 +1010,7 @@ export function createGatewayHttpServer(opts: { }), }); } - if (openAiCompatEnabled && isEmbeddingsPath(requestPath)) { + if (openAiCompatEnabled && isEmbeddingsPath(scopedRequestPath)) { requestStages.push({ name: "embeddings", run: async () => @@ -1006,7 +1022,7 @@ export function createGatewayHttpServer(opts: { }), }); } - if (isToolsInvokePath(requestPath)) { + if (isToolsInvokePath(scopedRequestPath)) { requestStages.push({ name: "tools-invoke", run: async () => @@ -1018,7 +1034,7 @@ export function createGatewayHttpServer(opts: { }), }); } - if (isSessionKillPath(requestPath)) { + if (isSessionKillPath(scopedRequestPath)) { requestStages.push({ name: "sessions-kill", run: async () => @@ -1030,7 +1046,7 @@ export function createGatewayHttpServer(opts: { }), }); } - if (isSessionHistoryPath(requestPath)) { + if (isSessionHistoryPath(scopedRequestPath)) { requestStages.push({ name: "sessions-history", run: async () => @@ -1043,7 +1059,7 @@ export function createGatewayHttpServer(opts: { }), }); } - if (openResponsesEnabled && isOpenResponsesPath(requestPath)) { + if (openResponsesEnabled && isOpenResponsesPath(scopedRequestPath)) { requestStages.push({ name: "openresponses", run: async () => @@ -1056,7 +1072,7 @@ export function createGatewayHttpServer(opts: { }), }); } - if (openAiChatCompletionsEnabled && isOpenAiChatCompletionsPath(requestPath)) { + if (openAiChatCompletionsEnabled && isOpenAiChatCompletionsPath(scopedRequestPath)) { requestStages.push({ name: "openai", run: async () => @@ -1073,7 +1089,7 @@ export function createGatewayHttpServer(opts: { requestStages.push({ name: "canvas-auth", run: async () => { - if (!isCanvasPath(requestPath)) { + if (!isCanvasPath(scopedRequestPath)) { return false; } const ok = await authorizeCanvasRequest({ @@ -1095,7 +1111,7 @@ export function createGatewayHttpServer(opts: { }); requestStages.push({ name: "a2ui", - run: () => (isA2uiPath(requestPath) ? handleA2uiHttpRequest(req, res) : false), + run: () => (isA2uiPath(scopedRequestPath) ? handleA2uiHttpRequest(req, res) : false), }); requestStages.push({ name: "canvas-http", @@ -1109,7 +1125,7 @@ export function createGatewayHttpServer(opts: { ...buildPluginRequestStages({ req, res, - requestPath, + requestPath: scopedRequestPath, getGatewayAuthBypassPaths: () => getCachedPluginGatewayAuthBypassPaths(configSnapshot), pluginPathContext, handlePluginRequest, diff --git a/src/gateway/server-startup-log.test.ts b/src/gateway/server-startup-log.test.ts index ea67a29880f..4a89820016d 100644 --- a/src/gateway/server-startup-log.test.ts +++ b/src/gateway/server-startup-log.test.ts @@ -49,7 +49,7 @@ describe("gateway startup log", () => { expect(warn).not.toHaveBeenCalled(); }); - it("logs a compact ready line with loaded plugin ids and duration", () => { + it("logs a compact listening line with loaded plugin ids and duration", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-03T10:00:16.000Z")); @@ -67,9 +67,11 @@ describe("gateway startup log", () => { isNixMode: false, }); - const readyMessages = info.mock.calls + const listeningMessages = info.mock.calls .map((call) => call[0]) - .filter((message) => message.startsWith("ready (")); - expect(readyMessages).toEqual(["ready (3 plugins: alpha, beta, delta; 16.0s)"]); + .filter((message) => message.startsWith("http server listening (")); + expect(listeningMessages).toEqual([ + "http server listening (3 plugins: alpha, beta, delta; 16.0s)", + ]); }); }); diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index 03aa033afd8..b510e498e6e 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -29,7 +29,9 @@ export function logGatewayStartup(params: { typeof params.startupStartedAt === "number" ? Date.now() - params.startupStartedAt : null; const startupDurationLabel = startupDurationMs == null ? null : `${(startupDurationMs / 1000).toFixed(1)}s`; - params.log.info(`ready (${formatReadyDetails(params.loadedPluginIds, startupDurationLabel)})`); + params.log.info( + `http server listening (${formatReadyDetails(params.loadedPluginIds, startupDurationLabel)})`, + ); params.log.info(`log file: ${getResolvedLoggerSettings().file}`); if (params.isNixMode) { params.log.info("gateway: running in Nix mode (config managed externally)"); diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 73deb453973..268a2ccf685 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -157,9 +157,11 @@ describe("startGatewayPostAttachRuntime", () => { it("re-enables startup-gated methods after post-attach sidecars start", async () => { const unavailableGatewayMethods = new Set(["chat.history", "models.list"]); const onSidecarsReady = vi.fn(); + const log = { info: vi.fn(), warn: vi.fn() }; await startGatewayPostAttachRuntime({ ...createPostAttachParams(), + log, unavailableGatewayMethods, onSidecarsReady, }); @@ -174,6 +176,7 @@ describe("startGatewayPostAttachRuntime", () => { expect(hoisted.logGatewayStartup).toHaveBeenCalledWith( expect.objectContaining({ loadedPluginIds: ["beta", "alpha"] }), ); + expect(log.info).toHaveBeenCalledWith("gateway ready"); expect(hoisted.startGatewayMemoryBackend).not.toHaveBeenCalled(); }); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index d20c464ac94..739237e9de6 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -538,6 +538,7 @@ export async function startGatewayPostAttachRuntime( params.onPluginServices?.(result.pluginServices); params.onSidecarsReady?.(); params.startupTrace?.mark("sidecars.ready"); + params.log.info("gateway ready"); return result; });