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. 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..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,30 +1,8 @@ -import type { Request, Response } from "express"; +import type { Response } from "express"; -import { errorHandlingMiddleware } from "./error-handler.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 { errorHandlingMiddleware, extractErrorInfo } from "./error-handler.ts"; +import { HttpError } from "./errors.ts"; +import { createMockRequest } from "./testing.ts"; function createMockResponse() { return { @@ -33,7 +11,98 @@ 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", + }); + }); + + // 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", + 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", + code: "ENOTFOUND", + }); + }); + + it("omits the code when the error has none", () => { + 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", + }); + }); + + 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 @@ -44,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 7205cc3f4..2c7f14501 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,25 @@ function extractErrorInfo(error: unknown) { name: "Error", }; } + +/** + * Picks the errno `code` off a Node.js system error or a node-fetch + * `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) { + const code = errnoCode(error) ?? errnoCode(error.cause); + + return code ? { code } : {}; +} + +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-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; +} diff --git a/packages/graph-explorer/src/utils/createDisplayError.test.ts b/packages/graph-explorer/src/utils/createDisplayError.test.ts index bb5042d85..f459aa0e4 100644 --- a/packages/graph-explorer/src/utils/createDisplayError.test.ts +++ b/packages/graph-explorer/src/utils/createDisplayError.test.ts @@ -116,6 +116,89 @@ 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. 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", () => { + 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(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", () => { + 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..5948a8759 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 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", "EAI_AGAIN"]); + /** * Attempts to convert the technicality of errors in to humane * friendly errors that are suitable for display. @@ -50,6 +57,25 @@ 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. 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 ( data.code === "ERR_INVALID_URL" || data.cause?.code === "ERR_INVALID_URL"