diff --git a/packages/graph-explorer-proxy-server/node-server.js b/packages/graph-explorer-proxy-server/node-server.js index eae2c9357..647781d07 100644 --- a/packages/graph-explorer-proxy-server/node-server.js +++ b/packages/graph-explorer-proxy-server/node-server.js @@ -58,12 +58,16 @@ const errorHandler = (error, request, response, next) => { proxyLogger.error(error.message); proxyLogger.debug(error.stack); } - response.status(error.status || 500).send({ - error: { - status: error.status || 500, - message: error.message || "Internal Server Error", - }, - }); + + response + .status(error.status || 500) + .json({ + error: { + ...error, + status: error.status || 500, + message: error.message || "Internal Server Error", + }, + }); }; // Function to retry fetch requests with exponential backoff. @@ -115,8 +119,7 @@ const retryFetch = async ( proxyLogger.error("!!Request failure!!"); proxyLogger.error("URL: " + url.href); proxyLogger.error(`Response: ${res.status} - ${res.statusText}`); - const result = await res.json(); - throw new Error("\n" + JSON.stringify(result, null, 2)); + return res; } else { proxyLogger.debug("Successful response: " + res.statusText); return res; @@ -147,302 +150,300 @@ async function fetchData(res, next, url, options, isIamEnabled, region, serviceT serviceType ); const data = await response.json(); + res.status(response.status); res.send(data); } catch (error) { next(error); } } -(async () => { - app.use(errorHandler); - app.use(compression()); // Use compression middleware - app.use(cors()); - app.use(bodyParser.json()); - app.use(bodyParser.urlencoded({ extended: true })); +app.use(compression()); // Use compression middleware +app.use(cors()); +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ extended: true })); +app.use( + "/defaultConnection", + express.static( + path.join(__dirname, "../graph-explorer/defaultConnection.json") + ) +); +if (process.env.NEPTUNE_NOTEBOOK !== "false") { app.use( - "/defaultConnection", - express.static( - path.join(__dirname, "../graph-explorer/defaultConnection.json") - ) + "/explorer", + express.static(path.join(__dirname, "../graph-explorer/dist")) ); - if (process.env.NEPTUNE_NOTEBOOK !== "false") { - app.use( - "/explorer", - express.static(path.join(__dirname, "../graph-explorer/dist")) - ); - } else { - app.use( - process.env.GRAPH_EXP_ENV_ROOT_FOLDER, - express.static(path.join(__dirname, "../graph-explorer/dist")) - ); - } - // POST endpoint for SPARQL queries. - app.post("/sparql", async (req, res, next) => { - // Gather info from the headers - const queryId = req.headers["queryid"]; - const graphDbConnectionUrl = req.headers["graph-db-connection-url"]; - const isIamEnabled = !!req.headers["aws-neptune-region"]; - const region = isIamEnabled ? req.headers["aws-neptune-region"] : ""; - const serviceType = isIamEnabled ? (req.headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; - - /// Function to cancel long running queries if the client disappears before completion - async function cancelQuery() { - if (!queryId) { - return; - } - proxyLogger.debug(`Cancelling request ${queryId}...`); - try { - await retryFetch( - new URL(`${graphDbConnectionUrl}/sparql/status`), - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: `cancelQuery&queryId=${encodeURIComponent(queryId)}&silent=true`, +} else { + app.use( + process.env.GRAPH_EXP_ENV_ROOT_FOLDER, + express.static(path.join(__dirname, "../graph-explorer/dist")) + ); +} +// POST endpoint for SPARQL queries. +app.post("/sparql", async (req, res, next) => { + // Gather info from the headers + const queryId = req.headers["queryid"]; + const graphDbConnectionUrl = req.headers["graph-db-connection-url"]; + const isIamEnabled = !!req.headers["aws-neptune-region"]; + const region = isIamEnabled ? req.headers["aws-neptune-region"] : ""; + const serviceType = isIamEnabled ? (req.headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; + + /// Function to cancel long running queries if the client disappears before completion + async function cancelQuery() { + if (!queryId) { + return; + } + proxyLogger.debug(`Cancelling request ${queryId}...`); + try { + await retryFetch( + new URL(`${graphDbConnectionUrl}/sparql/status`), + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", }, - isIamEnabled, - region, - serviceType - ); - } catch (err) { - // Not really an error - proxyLogger.warn("Failed to cancel the query: " + err); - } + body: `cancelQuery&queryId=${encodeURIComponent(queryId)}&silent=true`, + }, + isIamEnabled, + region, + serviceType + ); + } catch (err) { + // Not really an error + proxyLogger.warn("Failed to cancel the query: " + err); } + } - // Watch for a cancelled or aborted connection - req.on("close", async () => { - if (req.complete) { - return; - } + // Watch for a cancelled or aborted connection + req.on("close", async () => { + if (req.complete) { + return; + } - await cancelQuery(); - }); - res.on("close", async () => { - if (res.writableFinished) { - return; - } - await cancelQuery(); - }); + await cancelQuery(); + }); + res.on("close", async () => { + if (res.writableFinished) { + return; + } + await cancelQuery(); + }); - // Validate the input before making any external calls. - if (!req.body.query) { - return res - .status(400) - .send({ error: "[Proxy]SPARQL: Query not provided" }); + // Validate the input before making any external calls. + if (!req.body.query) { + return res + .status(400) + .send({ error: "[Proxy]SPARQL: Query not provided" }); + } + const rawUrl = `${graphDbConnectionUrl}/sparql`; + let body = `query=${encodeURIComponent(req.body.query)}`; + if (queryId) { + body += `&queryId=${encodeURIComponent(queryId)}`; + } + const requestOptions = { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }; + + fetchData(res, next, rawUrl, requestOptions, isIamEnabled, region, serviceType); +}); + +// POST endpoint for Gremlin queries. +app.post("/gremlin", async (req, res, next) => { + // Gather info from the headers + const queryId = req.headers["queryid"]; + const graphDbConnectionUrl = req.headers["graph-db-connection-url"]; + const isIamEnabled = !!req.headers["aws-neptune-region"]; + const region = isIamEnabled ? req.headers["aws-neptune-region"] : ""; + const serviceType = isIamEnabled ? (req.headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; + + // Validate the input before making any external calls. + if (!req.body.query) { + return res + .status(400) + .send({ error: "[Proxy]Gremlin: query not provided" }); + } + + /// Function to cancel long running queries if the client disappears before completion + async function cancelQuery() { + if (!queryId) { + return; } - const rawUrl = `${graphDbConnectionUrl}/sparql`; - let body = `query=${encodeURIComponent(req.body.query)}`; - if (queryId) { - body += `&queryId=${encodeURIComponent(queryId)}`; + proxyLogger.debug(`Cancelling request ${queryId}...`); + try { + await retryFetch( + new URL(`${graphDbConnectionUrl}/gremlin/status?cancelQuery&queryId=${encodeURIComponent(queryId)}`), + { method: "GET" }, + isIamEnabled, + region, + serviceType + ); + } catch (err) { + // Not really an error + proxyLogger.warn("Failed to cancel the query: " + err); } - const requestOptions = { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }; + } - fetchData(res, next, rawUrl, requestOptions, isIamEnabled, region, serviceType); + // Watch for a cancelled or aborted connection + req.on("close", async () => { + if (req.complete) { + return; + } + await cancelQuery(); }); - - // POST endpoint for Gremlin queries. - app.post("/gremlin", async (req, res, next) => { - // Gather info from the headers - const queryId = req.headers["queryid"]; - const graphDbConnectionUrl = req.headers["graph-db-connection-url"]; - const isIamEnabled = !!req.headers["aws-neptune-region"]; - const region = isIamEnabled ? req.headers["aws-neptune-region"] : ""; - const serviceType = isIamEnabled ? (req.headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; - - // Validate the input before making any external calls. - if (!req.body.query) { - return res - .status(400) - .send({ error: "[Proxy]Gremlin: query not provided" }); + res.on("close", async () => { + if (res.writableFinished) { + return; } + await cancelQuery(); + }); - /// Function to cancel long running queries if the client disappears before completion - async function cancelQuery() { - if (!queryId) { - return; - } - proxyLogger.debug(`Cancelling request ${queryId}...`); - try { - await retryFetch( - new URL(`${graphDbConnectionUrl}/gremlin/status?cancelQuery&queryId=${encodeURIComponent(queryId)}`), - { method: "GET" }, - isIamEnabled, - region, - serviceType - ); - } catch (err) { - // Not really an error - proxyLogger.warn("Failed to cancel the query: " + err); - } - } + const body = { gremlin: req.body.query, queryId }; + const rawUrl = `${graphDbConnectionUrl}/gremlin`; + const requestOptions = { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }; - // Watch for a cancelled or aborted connection - req.on("close", async () => { - if (req.complete) { - return; - } - await cancelQuery(); - }); - res.on("close", async () => { - if (res.writableFinished) { - return; - } - await cancelQuery(); - }); + fetchData(res, next, rawUrl, requestOptions, isIamEnabled, region, serviceType); +}); - const body = { gremlin: req.body.query, queryId }; - const rawUrl = `${graphDbConnectionUrl}/gremlin`; - const requestOptions = { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }; +// POST endpoint for openCypher queries. +app.post("/openCypher", async (req, res, next) => { + // Validate the input before making any external calls. + if (!req.body.query) { + return res + .status(400) + .send({ error: "[Proxy]OpenCypher: query not provided" }); + } - fetchData(res, next, rawUrl, requestOptions, isIamEnabled, region, serviceType); - }); + const rawUrl = `${req.headers["graph-db-connection-url"]}/openCypher`; + const requestOptions = { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: `query=${encodeURIComponent(req.body.query)}`, + }; - // POST endpoint for openCypher queries. - app.post("/openCypher", async (req, res, next) => { - // Validate the input before making any external calls. - if (!req.body.query) { - return res - .status(400) - .send({ error: "[Proxy]OpenCypher: query not provided" }); - } + const isIamEnabled = !!req.headers["aws-neptune-region"]; + const region = isIamEnabled ? req.headers["aws-neptune-region"] : ""; + const serviceType = isIamEnabled ? (req.headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; - const rawUrl = `${req.headers["graph-db-connection-url"]}/openCypher`; - const requestOptions = { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: `query=${encodeURIComponent(req.body.query)}`, - }; + fetchData(res, next, rawUrl, requestOptions, isIamEnabled, region, serviceType); +}); - const isIamEnabled = !!req.headers["aws-neptune-region"]; - const region = isIamEnabled ? req.headers["aws-neptune-region"] : ""; - const serviceType = isIamEnabled ? (req.headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; +// GET endpoint to retrieve PropertyGraph statistics summary for Neptune Analytics. +app.get("/summary", async (req, res, next) => { + const isIamEnabled = !!req.headers["aws-neptune-region"]; + const serviceType = isIamEnabled ? (req.headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; + const rawUrl = `${req.headers["graph-db-connection-url"]}/summary?mode=detailed`; - fetchData(res, next, rawUrl, requestOptions, isIamEnabled, region, serviceType); - }); + const requestOptions = { + method: "GET", + }; - // GET endpoint to retrieve PropertyGraph statistics summary for Neptune Analytics. - app.get("/summary", async (req, res, next) => { - const isIamEnabled = !!req.headers["aws-neptune-region"]; - const serviceType = isIamEnabled ? (req.headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; - const rawUrl = `${req.headers["graph-db-connection-url"]}/summary?mode=detailed`; + const region = isIamEnabled ? req.headers["aws-neptune-region"] : ""; - const requestOptions = { - method: "GET", - }; + fetchData(res, next, rawUrl, requestOptions, isIamEnabled, region, serviceType); +}); - const region = isIamEnabled ? req.headers["aws-neptune-region"] : ""; +// GET endpoint to retrieve PropertyGraph statistics summary for Neptune DB. +app.get("/pg/statistics/summary", async (req, res, next) => { + const isIamEnabled = !!req.headers["aws-neptune-region"]; + const serviceType = isIamEnabled ? (req.headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; + const rawUrl = `${req.headers["graph-db-connection-url"]}/pg/statistics/summary?mode=detailed`; - fetchData(res, next, rawUrl, requestOptions, isIamEnabled, region, serviceType); - }); + const requestOptions = { + method: "GET", + }; - // GET endpoint to retrieve PropertyGraph statistics summary for Neptune DB. - app.get("/pg/statistics/summary", async (req, res, next) => { - const isIamEnabled = !!req.headers["aws-neptune-region"]; - const serviceType = isIamEnabled ? (req.headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; - const rawUrl = `${req.headers["graph-db-connection-url"]}/pg/statistics/summary?mode=detailed`; + const region = isIamEnabled ? req.headers["aws-neptune-region"] : ""; - const requestOptions = { - method: "GET", - }; + fetchData(res, next, rawUrl, requestOptions, isIamEnabled, region, serviceType); +}); - const region = isIamEnabled ? req.headers["aws-neptune-region"] : ""; +// GET endpoint to retrieve RDF statistics summary. +app.get("/rdf/statistics/summary", async (req, res, next) => { + const isIamEnabled = !!req.headers["aws-neptune-region"]; + const serviceType = isIamEnabled ? (req.headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; + const rawUrl = `${req.headers["graph-db-connection-url"]}/rdf/statistics/summary?mode=detailed`; - fetchData(res, next, rawUrl, requestOptions, isIamEnabled, region, serviceType); - }); + const requestOptions = { + method: "GET", + }; - // GET endpoint to retrieve RDF statistics summary. - app.get("/rdf/statistics/summary", async (req, res, next) => { - const isIamEnabled = !!req.headers["aws-neptune-region"]; - const serviceType = isIamEnabled ? (req.headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; - const rawUrl = `${req.headers["graph-db-connection-url"]}/rdf/statistics/summary?mode=detailed`; + const region = isIamEnabled ? req.headers["aws-neptune-region"] : ""; - const requestOptions = { - method: "GET", - }; + fetchData(res, next, rawUrl, requestOptions, isIamEnabled, region, serviceType); +}); - const region = isIamEnabled ? req.headers["aws-neptune-region"] : ""; +app.get("/logger", (req, res, next) => { + let message; + let level; + try { + if (req.headers["level"] === undefined) { + throw new Error("No log level passed."); + } else { + level = req.headers["level"]; + } + if (req.headers["message"] === undefined) { + throw new Error("No log message passed."); + } else { + message = req.headers["message"].replaceAll("\\", ""); + } + if (level.toLowerCase() === "error") { + proxyLogger.error(message); + } else if (level.toLowerCase() === "warn") { + proxyLogger.warn(message); + } else if (level.toLowerCase() === "info") { + proxyLogger.info(message); + } else if (level.toLowerCase() === "debug") { + proxyLogger.debug(message); + } else if (level.toLowerCase() === "trace") { + proxyLogger.trace(message); + } else { + throw new Error("Tried to log to an unknown level."); + } + res.send("Log received."); + } catch (error) { + next(error); + } +}); - fetchData(res, next, rawUrl, requestOptions, isIamEnabled, region, serviceType); - }); +// Plugin the error handler after the routes +app.use(errorHandler); - app.get("/logger", (req, res, next) => { - let message; - let level; - try { - if (req.headers["level"] === undefined) { - throw new Error("No log level passed."); - } else { - level = req.headers["level"]; - } - if (req.headers["message"] === undefined) { - throw new Error("No log message passed."); - } else { - message = req.headers["message"].replaceAll("\\", ""); - } - if (level.toLowerCase() === "error") { - proxyLogger.error(message); - } else if (level.toLowerCase() === "warn") { - proxyLogger.warn(message); - } else if (level.toLowerCase() === "info") { - proxyLogger.info(message); - } else if (level.toLowerCase() === "debug") { - proxyLogger.debug(message); - } else if (level.toLowerCase() === "trace") { - proxyLogger.trace(message); - } else { - throw new Error("Tried to log to an unknown level."); - } - res.send("Log received."); - } catch (error) { - next(error); - } +// Start the server on port 80 or 443 (if HTTPS is enabled) +if (process.env.NEPTUNE_NOTEBOOK === "true") { + app.listen(9250, () => { + proxyLogger.info( + `\tProxy available at port 9250 for Neptune Notebook instance` + ); }); - app.use(errorHandler); - // Start the server on port 80 or 443 (if HTTPS is enabled) - if (process.env.NEPTUNE_NOTEBOOK === "true") { - app.listen(9250, async () => { +} else if ( + process.env.PROXY_SERVER_HTTPS_CONNECTION !== "false" && + fs.existsSync("../graph-explorer-proxy-server/cert-info/server.key") && + fs.existsSync("../graph-explorer-proxy-server/cert-info/server.crt") +) { + const options = { + key: fs.readFileSync("./cert-info/server.key"), + cert: fs.readFileSync("./cert-info/server.crt"), + }; + https.createServer(options, app) + .listen(443, () => { + proxyLogger.info(`Proxy server located at https://localhost`); proxyLogger.info( - `\tProxy available at port 9250 for Neptune Notebook instance` + `Graph Explorer live at: ${process.env.GRAPH_CONNECTION_URL}/explorer` ); }); - } else if ( - process.env.PROXY_SERVER_HTTPS_CONNECTION !== "false" && - fs.existsSync("../graph-explorer-proxy-server/cert-info/server.key") && - fs.existsSync("../graph-explorer-proxy-server/cert-info/server.crt") - ) { - https - .createServer( - { - key: fs.readFileSync("./cert-info/server.key"), - cert: fs.readFileSync("./cert-info/server.crt"), - }, - app - ) - .listen(443, async () => { - proxyLogger.info(`Proxy server located at https://localhost`); - proxyLogger.info( - `Graph Explorer live at: ${process.env.GRAPH_CONNECTION_URL}/explorer` - ); - }); - } else { - app.listen(80, async () => { - proxyLogger.info(`Proxy server located at http://localhost`); - }); - } -})(); +} else { + app.listen(80, () => { + proxyLogger.info(`Proxy server located at http://localhost`); + }); +} diff --git a/packages/graph-explorer/src/connector/useGEFetch.ts b/packages/graph-explorer/src/connector/useGEFetch.ts index 8eb7bdaa7..abcbe3c80 100644 --- a/packages/graph-explorer/src/connector/useGEFetch.ts +++ b/packages/graph-explorer/src/connector/useGEFetch.ts @@ -14,6 +14,46 @@ const localforageCache = localforage.createInstance({ storeName: "connector-cache", }); +/** + * Attempts to decode the error response into a JSON object. + * + * @param response The fetch response that should be decoded + * @returns The decoded response or undefined if it fails to decode + */ +async function decodeErrorSafely(response: Response): Promise { + const contentType = response.headers.get("Content-Type"); + const contentTypeHasValue = contentType !== null && contentType.length > 0; + // Assume missing content type is JSON + const isJson = + !contentTypeHasValue || contentType.includes("application/json"); + + if (isJson) { + try { + const data = await response.json(); + // Flatten the error if it contains an error object + return data?.error ?? data; + } catch (error) { + console.error("Failed to decode the error response as JSON", { + error, + response, + }); + return undefined; + } + } + + try { + const message = await response.text(); + return { message }; + } catch (error) { + console.error("Failed to decode the error response as text", { + error, + response, + }); + } + + return undefined; +} + const useGEFetch = () => { const connection = useConfiguration()?.connection as | ConnectionConfig @@ -46,6 +86,12 @@ const useGEFetch = () => { options: (RequestInit & { disableCache: boolean }) | undefined ) => { const response = await fetch(url, options); + if (!response.ok) { + const error = await decodeErrorSafely(response); + throw new Error("Network response was not OK", { cause: error }); + } + + // A successful response is assumed to be JSON const data = await response.json(); if (options?.disableCache !== true) { _setToCache(url, { data, updatedAt: new Date().getTime() }); diff --git a/packages/graph-explorer/src/hooks/useSchemaSync.ts b/packages/graph-explorer/src/hooks/useSchemaSync.ts index f02d1a3e0..0efe7a887 100644 --- a/packages/graph-explorer/src/hooks/useSchemaSync.ts +++ b/packages/graph-explorer/src/hooks/useSchemaSync.ts @@ -5,6 +5,7 @@ import useConfiguration from "../core/ConfigurationProvider/useConfiguration"; import useConnector from "../core/ConnectorProvider/useConnector"; import usePrefixesUpdater from "./usePrefixesUpdater"; import useUpdateSchema from "./useUpdateSchema"; +import { createDisplayError } from "../utils/createDisplayError"; const useSchemaSync = (onSyncChange?: (isSyncing: boolean) => void) => { const config = useConfiguration(); @@ -31,32 +32,26 @@ const useSchemaSync = (onSyncChange?: (isSyncing: boolean) => void) => { schema = await connector.explorer.fetchSchema(); } catch (e) { + notificationId.current && clearNotification(notificationId.current); + const displayError = createDisplayError(e); + enqueueNotification({ + ...displayError, + type: "error", + stackable: true, + }); if (e.name === "AbortError") { - notificationId.current && clearNotification(notificationId.current); - enqueueNotification({ - title: config.displayLabel || config.id, - message: `Fetch aborted, reached max time out ${config.connection?.fetchTimeoutMs} MS`, - type: "error", - stackable: true, - }); connector.logger?.error( `[${ config.displayLabel || config.id }] Fetch aborted, reached max time out ${config.connection?.fetchTimeoutMs} MS ` ); + } else { + connector.logger?.error( + `[${ + config.displayLabel || config.id + }] Error while fetching schema: ${e.message}` + ); } - notificationId.current && clearNotification(notificationId.current); - enqueueNotification({ - title: config.displayLabel || config.id, - message: `Error while fetching schema: ${e.message}`, - type: "error", - stackable: true, - }); - connector.logger?.error( - `[${ - config.displayLabel || config.id - }] Error while fetching schema: ${e.message}` - ); updateSchemaState(config.id); onSyncChange?.(false); return; diff --git a/packages/graph-explorer/src/modules/KeywordSearch/useKeywordSearchQuery.ts b/packages/graph-explorer/src/modules/KeywordSearch/useKeywordSearchQuery.ts index 5a4afd831..7fe3c51ae 100644 --- a/packages/graph-explorer/src/modules/KeywordSearch/useKeywordSearchQuery.ts +++ b/packages/graph-explorer/src/modules/KeywordSearch/useKeywordSearchQuery.ts @@ -4,6 +4,7 @@ import { useConfiguration } from "../../core"; import useConnector from "../../core/ConnectorProvider/useConnector"; import usePrefixesUpdater from "../../hooks/usePrefixesUpdater"; import { useCallback, useMemo } from "react"; +import { createDisplayError } from "../../utils/createDisplayError"; export type SearchQueryRequest = { debouncedSearchTerm: string; @@ -74,10 +75,10 @@ export function useKeywordSearchQuery({ updatePrefixes(response.vertices.map(v => v.data.id)); }, onError: (e: Error) => { + const displayError = createDisplayError(e); enqueueNotification({ type: "error", - title: "Something went wrong", - message: e.message, + ...displayError, }); }, } diff --git a/packages/graph-explorer/src/utils/createDisplayError.test.ts b/packages/graph-explorer/src/utils/createDisplayError.test.ts new file mode 100644 index 000000000..97e739e22 --- /dev/null +++ b/packages/graph-explorer/src/utils/createDisplayError.test.ts @@ -0,0 +1,64 @@ +import { createDisplayError } from "./createDisplayError"; + +const defaultResult = { + title: "Something went wrong", + message: "An error occurred. Please try again.", +}; + +describe("createDisplayError", () => { + it("Should handle empty object", () => { + const result = createDisplayError({}); + expect(result).toStrictEqual(defaultResult); + }); + + it("Should handle null", () => { + const result = createDisplayError(null); + expect(result).toStrictEqual(defaultResult); + }); + + it("Should handle undefined", () => { + const result = createDisplayError(undefined); + expect(result).toStrictEqual(defaultResult); + }); + + it("Should handle string", () => { + const result = createDisplayError("Some error message string"); + expect(result).toStrictEqual(defaultResult); + }); + + it("Should handle connection refused", () => { + const result = createDisplayError({ code: "ECONNREFUSED" }); + expect(result).toStrictEqual({ + title: "Connection refused", + message: "Please check your connection and try again.", + }); + }); + + it("Should handle connection refused as inner error", () => { + const error = new Error("Some error message string", { + cause: { code: "ECONNREFUSED" }, + }); + const result = createDisplayError(error); + expect(result).toStrictEqual({ + title: "Connection refused", + message: "Please check your connection and try again.", + }); + }); + + it("Should handle AbortError", () => { + const result = createDisplayError({ name: "AbortError" }); + expect(result).toStrictEqual({ + title: "Request cancelled", + message: "The request exceeded the configured timeout length.", + }); + }); + + it("Should handle deadline exceeded", () => { + const result = createDisplayError({ code: "TimeLimitExceededException" }); + expect(result).toStrictEqual({ + title: "Deadline exceeded", + message: + "Increase the query timeout in the DB cluster parameter group, or retry the request.", + }); + }); +}); diff --git a/packages/graph-explorer/src/utils/createDisplayError.ts b/packages/graph-explorer/src/utils/createDisplayError.ts new file mode 100644 index 000000000..5c7bba85a --- /dev/null +++ b/packages/graph-explorer/src/utils/createDisplayError.ts @@ -0,0 +1,52 @@ +export type DisplayError = { + title: string; + message: string; +}; + +const defaultDisplayError: DisplayError = { + title: "Something went wrong", + message: "An error occurred. Please try again.", +}; + +/** + * Attempts to convert the technicality of errors in to humane + * friendly errors that are suitable for display. + * + * @param error Any thrown error or error response. + * @returns A `DisplayError` that contains a title and message. + */ +export function createDisplayError(error: any): DisplayError { + if (typeof error === "object") { + // Bad connection configuration + if ( + error?.code === "ECONNREFUSED" || + error?.cause?.code === "ECONNREFUSED" + ) { + return { + title: "Connection refused", + message: "Please check your connection and try again.", + }; + } + + // Server timeout + if ( + error?.code === "TimeLimitExceededException" || + error?.cause?.code === "TimeLimitExceededException" + ) { + return { + title: "Deadline exceeded", + message: + "Increase the query timeout in the DB cluster parameter group, or retry the request.", + }; + } + + // Fetch timeout + if (error?.name === "AbortError") { + return { + title: "Request cancelled", + message: "The request exceeded the configured timeout length.", + }; + } + } + return defaultDisplayError; +}