From a30adedc9b10d3cece669da551d41e820f69df28 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 3 Aug 2026 04:52:42 -0700 Subject: [PATCH] fix(cli): preserve gateway request errors in health JSON (#118645) Co-authored-by: Peter Steinberger --- src/commands/health.test.ts | 65 +++++++++++++++++++++++++++++++++++-- src/commands/health.ts | 6 +++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index 7ef9da29747e..b14e988c53bb 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -1,5 +1,6 @@ // Health command tests cover gateway health probes, JSON output, and status formatting. import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GatewayClientRequestError } from "../../packages/gateway-client/src/index.js"; import { stripAnsi } from "../../packages/terminal-core/src/ansi.js"; import { buildCredentialsRequiredHealthDiagnostic, @@ -80,6 +81,7 @@ const buildGatewayProbeConnectionDetailsMock = vi.fn(() => ({ url: TEST_GATEWAY_URL, })); const formatGatewayAuthErrorJsonMock = vi.fn(); +const formatGatewayClientRequestErrorJsonMock = vi.fn(); const formatGatewayTransportErrorJsonMock = vi.fn(); const probeGatewayStatusMock = vi.fn(); vi.mock("../gateway/call.js", () => ({ @@ -89,6 +91,8 @@ vi.mock("../gateway/call.js", () => ({ buildGatewayProbeConnectionDetails: (...args: [unknown, ...unknown[]]) => Reflect.apply(buildGatewayProbeConnectionDetailsMock, undefined, args), formatGatewayAuthErrorJson: (...args: unknown[]) => formatGatewayAuthErrorJsonMock(...args), + formatGatewayClientRequestErrorJson: (...args: unknown[]) => + formatGatewayClientRequestErrorJsonMock(...args), formatGatewayTransportErrorJson: (...args: unknown[]) => formatGatewayTransportErrorJsonMock(...args), isGatewayCredentialsRequiredError: (value: unknown) => @@ -145,9 +149,14 @@ describe("healthCommand", () => { tlsFingerprint: TEST_TLS_FINGERPRINT, url: TEST_GATEWAY_URL, }); - formatGatewayAuthErrorJsonMock.mockReset(); - formatGatewayAuthErrorJsonMock.mockReturnValue(null); - formatGatewayTransportErrorJsonMock.mockReturnValue(null); + for (const formatterMock of [ + formatGatewayAuthErrorJsonMock, + formatGatewayClientRequestErrorJsonMock, + formatGatewayTransportErrorJsonMock, + ]) { + formatterMock.mockReset(); + formatterMock.mockReturnValue(null); + } isGatewayCredentialsRequiredErrorMock.mockReturnValue(false); isGatewaySecretRefUnavailableErrorMock.mockReturnValue(false); probeGatewayStatusMock.mockReset(); @@ -409,6 +418,56 @@ describe("healthCommand", () => { expect(JSON.parse(requireFirstRuntimeLog())).toEqual(payload); }); + it("keeps Gateway health request failures machine-readable in JSON mode", async () => { + const error = new GatewayClientRequestError({ + code: "UNAVAILABLE", + message: "health snapshot unavailable", + details: { operation: "refresh" }, + retryable: true, + retryAfterMs: 250, + }); + const payload = { + ok: false, + error: { + type: "gateway_request_error", + code: "UNAVAILABLE", + message: "health snapshot unavailable", + details: { operation: "refresh" }, + retryable: true, + retryAfterMs: 250, + }, + }; + callGatewayMock.mockRejectedValueOnce(error); + formatGatewayClientRequestErrorJsonMock.mockReturnValueOnce(payload); + + await healthCommand({ json: true, timeoutMs: 5000, config: {} }, runtime as never); + + expect(formatGatewayAuthErrorJsonMock).toHaveBeenCalledWith(error); + expect(formatGatewayClientRequestErrorJsonMock).toHaveBeenCalledWith(error); + expect(formatGatewayTransportErrorJsonMock).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledTimes(1); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(JSON.parse(requireFirstRuntimeLog())).toEqual(payload); + }); + + it("preserves Gateway health request failures in human-readable mode", async () => { + const error = new GatewayClientRequestError({ + code: "UNAVAILABLE", + message: "health snapshot unavailable", + retryable: true, + }); + callGatewayMock.mockRejectedValueOnce(error); + + await expect( + healthCommand({ json: false, timeoutMs: 5000, config: {} }, runtime as never), + ).rejects.toBe(error); + + expect(formatGatewayAuthErrorJsonMock).not.toHaveBeenCalled(); + expect(formatGatewayClientRequestErrorJsonMock).not.toHaveBeenCalled(); + expect(formatGatewayTransportErrorJsonMock).not.toHaveBeenCalled(); + }); + it.each([ { json: true, expectedLogs: 1 }, { json: undefined, expectedLogs: 2 }, diff --git a/src/commands/health.ts b/src/commands/health.ts index 59b08df4e5d4..146aa35341e7 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -13,6 +13,7 @@ import { buildGatewayProbeConnectionDetails, callGateway, formatGatewayAuthErrorJson, + formatGatewayClientRequestErrorJson, formatGatewayTransportErrorJson, isGatewayCredentialsRequiredError, } from "../gateway/call.js"; @@ -249,7 +250,10 @@ export async function healthCommand( return; } if (opts.json) { - const payload = formatGatewayAuthErrorJson(error) ?? formatGatewayTransportErrorJson(error); + const payload = + formatGatewayAuthErrorJson(error) ?? + formatGatewayClientRequestErrorJson(error) ?? + formatGatewayTransportErrorJson(error); if (payload) { writeRuntimeJson(runtime, payload); runtime.exit(1);