diff --git a/src/Common/QueryError.test.ts b/src/Common/QueryError.test.ts new file mode 100644 index 000000000..2eea29a62 --- /dev/null +++ b/src/Common/QueryError.test.ts @@ -0,0 +1,94 @@ +import QueryError, { QueryErrorLocation, QueryErrorSeverity } from "Common/QueryError"; + +describe("QueryError.tryParse", () => { + const testErrorLocationResolver = ({ start, end }: { start: number; end: number }) => + new QueryErrorLocation( + { offset: start, lineNumber: start, column: start }, + { offset: end, lineNumber: end, column: end }, + ); + + it("handles a string error", () => { + const error = "error"; + const result = QueryError.tryParse(error, testErrorLocationResolver); + expect(result).toEqual([new QueryError("error", QueryErrorSeverity.Error)]); + }); + + it("handles an error object", () => { + const error = { + message: "error", + severity: "Warning", + location: { start: 0, end: 1 }, + code: "code", + }; + const result = QueryError.tryParse(error, testErrorLocationResolver); + expect(result).toEqual([ + new QueryError( + "error", + QueryErrorSeverity.Warning, + "code", + new QueryErrorLocation({ offset: 0, lineNumber: 0, column: 0 }, { offset: 1, lineNumber: 1, column: 1 }), + ), + ]); + }); + + it("handles a JSON message without syntax errors", () => { + const innerError = { + code: "BadRequest", + message: "Your query is bad, and you should feel bad", + }; + const message = JSON.stringify(innerError); + const outerError = { + code: "BadRequest", + message, + }; + + const result = QueryError.tryParse(outerError, testErrorLocationResolver); + expect(result).toEqual([ + new QueryError("Your query is bad, and you should feel bad", QueryErrorSeverity.Error, "BadRequest"), + ]); + }); + + // Imitate the value coming from the backend, which has the syntax errors serialized as JSON in the message. + it("handles single-nested error", () => { + const errors = [ + { + message: "error1", + severity: "Warning", + location: { start: 0, end: 1 }, + code: "code1", + }, + { + message: "error2", + severity: "Error", + location: { start: 2, end: 3 }, + code: "code2", + }, + ]; + const innerError = { + code: "BadRequest", + message: "Your query is bad, and you should feel bad", + errors, + }; + const message = JSON.stringify(innerError); + const outerError = { + code: "BadRequest", + message, + }; + + const result = QueryError.tryParse(outerError, testErrorLocationResolver); + expect(result).toEqual([ + new QueryError( + "error1", + QueryErrorSeverity.Warning, + "code1", + new QueryErrorLocation({ offset: 0, lineNumber: 0, column: 0 }, { offset: 1, lineNumber: 1, column: 1 }), + ), + new QueryError( + "error2", + QueryErrorSeverity.Error, + "code2", + new QueryErrorLocation({ offset: 2, lineNumber: 2, column: 2 }, { offset: 3, lineNumber: 3, column: 3 }), + ), + ]); + }); +}); diff --git a/src/Common/QueryError.ts b/src/Common/QueryError.ts index d7fa8033c..51748d1a8 100644 --- a/src/Common/QueryError.ts +++ b/src/Common/QueryError.ts @@ -1,5 +1,5 @@ -import { getErrorMessage } from "Common/ErrorHandlingUtils"; import { monaco } from "Explorer/LazyMonaco"; +import { getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility"; export enum QueryErrorSeverity { Error = "Error", @@ -97,13 +97,44 @@ export const createMonacoMarkersForQueryErrors = (errors: QueryError[]) => { .filter((marker) => !!marker); }; +export interface ErrorEnrichment { + title?: string; + message: string; + learnMoreUrl?: string; +} + +const REPLACEMENT_MESSAGES: Record string> = { + OPERATION_RU_LIMIT_EXCEEDED: (original) => { + if (ruThresholdEnabled()) { + const threshold = getRUThreshold(); + return `Query exceeded the Request Unit (RU) limit of ${threshold} RUs. You can change this limit in Data Explorer settings.`; + } + return original; + }, +}; + +const HELP_LINKS: Record = { + OPERATION_RU_LIMIT_EXCEEDED: + "https://learn.microsoft.com/en-us/azure/cosmos-db/data-explorer#configure-request-unit-threshold", +}; + export default class QueryError { + message: string; + helpLink?: string; + constructor( - public message: string, + message: string, public severity: QueryErrorSeverity, public code?: string, public location?: QueryErrorLocation, - ) {} + helpLink?: string, + ) { + // Automatically replace the message with a more Data Explorer-specific message if we have for this error code. + this.message = REPLACEMENT_MESSAGES[code] ? REPLACEMENT_MESSAGES[code](message) : message; + + // Automatically set the help link if we have one for this error code. + this.helpLink = helpLink ?? HELP_LINKS[code]; + } getMonacoSeverity(): monaco.MarkerSeverity { // It's very difficult to use the monaco.MarkerSeverity enum from here, so we'll just use the numbers directly. @@ -135,7 +166,7 @@ export default class QueryError { return errors; } - const errorMessage = getErrorMessage(error as string | Error); + const errorMessage = error as string; // Map some well known messages to richer errors const knownError = knownErrors[errorMessage]; @@ -160,7 +191,9 @@ export default class QueryError { } const severity = - "severity" in error && typeof error.severity === "string" ? (error.severity as QueryErrorSeverity) : undefined; + "severity" in error && typeof error.severity === "string" + ? (error.severity as QueryErrorSeverity) + : QueryErrorSeverity.Error; const location = "location" in error && typeof error.location === "object" ? locationResolver(error.location as { start: number; end: number }) @@ -173,16 +206,15 @@ export default class QueryError { error: unknown, locationResolver: (location: { start: number; end: number }) => QueryErrorLocation, ): QueryError[] | null { - if (typeof error === "object" && "message" in error) { - error = error.message; - } - - if (typeof error !== "string") { + let message: string | undefined; + if (typeof error === "object" && "message" in error && typeof error.message === "string") { + message = error.message; + } else { + // Unsupported error format. return null; } // Assign to a new variable because of a TypeScript flow typing quirk, see below. - let message = error; if (message.startsWith("Message: ")) { // Reassigning this to 'error' restores the original type of 'error', which is 'unknown'. // So we use a separate variable to avoid this. @@ -196,12 +228,15 @@ export default class QueryError { try { parsed = JSON.parse(message); } catch (e) { - // Not a query error. - return null; + // The message doesn't contain a nested error. + return [QueryError.read(error, locationResolver)]; } - if (typeof parsed === "object" && "errors" in parsed && Array.isArray(parsed.errors)) { - return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null); + if (typeof parsed === "object") { + if ("errors" in parsed && Array.isArray(parsed.errors)) { + return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null); + } + return [QueryError.read(parsed, locationResolver)]; } return null; } diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index eb79f0fe9..92b2f9ad1 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -608,16 +608,16 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ -
RU Threshold
+
RU Limit
- If a query exceeds a configured RU threshold, the query will be aborted. + If a query exceeds a configured RU limit, the query will be aborted.
@@ -625,7 +625,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ {ruThresholdEnabled && (
- RU Threshold + RU Limit
@@ -164,11 +164,11 @@ exports[`Settings Pane should render Default properly 1`] = `
- If a query exceeds a configured RU threshold, the query will be aborted. + If a query exceeds a configured RU limit, the query will be aborted.
= ({ errors }) => { createTableColumn({ columnId: "code", compare: (item1, item2) => item1.code.localeCompare(item2.code), - renderHeaderCell: () => null, - renderCell: (item) => item.code, + renderHeaderCell: () => "Code", + renderCell: (item) => {item.code}, }), createTableColumn({ columnId: "severity", compare: (item1, item2) => compareSeverity(item1.severity, item2.severity), - renderHeaderCell: () => null, - renderCell: (item) => {item.severity}, + renderHeaderCell: () => "Severity", + renderCell: (item) => ( + + {item.severity} + + ), }), createTableColumn({ columnId: "location", compare: (item1, item2) => item1.location?.start?.offset - item2.location?.start?.offset, renderHeaderCell: () => "Location", - renderCell: (item) => - item.location - ? item.location.start.lineNumber - ? `Line ${item.location.start.lineNumber}` - : "" - : "", + renderCell: (item) => ( + + {item.location + ? item.location.start.lineNumber + ? `Line ${item.location.start.lineNumber}` + : "" + : ""} + + ), }), createTableColumn({ columnId: "message", @@ -60,8 +67,20 @@ export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => { renderHeaderCell: () => "Message", renderCell: (item) => (
-
{item.message}
-
+
+ {item.message} +
+
+ {item.helpLink && ( +