diff --git a/.changeset/add-to-error-utility.md b/.changeset/add-to-error-utility.md index fd7ce6fe27..4340562c10 100644 --- a/.changeset/add-to-error-utility.md +++ b/.changeset/add-to-error-utility.md @@ -2,4 +2,4 @@ '@backstage/errors': minor --- -A new `toError` utility function is now available for converting unknown values to `ErrorLike` objects. If the value is already error-like it is returned as-is. Strings are used directly as the error message, and other values are stringified with a fallback to JSON to avoid unhelpful messages like `[object Object]`. Non-error causes passed to `CustomErrorBase` are now converted and stored using `toError` rather than discarded. +A new `toError` utility function is now available for converting unknown values to `ErrorLike` objects. If the value is already error-like it is returned as-is, strings are used directly as the error message, and all other values are wrapped as `unknown error ''`. Non-error causes passed to `CustomErrorBase` are now converted and stored using `toError` rather than discarded. diff --git a/.changeset/deep-ripe-rats.md b/.changeset/deep-ripe-rats.md new file mode 100644 index 0000000000..56c25217c4 --- /dev/null +++ b/.changeset/deep-ripe-rats.md @@ -0,0 +1,30 @@ +--- +'@backstage/backend-app-api': patch +'@backstage/backend-defaults': patch +'@backstage/cli': patch +'@backstage/cli-common': patch +'@backstage/cli-module-migrate': patch +'@backstage/cli-module-new': patch +'@backstage/cli-node': patch +'@backstage/config-loader': patch +'@backstage/core-components': patch +'@backstage/repo-tools': patch +'@backstage/plugin-auth': patch +'@backstage/plugin-auth-backend': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-catalog-backend-module-incremental-ingestion': patch +'@backstage/plugin-catalog-import': patch +'@backstage/plugin-catalog-react': patch +'@backstage/plugin-catalog-unprocessed-entities': patch +'@backstage/plugin-devtools-backend': patch +'@backstage/plugin-mcp-actions-backend': patch +'@backstage/plugin-notifications-backend-module-slack': patch +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-scaffolder-backend-module-github': patch +'@backstage/plugin-search-backend-node': patch +'@backstage/plugin-techdocs-backend': patch +'@backstage/plugin-techdocs-node': patch +--- + +Migrated from `assertError` to `toError` for error handling. diff --git a/packages/errors/src/errors/CustomErrorBase.ts b/packages/errors/src/errors/CustomErrorBase.ts index 8b08887aa1..d2bf6411a9 100644 --- a/packages/errors/src/errors/CustomErrorBase.ts +++ b/packages/errors/src/errors/CustomErrorBase.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { stringifyError } from '../serialization/error'; import { toError } from './assertion'; /** @@ -42,13 +41,19 @@ export class CustomErrorBase extends Error { readonly cause?: Error | undefined; constructor(message?: string, cause?: Error | unknown) { + const causeError = cause !== undefined ? toError(cause) : undefined; + let fullMessage = message; - if (cause !== undefined) { - const causeStr = stringifyError(cause); + if (causeError !== undefined) { + const causeStr = String(causeError); + const causeMsg = + causeStr !== '[object Object]' + ? causeStr + : `${causeError.name}: ${causeError.message}`; if (fullMessage) { - fullMessage += `; caused by ${causeStr}`; + fullMessage += `; caused by ${causeMsg}`; } else { - fullMessage = `caused by ${causeStr}`; + fullMessage = `caused by ${causeMsg}`; } } @@ -63,6 +68,6 @@ export class CustomErrorBase extends Error { } } - this.cause = cause !== undefined ? toError(cause) : undefined; + this.cause = causeError; } } diff --git a/packages/errors/src/errors/assertion.test.ts b/packages/errors/src/errors/assertion.test.ts index e586c39e54..783a625518 100644 --- a/packages/errors/src/errors/assertion.test.ts +++ b/packages/errors/src/errors/assertion.test.ts @@ -125,17 +125,10 @@ describe('toError', () => { expect(toError(true).message).toBe("unknown error 'true'"); }); - it('should wrap plain objects using JSON when toString is unhelpful', () => { - expect(toError({ name: 'e' }).message).toBe(`unknown error '{"name":"e"}'`); - expect(toError({ message: '' }).message).toBe( - `unknown error '{"message":""}'`, + it('should wrap plain objects', () => { + expect(toError({ name: 'e' }).message).toBe( + "unknown error '[object Object]'", ); - expect(toError({ code: 404, detail: 'missing' }).message).toBe( - `unknown error '{"code":404,"detail":"missing"}'`, - ); - }); - - it('should fall back to [object Object] for empty plain objects', () => { expect(toError({}).message).toBe("unknown error '[object Object]'"); }); @@ -152,10 +145,25 @@ describe('toError', () => { it('should handle symbols', () => { const result = toError(Symbol('test')); expect(result).toBeInstanceOf(Error); - expect(result.message).toBe("unknown error 'Symbol(test)'"); + expect(result.message).toBe("unknown error of type 'symbol'"); }); it('should handle BigInt', () => { expect(toError(BigInt(42)).message).toBe("unknown error '42'"); }); + + it('should not throw for objects with a throwing toString', () => { + const obj = Object.create(null); + const result = toError(obj); + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe("unknown error of type 'object'"); + }); + + it('should not throw for circular objects', () => { + const obj: { self?: unknown } = {}; + obj.self = obj; + const result = toError(obj); + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe("unknown error '[object Object]'"); + }); }); diff --git a/packages/errors/src/errors/assertion.ts b/packages/errors/src/errors/assertion.ts index 90337bfd53..9f5e1fae1a 100644 --- a/packages/errors/src/errors/assertion.ts +++ b/packages/errors/src/errors/assertion.ts @@ -75,8 +75,10 @@ export function assertError(value: unknown): asserts value is ErrorLike { /** * Converts an unknown value to an {@link ErrorLike} object. * - * If the value is already an {@link ErrorLike} object, it is returned as-is. Otherwise, a new - * `Error` is created with the value stringified as the message. + * If the value is already an {@link ErrorLike} object, it is returned as-is. + * If the value is a string, a new `Error` is created with that string as the message. + * For all other values, a new `Error` is created with a message of the form + * `unknown error ''`. * * @public * @param value - an unknown value @@ -89,12 +91,9 @@ export function toError(value: unknown): ErrorLike { if (typeof value === 'string') { return new Error(value) as ErrorLike; } - const str = String(value); - if (str === '[object Object]') { - const json = JSON.stringify(value); - if (json !== '{}') { - return new Error(`unknown error '${json}'`) as ErrorLike; - } + try { + return new Error(`unknown error '${value}'`) as ErrorLike; + } catch { + return new Error(`unknown error of type '${typeof value}'`) as ErrorLike; } - return new Error(`unknown error '${str}'`) as ErrorLike; } diff --git a/plugins/catalog-backend/src/processors/UrlReaderProcessor.ts b/plugins/catalog-backend/src/processors/UrlReaderProcessor.ts index 24ddf6e072..c6c155a859 100644 --- a/plugins/catalog-backend/src/processors/UrlReaderProcessor.ts +++ b/plugins/catalog-backend/src/processors/UrlReaderProcessor.ts @@ -110,10 +110,8 @@ export class UrlReaderProcessor implements CatalogProcessor { emit(processingResult.refresh(`${location.type}:${location.target}`)); } catch (error) { const err = toError(error); - const message = `Unable to read ${location.type}, ${err}`.substring( - 0, - 5000, - ); + const message = + `Unable to read ${location.type}, ${err.message}`.substring(0, 5000); if (err.name === 'NotModifiedError' && cacheItem) { for (const parseResult of cacheItem.value) { emit(parseResult); diff --git a/plugins/catalog-unprocessed-entities/src/components/DeleteEntityConfirmationDialog.tsx b/plugins/catalog-unprocessed-entities/src/components/DeleteEntityConfirmationDialog.tsx index 641f95c4b2..4718491024 100644 --- a/plugins/catalog-unprocessed-entities/src/components/DeleteEntityConfirmationDialog.tsx +++ b/plugins/catalog-unprocessed-entities/src/components/DeleteEntityConfirmationDialog.tsx @@ -19,7 +19,6 @@ import Dialog from '@material-ui/core/Dialog'; import DialogActions from '@material-ui/core/DialogActions'; import DialogTitle from '@material-ui/core/DialogTitle'; import { useState } from 'react'; -import { toError } from '@backstage/errors'; interface DeleteEntityConfirmationProps { open: boolean; @@ -36,8 +35,8 @@ export function DeleteEntityConfirmationDialog( setBusy(true); try { onConfirm(); - } catch (err) { - void toError(err); + } catch { + // ignored } finally { setBusy(false); } diff --git a/plugins/techdocs-node/src/stages/publish/openStackSwift.ts b/plugins/techdocs-node/src/stages/publish/openStackSwift.ts index b718ba6246..f6ca5e9977 100644 --- a/plugins/techdocs-node/src/stages/publish/openStackSwift.ts +++ b/plugins/techdocs-node/src/stages/publish/openStackSwift.ts @@ -222,8 +222,9 @@ export class OpenStackSwiftPublish implements PublisherBase { resolve(techdocsMetadata); } catch (err) { - this.logger.error(toError(err).message); - reject(new Error(toError(err).message)); + const error = toError(err); + this.logger.error(error.message); + reject(error); } } else { reject({