Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
136 changes: 102 additions & 34 deletions packages/graph-explorer-proxy-server/src/error-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {},
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 {
Expand All @@ -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
Expand All @@ -44,21 +113,20 @@ describe("errorHandlingMiddleware", () => {
);

function logHeaderLine(headers: Record<string, string>) {
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]);
}
Expand Down
25 changes: 24 additions & 1 deletion packages/graph-explorer-proxy-server/src/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -99,6 +99,7 @@ function extractErrorInfo(error: unknown) {
return {
status: 500,
message: error.message || defaultErrorMessage,
...extractErrno(error),
};
}

Expand All @@ -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;
}
19 changes: 2 additions & 17 deletions packages/graph-explorer-proxy-server/src/logging.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,12 @@
import type { Request, Response } from "express";
import type { Response } from "express";

import {
createLogger,
getRequestLoggerPrefix,
logRequestAndResponse,
requestLoggingMiddleware,
} from "./logging.ts";
import { createTestEnvironment } from "./testing.ts";

const sharedLogger = createLogger(createTestEnvironment());

function createMockRequest(overrides: Partial<Request> = {}) {
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 {
Expand Down
23 changes: 23 additions & 0 deletions packages/graph-explorer-proxy-server/src/testing.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<Request> = {}) {
return {
method: "GET",
path: "/test",
headers: {},
app: {
locals: {
logger: createLogger(createTestEnvironment()),
},
},
...overrides,
} as unknown as Request;
}
83 changes: 83 additions & 0 deletions packages/graph-explorer/src/utils/createDisplayError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading