From 01eec285d98667762d2ede9362da910f9d8aa7ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 17 Aug 2026 21:18:02 -0700 Subject: [PATCH] fix(cli): render gateway transport failures as expected conditions (#125556) * fix(cli): render gateway transport failures as expected conditions An unreachable gateway is an expected, recoverable operator condition, but only the devices command family surfaced it through the root failure handler, where it rendered as "The CLI command failed" with debug hints. Extract GatewayTransportError into its own module so the failure renderer can classify it without importing the transport stack, and treat it as an expected CLI error alongside missing credentials. * fix(gateway): import the transport error kind used by the JSON shape * test(cli): extract the process-test gateway harness The new devices coverage pushed gateway-backed-exit.process.test.ts past the max-lines budget, and that file is not in the ratchet baseline. Move the mock Gateway servers into a sibling test-helpers module instead of suppressing the rule. --- config/assertion-safety-baseline.txt | 2 +- src/cli/failure-output.test.ts | 16 +- src/cli/failure-output.ts | 7 +- src/cli/gateway-backed-exit.process.test.ts | 332 +++++++------------- src/cli/gateway-backed-exit.test-helpers.ts | 231 ++++++++++++++ src/gateway/call.ts | 58 +--- src/gateway/transport-error.ts | 50 +++ 7 files changed, 422 insertions(+), 274 deletions(-) create mode 100644 src/cli/gateway-backed-exit.test-helpers.ts create mode 100644 src/gateway/transport-error.ts diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index cb274dd26999..b377fe0c0bae 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -2901,7 +2901,7 @@ src/gateway/agent-turn/internal-facade.ts 4 src/gateway/board-host-tools.ts 1 src/gateway/board-view-ticket.ts 1 src/gateway/boot.ts 1 -src/gateway/call.ts 7 +src/gateway/call.ts 6 src/gateway/channel-health-monitor.ts 6 src/gateway/channel-thaw-restart.ts 1 src/gateway/chat-attachments.ts 4 diff --git a/src/cli/failure-output.test.ts b/src/cli/failure-output.test.ts index c2f9120b4df9..7687cc32033e 100644 --- a/src/cli/failure-output.test.ts +++ b/src/cli/failure-output.test.ts @@ -1,6 +1,6 @@ // Failure output tests cover CLI error formatting and failure summaries. import { describe, expect, it } from "vitest"; -import { GatewayCredentialsRequiredError } from "../gateway/call.js"; +import { GatewayCredentialsRequiredError, GatewayTransportError } from "../gateway/call.js"; import { ExpectedCliError, formatCliFailureLines, @@ -157,6 +157,20 @@ describe("formatCliFailureLines", () => { configPath: "/tmp/openclaw.json", }), }, + { + label: "unreachable gateway", + createError: () => + new GatewayTransportError({ + kind: "closed", + message: + "Gateway not reachable at ws://127.0.0.1:51078 (ECONNREFUSED).\nStart it with `openclaw gateway run` or check `openclaw gateway status`.", + connectionDetails: { + url: "ws://127.0.0.1:51078", + urlSource: "local loopback", + message: "Gateway target: ws://127.0.0.1:51078", + }, + }), + }, ])( "routes $label through the shared expected-condition predicate without crash framing", ({ createError }) => { diff --git a/src/cli/failure-output.ts b/src/cli/failure-output.ts index 97aefa57a2be..abc5c02b4f12 100644 --- a/src/cli/failure-output.ts +++ b/src/cli/failure-output.ts @@ -1,4 +1,5 @@ // Shared root CLI failure formatting with debug stack gating and recovery hints. +import { isGatewayTransportError } from "../gateway/transport-error.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { formatErrorMessage, formatUncaughtError } from "../infra/errors.js"; import { formatCliCommand } from "./command-format.js"; @@ -58,7 +59,11 @@ function isGatewayCredentialsCliError( } export function isExpectedCliError(error: unknown): error is Error { - return error instanceof ExpectedCliError || isGatewayCredentialsCliError(error); + return ( + error instanceof ExpectedCliError || + isGatewayCredentialsCliError(error) || + isGatewayTransportError(error) + ); } export function rethrowExpectedCliError(error: unknown): void { diff --git a/src/cli/gateway-backed-exit.process.test.ts b/src/cli/gateway-backed-exit.process.test.ts index e791b36c841b..8492b5217314 100644 --- a/src/cli/gateway-backed-exit.process.test.ts +++ b/src/cli/gateway-backed-exit.process.test.ts @@ -3,48 +3,33 @@ import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child import { createHash } from "node:crypto"; import { once } from "node:events"; import fs from "node:fs/promises"; -import type { AddressInfo } from "node:net"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; -import { isLoopbackIpAddress, isPrivateOrLoopbackIpAddress } from "@openclaw/net-policy/ip"; import { afterEach, describe, expect, it } from "vitest"; -import { WebSocketServer } from "ws"; import { gatewayOriginScope } from "../../packages/gateway-client/src/gateway-origin-scope.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; -import { - buildMinimalGatewayHelloOkPayload, - closeMinimalGatewayServer, - parseMinimalGatewayRequestFrame, - sendMinimalGatewayConnectChallenge, - sendMinimalGatewayResponse, -} from "../gateway/minimal-gateway.test-helpers.js"; import { loadOriginDeviceTokenReadOnly, storeOriginDeviceToken, } from "../infra/device-auth-store.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { acquireGatewayLock } from "../infra/gateway-lock.js"; -import { - pickMatchingExternalInterfaceAddress, - readNetworkInterfaces, -} from "../infra/network-interfaces.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { getFreePort } from "../test-utils/ports.js"; +import { + closeActiveGatewayServers, + EMPTY_STABILITY_SNAPSHOT, + startCronListGateway, + startGatewayStabilityRpcServer, + startNodePairingGateway, + startRateLimitedGateway, +} from "./gateway-backed-exit.test-helpers.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); const execFileAsync = promisify(execFile); const activeChildren = new Set(); -const activeServers = new Set(); const UNREACHABLE_GATEWAY_URL = "ws://127.0.0.1:9"; -const EMPTY_STABILITY_SNAPSHOT = { - capacity: 100, - count: 0, - dropped: 0, - events: [], - summary: { byType: {} }, -}; - afterEach(async () => { await Promise.all( Array.from(activeChildren, async (child) => { @@ -56,208 +41,9 @@ afterEach(async () => { }), ); activeChildren.clear(); - await Promise.all(Array.from(activeServers, closeMinimalGatewayServer)); - activeServers.clear(); + await closeActiveGatewayServers(); }); -async function startCronListGateway(token: string): Promise<{ url: string }> { - const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - activeServers.add(wss); - wss.on("connection", (ws) => { - sendMinimalGatewayConnectChallenge(ws); - ws.on("message", (data) => { - const frame = parseMinimalGatewayRequestFrame(data); - if (frame.type !== "req" || !frame.id) { - return; - } - if (frame.method === "connect") { - expect(frame.params?.auth?.token).toBe(token); - sendMinimalGatewayResponse( - ws, - frame.id, - buildMinimalGatewayHelloOkPayload({ - methods: ["cron.list"], - auth: { role: "operator", scopes: ["operator.admin"] }, - }), - ); - return; - } - if (frame.method === "cron.list") { - sendMinimalGatewayResponse(ws, frame.id, { - jobs: [], - snapshotRevision: "test-revision", - total: 0, - offset: 0, - limit: 50, - hasMore: false, - nextOffset: null, - deliveryPreviews: {}, - }); - } - }); - }); - await once(wss, "listening"); - const address = wss.address() as AddressInfo; - return { url: `ws://127.0.0.1:${address.port}` }; -} - -async function startRateLimitedGateway(): Promise<{ url: string }> { - const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - activeServers.add(wss); - wss.on("connection", (ws) => { - sendMinimalGatewayConnectChallenge(ws); - ws.on("message", (data) => { - const frame = parseMinimalGatewayRequestFrame(data); - if (frame.type !== "req" || !frame.id || frame.method !== "connect") { - return; - } - const message = "unauthorized: too many failed authentication attempts (retry later)"; - ws.send( - JSON.stringify({ - type: "res", - id: frame.id, - ok: false, - error: { - code: "INVALID_REQUEST", - message, - retryable: true, - retryAfterMs: 60_000, - details: { - code: "AUTH_RATE_LIMITED", - authReason: "rate_limited", - recommendedNextStep: "wait_then_retry", - }, - }, - }), - () => ws.close(1008, message), - ); - }); - }); - await once(wss, "listening"); - const address = wss.address() as AddressInfo; - return { url: `ws://127.0.0.1:${address.port}` }; -} - -async function startNodePairingGateway( - token: string, - issuedDeviceToken?: string, -): Promise<{ - calls: string[]; - url: string; -}> { - const calls: string[] = []; - const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - activeServers.add(wss); - wss.on("connection", (ws) => { - sendMinimalGatewayConnectChallenge(ws); - ws.on("message", (data) => { - const frame = parseMinimalGatewayRequestFrame(data); - if (frame.type !== "req" || !frame.id) { - return; - } - if (frame.method === "connect") { - expect(frame.params?.auth?.token).toBe(token); - sendMinimalGatewayResponse( - ws, - frame.id, - buildMinimalGatewayHelloOkPayload({ - methods: ["node.pair.list", "node.pair.approve"], - auth: { - role: "operator", - scopes: ["operator.admin"], - ...(issuedDeviceToken ? { deviceToken: issuedDeviceToken } : {}), - }, - }), - ); - return; - } - if (typeof frame.method !== "string") { - return; - } - calls.push(frame.method); - if (frame.method === "node.pair.list") { - sendMinimalGatewayResponse(ws, frame.id, { - pending: [{ requestId: "request-1", nodeId: "node-1", commands: [] }], - paired: [], - }); - return; - } - if (frame.method === "node.pair.approve") { - sendMinimalGatewayResponse(ws, frame.id, { approved: true }); - } - }); - }); - await once(wss, "listening"); - const address = wss.address() as AddressInfo; - return { calls, url: `ws://127.0.0.1:${address.port}` }; -} - -async function startGatewayStabilityRpcServer( - token: string, - issuedDeviceToken: string, -): Promise<{ - authTokens: Array; - calls: string[]; - url: string; -}> { - const authTokens: Array = []; - const calls: string[] = []; - const wss = new WebSocketServer({ host: "0.0.0.0", port: 0 }); - activeServers.add(wss); - wss.on("connection", (ws) => { - sendMinimalGatewayConnectChallenge(ws); - ws.on("message", (data) => { - const frame = parseMinimalGatewayRequestFrame(data); - if (frame.type !== "req" || !frame.id) { - return; - } - if (frame.method === "connect") { - expect(frame.params?.auth?.token).toBe(token); - authTokens.push(frame.params?.auth?.token); - sendMinimalGatewayResponse( - ws, - frame.id, - buildMinimalGatewayHelloOkPayload({ - methods: ["diagnostics.stability", "status"], - auth: { - role: "operator", - scopes: ["operator.admin"], - deviceToken: issuedDeviceToken, - }, - }), - ); - return; - } - if (typeof frame.method !== "string") { - return; - } - calls.push(frame.method); - if (frame.method === "diagnostics.stability") { - sendMinimalGatewayResponse(ws, frame.id, EMPTY_STABILITY_SNAPSHOT); - return; - } - if (frame.method === "status") { - sendMinimalGatewayResponse(ws, frame.id, { - runtimeVersion: "2026.8.17-test", - status: "ok", - }); - } - }); - }); - await once(wss, "listening"); - const address = wss.address() as AddressInfo; - // A private non-loopback target keeps shared-secret auth from bypassing device identity. - const host = pickMatchingExternalInterfaceAddress(readNetworkInterfaces(), { - family: "IPv4", - matches: (candidate) => - isPrivateOrLoopbackIpAddress(candidate) && !isLoopbackIpAddress(candidate), - }); - if (!host) { - throw new Error("test host has no non-loopback private IPv4 address"); - } - return { authTokens, calls, url: `ws://${host}:${address.port}` }; -} - async function snapshotDirectoryContents(root: string): Promise> { const snapshot: Record = {}; const visit = async (directory: string): Promise => { @@ -693,6 +479,106 @@ describe("gateway-backed CLI process exit", () => { 30_000, ); + it.each([ + { label: "list", args: ["devices", "list", "--timeout", "250"] }, + { label: "join-code", args: ["devices", "join-code", "--timeout", "250"] }, + { + label: "remove", + args: ["devices", "remove", "test-device", "--timeout", "250"], + }, + { + label: "clear", + args: ["devices", "clear", "--yes", "--pending", "--timeout", "250"], + }, + { + label: "approve", + args: ["devices", "approve", "test-request", "--timeout", "250"], + }, + { + label: "reject", + args: ["devices", "reject", "test-request", "--timeout", "250"], + }, + { + label: "rename", + args: [ + "devices", + "rename", + "--device", + "test-device", + "--name", + "Test Device", + "--timeout", + "250", + ], + }, + { + label: "rotate", + args: [ + "devices", + "rotate", + "--device", + "test-device", + "--role", + "operator", + "--timeout", + "250", + ], + machineOutput: true, + }, + { + label: "revoke", + args: [ + "devices", + "revoke", + "--device", + "test-device", + "--role", + "operator", + "--timeout", + "250", + ], + machineOutput: true, + }, + ])( + "renders an unreachable gateway as expected guidance for devices $label", + async ({ label, args, machineOutput }) => { + const root = tempDirs.make(`openclaw-devices-${label}-transport-`); + const stateDir = path.join(root, "state"); + const configPath = path.join(stateDir, "openclaw.json"); + const port = await getFreePort(); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ + gateway: { mode: "local", port, auth: { mode: "token", token: "test-token" } }, + })}\n`, + "utf8", + ); + + const result = await runIsolatedGatewayCli({ args, root, stateDir, configPath }); + + expect(result).toMatchObject({ code: 1, signal: null }); + if (machineOutput) { + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { type: "cli_error", message: expect.stringContaining("Gateway not reachable") }, + }); + } else { + expect(result.stdout).toBe(""); + } + expect(result.stderr).toContain(`Gateway not reachable at ws://127.0.0.1:${port}`); + expect(result.stderr).toContain( + "Start it with `openclaw gateway run` or check `openclaw gateway status`.", + ); + expect(result.stderr).not.toContain("The CLI command failed"); + expect(result.stderr).not.toContain("Could not start the CLI"); + expect(result.stderr).not.toContain("OPENCLAW_DEBUG"); + expect(result.stderr).not.toContain("Stack:"); + expect(result.stderr).not.toContain("openclaw doctor"); + }, + 30_000, + ); + it.each([ { label: "absent", seeded: false }, { label: "seeded", seeded: true }, diff --git a/src/cli/gateway-backed-exit.test-helpers.ts b/src/cli/gateway-backed-exit.test-helpers.ts new file mode 100644 index 000000000000..d8415de45227 --- /dev/null +++ b/src/cli/gateway-backed-exit.test-helpers.ts @@ -0,0 +1,231 @@ +// Shared process-test harness: mock Gateway servers used by CLI exit-code proofs. +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; +import { isLoopbackIpAddress, isPrivateOrLoopbackIpAddress } from "@openclaw/net-policy/ip"; +import { expect } from "vitest"; +import { WebSocketServer } from "ws"; +import { + buildMinimalGatewayHelloOkPayload, + closeMinimalGatewayServer, + parseMinimalGatewayRequestFrame, + sendMinimalGatewayConnectChallenge, + sendMinimalGatewayResponse, +} from "../gateway/minimal-gateway.test-helpers.js"; +import { + pickMatchingExternalInterfaceAddress, + readNetworkInterfaces, +} from "../infra/network-interfaces.js"; + +const activeServers = new Set(); + +export const EMPTY_STABILITY_SNAPSHOT = { + capacity: 100, + count: 0, + dropped: 0, + events: [], + summary: { byType: {} }, +}; + +export async function startCronListGateway(token: string): Promise<{ url: string }> { + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + activeServers.add(wss); + wss.on("connection", (ws) => { + sendMinimalGatewayConnectChallenge(ws); + ws.on("message", (data) => { + const frame = parseMinimalGatewayRequestFrame(data); + if (frame.type !== "req" || !frame.id) { + return; + } + if (frame.method === "connect") { + expect(frame.params?.auth?.token).toBe(token); + sendMinimalGatewayResponse( + ws, + frame.id, + buildMinimalGatewayHelloOkPayload({ + methods: ["cron.list"], + auth: { role: "operator", scopes: ["operator.admin"] }, + }), + ); + return; + } + if (frame.method === "cron.list") { + sendMinimalGatewayResponse(ws, frame.id, { + jobs: [], + snapshotRevision: "test-revision", + total: 0, + offset: 0, + limit: 50, + hasMore: false, + nextOffset: null, + deliveryPreviews: {}, + }); + } + }); + }); + await once(wss, "listening"); + const address = wss.address() as AddressInfo; + return { url: `ws://127.0.0.1:${address.port}` }; +} + +export async function startRateLimitedGateway(): Promise<{ url: string }> { + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + activeServers.add(wss); + wss.on("connection", (ws) => { + sendMinimalGatewayConnectChallenge(ws); + ws.on("message", (data) => { + const frame = parseMinimalGatewayRequestFrame(data); + if (frame.type !== "req" || !frame.id || frame.method !== "connect") { + return; + } + const message = "unauthorized: too many failed authentication attempts (retry later)"; + ws.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message, + retryable: true, + retryAfterMs: 60_000, + details: { + code: "AUTH_RATE_LIMITED", + authReason: "rate_limited", + recommendedNextStep: "wait_then_retry", + }, + }, + }), + () => ws.close(1008, message), + ); + }); + }); + await once(wss, "listening"); + const address = wss.address() as AddressInfo; + return { url: `ws://127.0.0.1:${address.port}` }; +} + +export async function startNodePairingGateway( + token: string, + issuedDeviceToken?: string, +): Promise<{ + calls: string[]; + url: string; +}> { + const calls: string[] = []; + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + activeServers.add(wss); + wss.on("connection", (ws) => { + sendMinimalGatewayConnectChallenge(ws); + ws.on("message", (data) => { + const frame = parseMinimalGatewayRequestFrame(data); + if (frame.type !== "req" || !frame.id) { + return; + } + if (frame.method === "connect") { + expect(frame.params?.auth?.token).toBe(token); + sendMinimalGatewayResponse( + ws, + frame.id, + buildMinimalGatewayHelloOkPayload({ + methods: ["node.pair.list", "node.pair.approve"], + auth: { + role: "operator", + scopes: ["operator.admin"], + ...(issuedDeviceToken ? { deviceToken: issuedDeviceToken } : {}), + }, + }), + ); + return; + } + if (typeof frame.method !== "string") { + return; + } + calls.push(frame.method); + if (frame.method === "node.pair.list") { + sendMinimalGatewayResponse(ws, frame.id, { + pending: [{ requestId: "request-1", nodeId: "node-1", commands: [] }], + paired: [], + }); + return; + } + if (frame.method === "node.pair.approve") { + sendMinimalGatewayResponse(ws, frame.id, { approved: true }); + } + }); + }); + await once(wss, "listening"); + const address = wss.address() as AddressInfo; + return { calls, url: `ws://127.0.0.1:${address.port}` }; +} + +export async function startGatewayStabilityRpcServer( + token: string, + issuedDeviceToken: string, +): Promise<{ + authTokens: Array; + calls: string[]; + url: string; +}> { + const authTokens: Array = []; + const calls: string[] = []; + const wss = new WebSocketServer({ host: "0.0.0.0", port: 0 }); + activeServers.add(wss); + wss.on("connection", (ws) => { + sendMinimalGatewayConnectChallenge(ws); + ws.on("message", (data) => { + const frame = parseMinimalGatewayRequestFrame(data); + if (frame.type !== "req" || !frame.id) { + return; + } + if (frame.method === "connect") { + expect(frame.params?.auth?.token).toBe(token); + authTokens.push(frame.params?.auth?.token); + sendMinimalGatewayResponse( + ws, + frame.id, + buildMinimalGatewayHelloOkPayload({ + methods: ["diagnostics.stability", "status"], + auth: { + role: "operator", + scopes: ["operator.admin"], + deviceToken: issuedDeviceToken, + }, + }), + ); + return; + } + if (typeof frame.method !== "string") { + return; + } + calls.push(frame.method); + if (frame.method === "diagnostics.stability") { + sendMinimalGatewayResponse(ws, frame.id, EMPTY_STABILITY_SNAPSHOT); + return; + } + if (frame.method === "status") { + sendMinimalGatewayResponse(ws, frame.id, { + runtimeVersion: "2026.8.17-test", + status: "ok", + }); + } + }); + }); + await once(wss, "listening"); + const address = wss.address() as AddressInfo; + // A private non-loopback target keeps shared-secret auth from bypassing device identity. + const host = pickMatchingExternalInterfaceAddress(readNetworkInterfaces(), { + family: "IPv4", + matches: (candidate) => + isPrivateOrLoopbackIpAddress(candidate) && !isLoopbackIpAddress(candidate), + }); + if (!host) { + throw new Error("test host has no non-loopback private IPv4 address"); + } + return { authTokens, calls, url: `ws://${host}:${address.port}` }; +} + +/** Closes every mock Gateway started by these helpers; call from the suite afterEach. */ +export async function closeActiveGatewayServers(): Promise { + await Promise.all(Array.from(activeServers, closeMinimalGatewayServer)); + activeServers.clear(); +} diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 8a2a5a71cb2d..f5f6d889823f 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -87,7 +87,17 @@ import { type OperatorScope, } from "./method-scopes.js"; import { resolveGatewayConnectionTlsFingerprint } from "./tls-fingerprint.js"; +import { + GatewayTransportError, + type GatewayTransportErrorKind, + isGatewayTransportError, +} from "./transport-error.js"; export type { GatewayConnectionDetails }; +export { + GatewayTransportError, + isGatewayTransportError, + type GatewayTransportErrorKind, +} from "./transport-error.js"; export type GatewayRequestFunction = >( method: string, @@ -147,39 +157,6 @@ export type CallGatewayOptions = CallGatewayBaseOptions & { scopes?: OperatorScope[]; }; -export type GatewayTransportErrorKind = "closed" | "timeout"; - -export class GatewayTransportError extends Error { - readonly kind: GatewayTransportErrorKind; - readonly connectionDetails: GatewayConnectionDetails; - readonly code?: number; - readonly reason?: string; - readonly timeoutMs?: number; - - constructor(params: { - kind: GatewayTransportErrorKind; - message: string; - connectionDetails: GatewayConnectionDetails; - code?: number; - reason?: string; - timeoutMs?: number; - }) { - super(params.message); - this.name = "GatewayTransportError"; - this.kind = params.kind; - this.connectionDetails = params.connectionDetails; - if (params.code !== undefined) { - this.code = params.code; - } - if (params.reason !== undefined) { - this.reason = params.reason; - } - if (params.timeoutMs !== undefined) { - this.timeoutMs = params.timeoutMs; - } - } -} - export class GatewayCredentialsRequiredError extends Error { readonly method: string; readonly configPath: string; @@ -375,21 +352,6 @@ export function formatGatewayAuthErrorJson(value: unknown): GatewayAuthErrorJson }; } -export function isGatewayTransportError(value: unknown): value is GatewayTransportError { - if (value instanceof GatewayTransportError) { - return true; - } - if (!(value instanceof Error) || value.name !== "GatewayTransportError") { - return false; - } - const candidate = value as Partial; - return ( - (candidate.kind === "closed" || candidate.kind === "timeout") && - typeof candidate.connectionDetails === "object" && - candidate.connectionDetails !== null - ); -} - export function isGatewayCredentialsRequiredError( value: unknown, ): value is GatewayCredentialsRequiredError { diff --git a/src/gateway/transport-error.ts b/src/gateway/transport-error.ts new file mode 100644 index 000000000000..860969690f62 --- /dev/null +++ b/src/gateway/transport-error.ts @@ -0,0 +1,50 @@ +import type { GatewayConnectionDetails } from "./connection-details.js"; + +export type GatewayTransportErrorKind = "closed" | "timeout"; + +export class GatewayTransportError extends Error { + readonly kind: GatewayTransportErrorKind; + readonly connectionDetails: GatewayConnectionDetails; + readonly code?: number; + readonly reason?: string; + readonly timeoutMs?: number; + + constructor(params: { + kind: GatewayTransportErrorKind; + message: string; + connectionDetails: GatewayConnectionDetails; + code?: number; + reason?: string; + timeoutMs?: number; + }) { + super(params.message); + this.name = "GatewayTransportError"; + this.kind = params.kind; + this.connectionDetails = params.connectionDetails; + if (params.code !== undefined) { + this.code = params.code; + } + if (params.reason !== undefined) { + this.reason = params.reason; + } + if (params.timeoutMs !== undefined) { + this.timeoutMs = params.timeoutMs; + } + } +} + +export function isGatewayTransportError(value: unknown): value is GatewayTransportError { + if (value instanceof GatewayTransportError) { + return true; + } + if (!(value instanceof Error) || value.name !== "GatewayTransportError") { + return false; + } + return ( + "kind" in value && + (value.kind === "closed" || value.kind === "timeout") && + "connectionDetails" in value && + typeof value.connectionDetails === "object" && + value.connectionDetails !== null + ); +}