feat(backend-app-api): attribute registration errors to plugins/modules (#33029)

* feat(backend-app-api): attribute registration errors to plugins/modules

Signed-off-by: Carl-Erik Bergström <cbergstrom@spotify.com>
This commit is contained in:
Carl-Erik Bergström
2026-03-03 19:12:44 +01:00
committed by GitHub
parent 95fecdf617
commit 545557a928
3 changed files with 332 additions and 78 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': minor
---
Registration errors should be forwarded as BackendStartupResult
@@ -899,7 +899,7 @@ describe('BackendInitializer', () => {
});
it('should reject duplicate plugins', async () => {
const init = new BackendInitializer([]);
const init = new BackendInitializer(baseFactories);
init.add(
createBackendPlugin({
pluginId: 'test',
@@ -922,13 +922,24 @@ describe('BackendInitializer', () => {
},
}),
);
await expect(init.start()).rejects.toThrow(
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
const plugin = err?.result.plugins.find(p => p.pluginId === 'test');
expect(plugin?.failure?.error.message).toBe(
"Plugin 'test' is already registered",
);
expect(plugin?.failure?.allowed).toBe(false);
});
it('should reject duplicate modules', async () => {
const init = new BackendInitializer([]);
const init = new BackendInitializer(baseFactories);
init.add(testPlugin);
init.add(
createBackendModule({
@@ -954,8 +965,202 @@ describe('BackendInitializer', () => {
},
}),
);
await expect(init.start()).rejects.toThrow(
"Module 'mod' for plugin 'test' is already registered",
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
const plugin = err?.result.plugins.find(p => p.pluginId === 'test');
const modResult = plugin?.modules.find(
m =>
m.failure?.error.message ===
"Module 'mod' for plugin 'test' is already registered",
);
expect(modResult).toBeDefined();
expect(modResult?.failure?.allowed).toBe(false);
});
it('should allow other plugins to continue when one has a registration error', async () => {
const pluginAInit = jest.fn(async () => {});
const init = new BackendInitializer(baseFactories);
init.add(
createBackendPlugin({
pluginId: 'plugin-a',
register(reg) {
reg.registerInit({
deps: {},
init: pluginAInit,
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'plugin-b',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'plugin-b',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
// plugin-a should have started successfully
expect(pluginAInit).toHaveBeenCalled();
const pluginA = err?.result.plugins.find(p => p.pluginId === 'plugin-a');
expect(pluginA?.failure).toBeUndefined();
// plugin-b should have a registration failure
const pluginB = err?.result.plugins.find(p => p.pluginId === 'plugin-b');
expect(pluginB?.failure?.error.message).toBe(
"Plugin 'plugin-b' is already registered",
);
});
it('should permit registration errors for plugins with onPluginBootFailure: continue', async () => {
const init = new BackendInitializer([
...baseFactories,
mockServices.rootConfig.factory({
data: {
backend: {
startup: {
plugins: { test: { onPluginBootFailure: 'continue' } },
},
},
},
}),
]);
init.add(
createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
const { result } = await init.start();
const plugin = result.plugins.find(p => p.pluginId === 'test');
expect(plugin?.failure?.error.message).toBe(
"Plugin 'test' is already registered",
);
expect(plugin?.failure?.allowed).toBe(true);
});
it('should attribute duplicate extension point errors to the correct plugin', async () => {
const extensionPoint = createExtensionPoint<string>({ id: 'shared-ext' });
const init = new BackendInitializer(baseFactories);
init.add(
createBackendPlugin({
pluginId: 'plugin-a',
register(reg) {
reg.registerExtensionPoint(extensionPoint, 'a');
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
init.add(
createBackendPlugin({
pluginId: 'plugin-b',
register(reg) {
reg.registerExtensionPoint(extensionPoint, 'b');
reg.registerInit({
deps: {},
async init() {},
});
},
}),
);
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
// plugin-a should succeed (registered first)
const pluginA = err?.result.plugins.find(p => p.pluginId === 'plugin-a');
expect(pluginA?.failure).toBeUndefined();
// plugin-b should fail due to duplicate extension point
const pluginB = err?.result.plugins.find(p => p.pluginId === 'plugin-b');
expect(pluginB?.failure?.error.message).toBe(
"ExtensionPoint with ID 'shared-ext' is already registered",
);
});
it('should attribute invalid registration type errors to plugin when pluginId is available', async () => {
const init = new BackendInitializer(baseFactories);
// Create a fake registration with an invalid type but valid pluginId
const fakeFeature = {
$$type: '@backstage/BackendFeature' as const,
version: 'v1' as const,
featureType: 'registrations' as const,
getRegistrations: () => [
{
type: 'invalid-type',
pluginId: 'broken-plugin',
init: { deps: {}, func: async () => {} },
extensionPoints: [],
},
],
};
init.add(fakeFeature as any);
const err = await init.start().then(
() => {
throw new Error('Expected BackendStartupError to be thrown');
},
(e: BackendStartupError) => e,
);
expect(err).toBeInstanceOf(BackendStartupError);
const plugin = err?.result.plugins.find(
p => p.pluginId === 'broken-plugin',
);
expect(plugin?.failure?.error.message).toBe(
"Invalid registration type 'invalid-type'",
);
});
@@ -310,77 +310,6 @@ export class BackendInitializer {
// Initialize all root scoped services
await this.#serviceRegistry.initializeEagerServicesWithScope('root');
const pluginInits = new Map<string, BackendRegisterInit>();
const moduleInits = new Map<string, Map<string, BackendRegisterInit>>();
// Enumerate all registrations
for (const feature of this.#registrations) {
for (const r of feature.getRegistrations()) {
const provides = new Set<ExtensionPoint<unknown>>();
if (r.type === 'plugin' || r.type === 'module') {
// Handle v1 format: Array<readonly [ExtensionPoint<unknown>, unknown]>
for (const [extRef, extImpl] of r.extensionPoints) {
if (this.#extensionPoints.has(extRef.id)) {
throw new Error(
`ExtensionPoint with ID '${extRef.id}' is already registered`,
);
}
this.#extensionPoints.set(extRef.id, {
pluginId: r.pluginId,
factory: () => extImpl,
});
provides.add(extRef);
}
} else if (r.type === 'plugin-v1.1' || r.type === 'module-v1.1') {
// Handle v1.1 format: Array<ExtensionPointRegistration>
for (const extReg of r.extensionPoints) {
if (this.#extensionPoints.has(extReg.extensionPoint.id)) {
throw new Error(
`ExtensionPoint with ID '${extReg.extensionPoint.id}' is already registered`,
);
}
this.#extensionPoints.set(extReg.extensionPoint.id, {
pluginId: r.pluginId,
factory: extReg.factory,
});
provides.add(extReg.extensionPoint);
}
}
if (r.type === 'plugin' || r.type === 'plugin-v1.1') {
if (pluginInits.has(r.pluginId)) {
throw new Error(`Plugin '${r.pluginId}' is already registered`);
}
pluginInits.set(r.pluginId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else if (r.type === 'module' || r.type === 'module-v1.1') {
let modules = moduleInits.get(r.pluginId);
if (!modules) {
modules = new Map();
moduleInits.set(r.pluginId, modules);
}
if (modules.has(r.moduleId)) {
throw new Error(
`Module '${r.moduleId}' for plugin '${r.pluginId}' is already registered`,
);
}
modules.set(r.moduleId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else {
throw new Error(`Invalid registration type '${(r as any).type}'`);
}
}
}
const pluginIds = [...pluginInits.keys()];
const rootConfig = await this.#serviceRegistry.get(
coreServices.rootConfig,
'root',
@@ -390,15 +319,32 @@ export class BackendInitializer {
'root',
);
const allRegistrations = this.#registrations.flatMap(f =>
f.getRegistrations(),
);
const allPluginIds = [
...new Set(
allRegistrations.flatMap(r =>
'pluginId' in r && typeof r.pluginId === 'string' ? [r.pluginId] : [],
),
),
];
const resultCollector = createInitializationResultCollector({
pluginIds,
pluginIds: allPluginIds,
logger: rootLogger,
allowBootFailurePredicate: createAllowBootFailurePredicate(rootConfig),
});
const { pluginInits, moduleInits } = this.#enumerateRegistrations(
allRegistrations,
resultCollector,
);
// All plugins are initialized in parallel
await Promise.all(
pluginIds.map(async pluginId => {
[...pluginInits.keys()].map(async pluginId => {
try {
// Initialize all eager services
await this.#serviceRegistry.initializeEagerServicesWithScope(
@@ -491,6 +437,104 @@ export class BackendInitializer {
return { result };
}
#enumerateRegistrations(
allRegistrations: ReturnType<
InternalBackendRegistrations['getRegistrations']
>,
resultCollector: ReturnType<typeof createInitializationResultCollector>,
): {
pluginInits: Map<string, BackendRegisterInit>;
moduleInits: Map<string, Map<string, BackendRegisterInit>>;
} {
const pluginInits = new Map<string, BackendRegisterInit>();
const moduleInits = new Map<string, Map<string, BackendRegisterInit>>();
for (const r of allRegistrations) {
const addedExtensionPointIds: string[] = [];
try {
const provides = new Set<ExtensionPoint<unknown>>();
if (r.type === 'plugin' || r.type === 'module') {
// Handle v1 format: Array<readonly [ExtensionPoint<unknown>, unknown]>
for (const [extRef, extImpl] of r.extensionPoints) {
if (this.#extensionPoints.has(extRef.id)) {
throw new Error(
`ExtensionPoint with ID '${extRef.id}' is already registered`,
);
}
this.#extensionPoints.set(extRef.id, {
pluginId: r.pluginId,
factory: () => extImpl,
});
addedExtensionPointIds.push(extRef.id);
provides.add(extRef);
}
} else if (r.type === 'plugin-v1.1' || r.type === 'module-v1.1') {
// Handle v1.1 format: Array<ExtensionPointRegistration>
for (const extReg of r.extensionPoints) {
if (this.#extensionPoints.has(extReg.extensionPoint.id)) {
throw new Error(
`ExtensionPoint with ID '${extReg.extensionPoint.id}' is already registered`,
);
}
this.#extensionPoints.set(extReg.extensionPoint.id, {
pluginId: r.pluginId,
factory: extReg.factory,
});
addedExtensionPointIds.push(extReg.extensionPoint.id);
provides.add(extReg.extensionPoint);
}
}
if (r.type === 'plugin' || r.type === 'plugin-v1.1') {
if (pluginInits.has(r.pluginId)) {
throw new Error(`Plugin '${r.pluginId}' is already registered`);
}
pluginInits.set(r.pluginId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else if (r.type === 'module' || r.type === 'module-v1.1') {
let modules = moduleInits.get(r.pluginId);
if (!modules) {
modules = new Map();
moduleInits.set(r.pluginId, modules);
}
if (modules.has(r.moduleId)) {
throw new Error(
`Module '${r.moduleId}' for plugin '${r.pluginId}' is already registered`,
);
}
modules.set(r.moduleId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init,
});
} else {
throw new Error(`Invalid registration type '${(r as any).type}'`);
}
} catch (error: unknown) {
assertError(error);
// Clean up partially registered extension points
for (const id of addedExtensionPointIds) {
this.#extensionPoints.delete(id);
}
if ('pluginId' in r && 'moduleId' in r) {
resultCollector.onPluginModuleResult(r.pluginId, r.moduleId, error);
} else if ('pluginId' in r) {
pluginInits.delete(r.pluginId);
moduleInits.delete(r.pluginId);
resultCollector.onPluginResult(r.pluginId, error);
} else {
throw error;
}
}
}
return { pluginInits, moduleInits };
}
// It's fine to call .stop() multiple times, which for example can happen with manual stop + process exit
async stop(): Promise<void> {
instanceRegistry.unregister(this);