From c071e6f561e4553fe459796a4b8ffc3285776b5c Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Wed, 23 Sep 2026 16:26:56 -0500 Subject: [PATCH 1/5] Carry the errno through the proxy server's error payload extractErrorInfo dropped a plain Error's code and cause.code, so the client's createDisplayError could never match the ECONNREFUSED and ECONNRESET branches it already had. An unresolvable host and a refused port both surfaced as a generic "Network Response 500". Carry code and cause.code through the response, each guarded on typeof === "string" so no stack, path, or other cause property rides along, and give ENOTFOUND, ETIMEDOUT, and EAI_AGAIN a message of their own. --- .../src/error-handler.test.ts | 106 +++++++++++++++++- .../src/error-handler.ts | 31 ++++- .../src/utils/createDisplayError.test.ts | 68 +++++++++++ .../src/utils/createDisplayError.ts | 17 +++ 4 files changed, 220 insertions(+), 2 deletions(-) diff --git a/packages/graph-explorer-proxy-server/src/error-handler.test.ts b/packages/graph-explorer-proxy-server/src/error-handler.test.ts index 8aa1aaa46..232a57700 100644 --- a/packages/graph-explorer-proxy-server/src/error-handler.test.ts +++ b/packages/graph-explorer-proxy-server/src/error-handler.test.ts @@ -1,6 +1,7 @@ import type { Request, Response } from "express"; -import { errorHandlingMiddleware } from "./error-handler.ts"; +import { errorHandlingMiddleware, extractErrorInfo } from "./error-handler.ts"; +import { HttpError } from "./errors.ts"; import { type AppLogger, createLogger } from "./logging.ts"; import { createTestEnvironment } from "./testing.ts"; @@ -33,7 +34,110 @@ function createMockResponse() { } as unknown as Response; } +describe("extractErrorInfo", () => { + it("carries status, message, and details from an HttpError", () => { + const error = new HttpError(403, "Forbidden", { field: "name" }); + + expect(extractErrorInfo(error)).toStrictEqual({ + status: 403, + message: "Forbidden", + field: "name", + }); + }); + + it("surfaces a top-level errno code", () => { + const error = Object.assign( + new Error( + "request to http://db:8182/gremlin failed, reason: connect ECONNREFUSED 10.0.0.4:8182", + ), + { code: "ECONNREFUSED" }, + ); + + expect(extractErrorInfo(error)).toStrictEqual({ + status: 500, + message: + "request to http://db:8182/gremlin failed, reason: connect ECONNREFUSED 10.0.0.4:8182", + code: "ECONNREFUSED", + }); + }); + + it("surfaces cause.code when the error has no code of its own", () => { + const error = new Error("fetch failed", { + cause: { code: "ENOTFOUND" }, + }); + + expect(extractErrorInfo(error)).toStrictEqual({ + status: 500, + message: "fetch failed", + cause: { code: "ENOTFOUND" }, + }); + }); + + it("omits code and cause when neither is present", () => { + expect(extractErrorInfo(new Error("Something broke"))).toStrictEqual({ + status: 500, + message: "Something broke", + }); + }); + + it("ignores a non-string code", () => { + const error = Object.assign(new Error("bad code"), { code: 500 }); + + expect(extractErrorInfo(error)).toStrictEqual({ + status: 500, + message: "bad code", + }); + }); + + // The payload goes straight to the browser, so a wrapped system error must + // not drag the rest of its cause along with the errno. + it("does not forward unrelated cause properties", () => { + const error = new Error("fetch failed", { + cause: { + code: "ENOTFOUND", + stack: "Error: fetch failed\n at /graph-explorer/src/app.ts:210", + hostname: "internal-db.example.com", + path: "/etc/ssl/private/server.key", + syscall: "getaddrinfo", + }, + }); + + expect(extractErrorInfo(error)).toStrictEqual({ + status: 500, + message: "fetch failed", + cause: { code: "ENOTFOUND" }, + }); + }); + + it("falls back to a generic message for a thrown non-Error", () => { + expect(extractErrorInfo("boom")).toStrictEqual({ + status: 500, + message: "Internal Server Error", + name: "Error", + }); + }); +}); + describe("errorHandlingMiddleware", () => { + it("sends the errno code to the client so display errors can use it", () => { + const middleware = errorHandlingMiddleware(); + const response = createMockResponse(); + const error = Object.assign(new Error("connect ECONNREFUSED"), { + code: "ECONNREFUSED", + }); + + middleware(error, createMockRequest(), response, vi.fn()); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.send).toHaveBeenCalledWith({ + error: { + status: 500, + message: "connect ECONNREFUSED", + code: "ECONNREFUSED", + }, + }); + }); + describe("request header logging", () => { // node-fetch's real wording when handed a URL carrying userinfo, verified // against node-fetch 3. The value appears in the header log line the diff --git a/packages/graph-explorer-proxy-server/src/error-handler.ts b/packages/graph-explorer-proxy-server/src/error-handler.ts index 7205cc3f4..7f8d98cd3 100644 --- a/packages/graph-explorer-proxy-server/src/error-handler.ts +++ b/packages/graph-explorer-proxy-server/src/error-handler.ts @@ -84,7 +84,7 @@ export function errorHandlingMiddleware() { }; } -function extractErrorInfo(error: unknown) { +export function extractErrorInfo(error: unknown) { const defaultErrorMessage = "Internal Server Error"; if (error instanceof HttpError) { @@ -99,6 +99,7 @@ function extractErrorInfo(error: unknown) { return { status: 500, message: error.message || defaultErrorMessage, + ...extractErrno(error), }; } @@ -108,3 +109,31 @@ function extractErrorInfo(error: unknown) { name: "Error", }; } + +/** + * Picks the errno `code` off a Node.js system error or a node-fetch + * `FetchError`, plus the same field off its `cause`, so the client's + * `createDisplayError` can tell ECONNREFUSED from ENOTFOUND. Nothing else is + * copied: the stack and any other `cause` property stay out of the response. + */ +function extractErrno(error: Error): { + code?: string; + cause?: { code: string }; +} { + const code = errnoCode(error); + const causeCode = errnoCode(error.cause); + + return { + ...(code ? { code } : {}), + ...(causeCode ? { cause: { code: causeCode } } : {}), + }; +} + +function errnoCode(value: unknown): string | undefined { + return typeof value === "object" && + value !== null && + "code" in value && + typeof value.code === "string" + ? value.code + : undefined; +} diff --git a/packages/graph-explorer/src/utils/createDisplayError.test.ts b/packages/graph-explorer/src/utils/createDisplayError.test.ts index bb5042d85..bbb6cc2e8 100644 --- a/packages/graph-explorer/src/utils/createDisplayError.test.ts +++ b/packages/graph-explorer/src/utils/createDisplayError.test.ts @@ -116,6 +116,74 @@ describe("createDisplayError", () => { }); }); + // The proxy server's error handler used to send only { status, message }, so + // an errno could never reach the browser and every network failure fell + // through to a generic "Network Response 500". These use the exact payload + // extractErrorInfo now sends + // (packages/graph-explorer-proxy-server/src/error-handler.ts). + describe("errno codes from the proxy server's error payload", () => { + const unreachable = { + title: "Database unreachable", + message: + "The database hostname could not be resolved, or the endpoint did not answer. Check the hostname in the connection and that the endpoint is reachable.", + }; + + it("Should handle an unresolvable host", () => { + const error = new NetworkError("getaddrinfo ENOTFOUND bad-host", 500, { + status: 500, + message: "getaddrinfo ENOTFOUND bad-host", + code: "ENOTFOUND", + }); + + expect(createDisplayError(error)).toStrictEqual(unreachable); + }); + + it("Should handle a connection that timed out", () => { + const error = new NetworkError("connect ETIMEDOUT 10.0.0.4:8182", 500, { + status: 500, + message: "connect ETIMEDOUT 10.0.0.4:8182", + code: "ETIMEDOUT", + }); + + expect(createDisplayError(error)).toStrictEqual(unreachable); + }); + + it("Should handle a temporary DNS failure", () => { + const error = new NetworkError("getaddrinfo EAI_AGAIN db", 500, { + status: 500, + message: "getaddrinfo EAI_AGAIN db", + code: "EAI_AGAIN", + }); + + expect(createDisplayError(error)).toStrictEqual(unreachable); + }); + + it("Should handle an unresolvable host reported under cause", () => { + const error = new NetworkError("fetch failed", 500, { + status: 500, + message: "fetch failed", + cause: { code: "ENOTFOUND" }, + }); + + expect(createDisplayError(error)).toStrictEqual(unreachable); + }); + + it("Should handle a refused port", () => { + const message = + "request to http://localhost:9999/gremlin failed, reason: connect ECONNREFUSED 127.0.0.1:9999"; + const error = new NetworkError(message, 500, { + status: 500, + message, + code: "ECONNREFUSED", + }); + + expect(createDisplayError(error)).toStrictEqual({ + title: "Connection refused", + message: "Please check your connection and try again.", + }); + }); + }); + it("should handle cancelled error", async () => { const error = await createCancelledError(); const result = createDisplayError(error); diff --git a/packages/graph-explorer/src/utils/createDisplayError.ts b/packages/graph-explorer/src/utils/createDisplayError.ts index e38107fc1..31bcb75ee 100644 --- a/packages/graph-explorer/src/utils/createDisplayError.ts +++ b/packages/graph-explorer/src/utils/createDisplayError.ts @@ -22,6 +22,13 @@ const defaultDisplayError: DisplayError = { message: "An error occurred. Please try again.", }; +/** + * Errno codes that all mean the endpoint was never reached: DNS said no such + * host, DNS failed temporarily, or nothing answered. They share one message + * because the remedy is the same. + */ +const UNREACHABLE_HOST_CODES = new Set(["ENOTFOUND", "ETIMEDOUT", "EAI_AGAIN"]); + /** * Attempts to convert the technicality of errors in to humane * friendly errors that are suitable for display. @@ -50,6 +57,16 @@ export function createDisplayError(error: any): DisplayError { message: "Please check your connection and try again.", }; } + if ( + UNREACHABLE_HOST_CODES.has(data.code) || + UNREACHABLE_HOST_CODES.has(data.cause?.code) + ) { + return { + title: "Database unreachable", + message: + "The database hostname could not be resolved, or the endpoint did not answer. Check the hostname in the connection and that the endpoint is reachable.", + }; + } if ( data.code === "ERR_INVALID_URL" || data.cause?.code === "ERR_INVALID_URL" From 5df9683b282325aaa4871c13b160dd1b8ed5382f Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 24 Sep 2026 11:57:00 -0500 Subject: [PATCH 2/5] Share the mock request factory across proxy server tests error-handler.test.ts and logging.test.ts each built their own, with the same app.locals.logger shape. Each request now gets its own logger so a test can spy on it without leaking onto another test. --- .../src/logging.test.ts | 19 ++------------- .../src/testing.ts | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/graph-explorer-proxy-server/src/logging.test.ts b/packages/graph-explorer-proxy-server/src/logging.test.ts index 9f59b2b06..0544145af 100644 --- a/packages/graph-explorer-proxy-server/src/logging.test.ts +++ b/packages/graph-explorer-proxy-server/src/logging.test.ts @@ -1,4 +1,4 @@ -import type { Request, Response } from "express"; +import type { Response } from "express"; import { createLogger, @@ -6,22 +6,7 @@ import { logRequestAndResponse, requestLoggingMiddleware, } from "./logging.ts"; -import { createTestEnvironment } from "./testing.ts"; - -const sharedLogger = createLogger(createTestEnvironment()); - -function createMockRequest(overrides: Partial = {}) { - return { - method: "GET", - path: "/test", - app: { - locals: { - logger: sharedLogger, - }, - }, - ...overrides, - } as unknown as Request; -} +import { createMockRequest, createTestEnvironment } from "./testing.ts"; function createMockResponse(statusCode: number) { return { diff --git a/packages/graph-explorer-proxy-server/src/testing.ts b/packages/graph-explorer-proxy-server/src/testing.ts index 257599f55..b0270815c 100644 --- a/packages/graph-explorer-proxy-server/src/testing.ts +++ b/packages/graph-explorer-proxy-server/src/testing.ts @@ -1,5 +1,9 @@ +import type { Request } from "express"; + import type { EnvironmentValues } from "./env.ts"; +import { createLogger } from "./logging.ts"; + /** * Builds a complete {@link EnvironmentValues} for tests, so a new schema field * only has to be defaulted here rather than at every call site. @@ -17,3 +21,22 @@ export function createTestEnvironment( ...overrides, }; } + +/** + * Builds an Express {@link Request} carrying the fields the logging and error + * handling middleware read. Each request gets its own silent logger, so a test + * can spy on `request.app.locals.logger` without leaking onto other tests. + */ +export function createMockRequest(overrides: Partial = {}) { + return { + method: "GET", + path: "/test", + headers: {}, + app: { + locals: { + logger: createLogger(createTestEnvironment()), + }, + }, + ...overrides, + } as unknown as Request; +} From d5621a01bd5b5bb7919a0e5e336b0824b98e1031 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 24 Sep 2026 11:57:00 -0500 Subject: [PATCH 3/5] Send one errno code instead of mirroring it under cause node-fetch assigns the errno to code and never sets cause, so the nested field was unreachable. Resolving code from the error or its cause covers the same ground with one field, and keeps the nested object out of the wire format. --- .../src/error-handler.test.ts | 78 +++++-------------- .../src/error-handler.ts | 20 ++--- 2 files changed, 28 insertions(+), 70 deletions(-) diff --git a/packages/graph-explorer-proxy-server/src/error-handler.test.ts b/packages/graph-explorer-proxy-server/src/error-handler.test.ts index 232a57700..24e4b0c9c 100644 --- a/packages/graph-explorer-proxy-server/src/error-handler.test.ts +++ b/packages/graph-explorer-proxy-server/src/error-handler.test.ts @@ -1,31 +1,8 @@ -import type { Request, Response } from "express"; +import type { Response } from "express"; import { errorHandlingMiddleware, extractErrorInfo } from "./error-handler.ts"; import { HttpError } from "./errors.ts"; -import { type AppLogger, createLogger } from "./logging.ts"; -import { createTestEnvironment } from "./testing.ts"; - -const sharedLogger = createLogger(createTestEnvironment()); - -function createMockRequest( - headers: Record = {}, - logger: AppLogger = sharedLogger, -) { - return { - method: "POST", - path: "/gremlin", - headers, - app: { - locals: { - logger, - }, - }, - } as unknown as Request; -} - -function createSpyLogger() { - return { error: vi.fn() } as unknown as AppLogger; -} +import { createMockRequest } from "./testing.ts"; function createMockResponse() { return { @@ -61,19 +38,27 @@ describe("extractErrorInfo", () => { }); }); - it("surfaces cause.code when the error has no code of its own", () => { + // The payload goes straight to the browser, so a wrapped system error must + // not drag the rest of its cause along with the errno. + it("surfaces cause.code without forwarding the rest of the cause", () => { const error = new Error("fetch failed", { - cause: { code: "ENOTFOUND" }, + cause: { + code: "ENOTFOUND", + stack: "Error: fetch failed\n at /graph-explorer/src/app.ts:210", + hostname: "internal-db.example.com", + path: "/etc/ssl/private/server.key", + syscall: "getaddrinfo", + }, }); expect(extractErrorInfo(error)).toStrictEqual({ status: 500, message: "fetch failed", - cause: { code: "ENOTFOUND" }, + code: "ENOTFOUND", }); }); - it("omits code and cause when neither is present", () => { + it("omits the code when the error has none", () => { expect(extractErrorInfo(new Error("Something broke"))).toStrictEqual({ status: 500, message: "Something broke", @@ -89,26 +74,6 @@ describe("extractErrorInfo", () => { }); }); - // The payload goes straight to the browser, so a wrapped system error must - // not drag the rest of its cause along with the errno. - it("does not forward unrelated cause properties", () => { - const error = new Error("fetch failed", { - cause: { - code: "ENOTFOUND", - stack: "Error: fetch failed\n at /graph-explorer/src/app.ts:210", - hostname: "internal-db.example.com", - path: "/etc/ssl/private/server.key", - syscall: "getaddrinfo", - }, - }); - - expect(extractErrorInfo(error)).toStrictEqual({ - status: 500, - message: "fetch failed", - cause: { code: "ENOTFOUND" }, - }); - }); - it("falls back to a generic message for a thrown non-Error", () => { expect(extractErrorInfo("boom")).toStrictEqual({ status: 500, @@ -148,21 +113,20 @@ describe("errorHandlingMiddleware", () => { ); function logHeaderLine(headers: Record) { - const logger = createSpyLogger(); + const request = createMockRequest({ headers }); + const errorSpy = vi.spyOn(request.app.locals.logger, "error"); errorHandlingMiddleware()( fetchError, - createMockRequest(headers, logger), + request, createMockResponse(), vi.fn(), ); - const headerCall = vi - .mocked(logger.error) - .mock.calls.find( - ([first]) => - typeof first === "string" && first.includes("Request headers"), - ); + const headerCall = errorSpy.mock.calls.find( + ([first]) => + typeof first === "string" && first.includes("Request headers"), + ); expect(headerCall).toBeDefined(); return String(headerCall![1]); } diff --git a/packages/graph-explorer-proxy-server/src/error-handler.ts b/packages/graph-explorer-proxy-server/src/error-handler.ts index 7f8d98cd3..2c7f14501 100644 --- a/packages/graph-explorer-proxy-server/src/error-handler.ts +++ b/packages/graph-explorer-proxy-server/src/error-handler.ts @@ -112,21 +112,15 @@ export function extractErrorInfo(error: unknown) { /** * Picks the errno `code` off a Node.js system error or a node-fetch - * `FetchError`, plus the same field off its `cause`, so the client's - * `createDisplayError` can tell ECONNREFUSED from ENOTFOUND. Nothing else is - * copied: the stack and any other `cause` property stay out of the response. + * `FetchError`, falling back to the same field on its `cause` for wrappers that + * carry the errno one level down, so the client's `createDisplayError` can tell + * ECONNREFUSED from ENOTFOUND. Nothing else is copied: the stack and any other + * `cause` property stay out of the response. */ -function extractErrno(error: Error): { - code?: string; - cause?: { code: string }; -} { - const code = errnoCode(error); - const causeCode = errnoCode(error.cause); +function extractErrno(error: Error) { + const code = errnoCode(error) ?? errnoCode(error.cause); - return { - ...(code ? { code } : {}), - ...(causeCode ? { cause: { code: causeCode } } : {}), - }; + return code ? { code } : {}; } function errnoCode(value: unknown): string | undefined { From 99928a2021b7dbf46e4bd9854283df8af4e2c2eb Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 24 Sep 2026 11:57:00 -0500 Subject: [PATCH 4/5] Give a connect timeout its own message ETIMEDOUT shared the DNS message, which told the user to check a hostname that had already resolved. The name resolved and nothing answered, so the remedy is a security group, a firewall, or the port. --- .../src/utils/createDisplayError.test.ts | 19 +++++++++++++++++-- .../src/utils/createDisplayError.ts | 19 ++++++++++++++----- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/graph-explorer/src/utils/createDisplayError.test.ts b/packages/graph-explorer/src/utils/createDisplayError.test.ts index bbb6cc2e8..f459aa0e4 100644 --- a/packages/graph-explorer/src/utils/createDisplayError.test.ts +++ b/packages/graph-explorer/src/utils/createDisplayError.test.ts @@ -125,7 +125,12 @@ describe("createDisplayError", () => { const unreachable = { title: "Database unreachable", message: - "The database hostname could not be resolved, or the endpoint did not answer. Check the hostname in the connection and that the endpoint is reachable.", + "The database hostname could not be resolved. Check the hostname in the connection and try again.", + }; + const timedOut = { + title: "Database connection timed out", + message: + "The database hostname resolved, but nothing answered at that address. Check that a security group or firewall permits the Graph Explorer server, and that the port in the connection is correct.", }; it("Should handle an unresolvable host", () => { @@ -145,7 +150,17 @@ describe("createDisplayError", () => { code: "ETIMEDOUT", }); - expect(createDisplayError(error)).toStrictEqual(unreachable); + expect(createDisplayError(error)).toStrictEqual(timedOut); + }); + + it("Should handle a connection that timed out reported under cause", () => { + const error = new NetworkError("fetch failed", 500, { + status: 500, + message: "fetch failed", + cause: { code: "ETIMEDOUT" }, + }); + + expect(createDisplayError(error)).toStrictEqual(timedOut); }); it("Should handle a temporary DNS failure", () => { diff --git a/packages/graph-explorer/src/utils/createDisplayError.ts b/packages/graph-explorer/src/utils/createDisplayError.ts index 31bcb75ee..5948a8759 100644 --- a/packages/graph-explorer/src/utils/createDisplayError.ts +++ b/packages/graph-explorer/src/utils/createDisplayError.ts @@ -23,11 +23,11 @@ const defaultDisplayError: DisplayError = { }; /** - * Errno codes that all mean the endpoint was never reached: DNS said no such - * host, DNS failed temporarily, or nothing answered. They share one message - * because the remedy is the same. + * Errno codes that mean DNS never produced an address: no such host, or a + * temporary resolver failure. They share one message because both point at the + * hostname. */ -const UNREACHABLE_HOST_CODES = new Set(["ENOTFOUND", "ETIMEDOUT", "EAI_AGAIN"]); +const UNREACHABLE_HOST_CODES = new Set(["ENOTFOUND", "EAI_AGAIN"]); /** * Attempts to convert the technicality of errors in to humane @@ -64,7 +64,16 @@ export function createDisplayError(error: any): DisplayError { return { title: "Database unreachable", message: - "The database hostname could not be resolved, or the endpoint did not answer. Check the hostname in the connection and that the endpoint is reachable.", + "The database hostname could not be resolved. Check the hostname in the connection and try again.", + }; + } + // The hostname is the one thing already proven correct, so this cannot + // reuse the message above. + if (data.code === "ETIMEDOUT" || data.cause?.code === "ETIMEDOUT") { + return { + title: "Database connection timed out", + message: + "The database hostname resolved, but nothing answered at that address. Check that a security group or firewall permits the Graph Explorer server, and that the port in the connection is correct.", }; } if ( From a503b94245bad4b8ecc0d4d6443085b4f715de85 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 24 Sep 2026 12:59:13 -0500 Subject: [PATCH 5/5] Document the database unreachable and timed out errors Both titles are new, and the troubleshooting guide had nothing to find when a user searched for them. Each gets its cause and its fix, since a DNS failure and a connect timeout point at different things. --- docs/guides/troubleshooting.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index e732f9f9d..a868e038f 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -117,6 +117,22 @@ This can manifest as different types of errors depending on the root cause. You > [!IMPORTANT] > The paths listed here could always change in the future. If they do change, we will note that in the release notes. +### Database Cannot Be Reached + +These errors mean the browser reached the proxy server, but the proxy server could not reach the database. Graph Explorer names the failure with one of two titles, and each one points at a different fix. + +**Database unreachable** means the database hostname could not be resolved. Check the hostname in the connection's Graph Connection URL for a typo, and check that the proxy server's host can resolve that name. A private endpoint, such as a Neptune cluster endpoint inside a VPC, only resolves from inside that VPC. + +**Database connection timed out** means the hostname resolved, but nothing answered at that address. The hostname is correct, so look at the network path instead: + +- The database's security group must allow inbound traffic on the database port from the proxy server, not from your browser +- A firewall or network ACL between the proxy server and the database can drop the connection +- The port in the connection must match the port the database listens on, for example `8182` for Neptune + +This error can take a minute or more to appear, because the proxy server waits for the operating system's connection timeout. + +For the network setup Neptune needs, see [Network Access](./connecting-to-neptune.md#network-access). + ## Save & Load Configuration Inside of Graph Explorer there is an option to save all the configuration data that Graph Explorer uses. This data is local to the user's browser and does not exist on the server.