Address review feedback: simplify toError and add changeset

- Remove JSON.stringify fallback from toError, use the same unknown
  error messaging as stringifyError for all non-string/non-error values
- Add try/catch to protect against values that throw on string
  conversion (e.g. null-prototype objects, symbols)
- Fix no-op `void toError(err)` in DeleteEntityConfirmationDialog
- Fix `${err}` producing [object Object] in UrlReaderProcessor
- Fix double toError call in openStackSwift
- Update JSDoc to accurately describe the behavior
- Add tests for throwing toString and circular objects
- Add changeset for all refactored packages

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-04-03 11:02:11 +02:00
parent b2319ffe45
commit 482ceed6d2
8 changed files with 76 additions and 36 deletions
+1 -1
View File
@@ -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 '<stringified>'`. Non-error causes passed to `CustomErrorBase` are now converted and stored using `toError` rather than discarded.
+30
View File
@@ -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.
+11 -6
View File
@@ -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;
}
}
+19 -11
View File
@@ -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]'");
});
});
+8 -9
View File
@@ -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 '<stringified>'`.
*
* @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;
}
@@ -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);
@@ -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);
}
@@ -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({