fix(gateway): keep liveness probes independent of config load

This commit is contained in:
Peter Steinberger
2026-04-27 07:42:02 +01:00
parent 7559845597
commit 04be516926
6 changed files with 64 additions and 19 deletions

View File

@@ -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<boolean> => new Promise(() => {}));
const getReadiness = vi.fn(() => ({

View File

@@ -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,

View File

@@ -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)",
]);
});
});

View File

@@ -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)");

View File

@@ -157,9 +157,11 @@ describe("startGatewayPostAttachRuntime", () => {
it("re-enables startup-gated methods after post-attach sidecars start", async () => {
const unavailableGatewayMethods = new Set<string>(["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();
});

View File

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