Add FetchMiddlewares.clarifyFailures and improve permission error handling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
This commit is contained in:
Fredrik Adelöw
2026-03-28 16:55:33 +01:00
parent 7d942709fa
commit 400aa2313a
9 changed files with 141 additions and 11 deletions
@@ -0,0 +1,5 @@
---
'@backstage/app-defaults': patch
---
Added `FetchMiddlewares.clarifyFailures()` to the default fetch API middleware stack.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': minor
---
Added `FetchMiddlewares.clarifyFailures()` which replaces the uninformative "TypeError: Failed to fetch" with a message that includes the request method and URL.
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Wrapped extension permission authorization in a try/catch to surface errors as `ForwardedError` with a clear message.
@@ -113,6 +113,7 @@ export const apis = [
identityApi,
config: configApi,
}),
FetchMiddlewares.clarifyFailures(),
],
});
},
+1
View File
@@ -438,6 +438,7 @@ export interface FetchMiddleware {
// @public
export class FetchMiddlewares {
static clarifyFailures(): FetchMiddleware;
static injectIdentityAuth(options: {
identityApi: IdentityApi;
config?: Config;
@@ -0,0 +1,56 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ClarifyFailuresFetchMiddleware } from './ClarifyFailuresFetchMiddleware';
describe('ClarifyFailuresFetchMiddleware', () => {
it('passes through successful responses', async () => {
const response = new Response('ok');
const inner = jest.fn().mockResolvedValue(response);
const middleware = new ClarifyFailuresFetchMiddleware();
const result = await middleware.apply(inner)('https://example.com/api');
expect(result).toBe(response);
expect(inner).toHaveBeenCalled();
});
it('replaces "Failed to fetch" TypeError with one that includes the URL', async () => {
const inner = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'));
const middleware = new ClarifyFailuresFetchMiddleware();
await expect(
middleware.apply(inner)('https://example.com/api/catalog'),
).rejects.toThrow(
new TypeError('Failed to fetch: GET https://example.com/api/catalog'),
);
});
it('does not modify other TypeErrors', async () => {
const error = new TypeError('some other error');
const inner = jest.fn().mockRejectedValue(error);
const middleware = new ClarifyFailuresFetchMiddleware();
await expect(
middleware.apply(inner)('https://example.com/api'),
).rejects.toThrow(error);
});
it('does not modify non-TypeError errors', async () => {
const error = new Error('Failed to fetch');
const inner = jest.fn().mockRejectedValue(error);
const middleware = new ClarifyFailuresFetchMiddleware();
await expect(
middleware.apply(inner)('https://example.com/api'),
).rejects.toThrow(error);
});
});
@@ -0,0 +1,39 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FetchMiddleware } from './types';
/**
* Replaces the generic "TypeError: Failed to fetch" error with a more
* informative message that includes the target URL.
*/
export class ClarifyFailuresFetchMiddleware implements FetchMiddleware {
apply(next: typeof fetch): typeof fetch {
return async (input, init) => {
try {
return await next(input as any, init);
} catch (e) {
if (e instanceof TypeError && e.message === 'Failed to fetch') {
const request = new Request(input as any, init);
throw new TypeError(
`Failed to fetch: ${request.method} ${request.url}`,
);
}
throw e;
}
};
}
}
@@ -16,6 +16,7 @@
import { Config } from '@backstage/config';
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { ClarifyFailuresFetchMiddleware } from './ClarifyFailuresFetchMiddleware';
import { IdentityAuthInjectorFetchMiddleware } from './IdentityAuthInjectorFetchMiddleware';
import { PluginProtocolResolverFetchMiddleware } from './PluginProtocolResolverFetchMiddleware';
import { FetchMiddleware } from './types';
@@ -74,5 +75,13 @@ export class FetchMiddlewares {
return IdentityAuthInjectorFetchMiddleware.create(options);
}
/**
* Replaces the generic "TypeError: Failed to fetch" with a more informative
* message that includes some request details to ease debugging.
*/
static clarifyFailures(): FetchMiddleware {
return new ClarifyFailuresFetchMiddleware();
}
private constructor() {}
}
@@ -24,6 +24,7 @@ import type {
EvaluatePermissionRequest,
EvaluatePermissionResponse,
} from '@backstage/plugin-permission-common';
import { assertError, ForwardedError } from '@backstage/errors';
export type ExtensionPredicateContext = {
featureFlags: string[];
@@ -84,17 +85,25 @@ export function createPredicateContextLoader(options: {
let allowedPermissions: string[] = [];
const permissionApi = options.apis.get(localPermissionApiRef);
if (permissionApi) {
const permissionNames = options.predicateReferences.permissions;
const responses = await Promise.all(
permissionNames.map(name =>
permissionApi.authorize({
permission: { name, type: 'basic', attributes: {} },
}),
),
);
allowedPermissions = permissionNames.filter(
(_, i) => responses[i].result === 'ALLOW',
);
try {
const permissionNames = options.predicateReferences.permissions;
const responses = await Promise.all(
permissionNames.map(name =>
permissionApi.authorize({
permission: { name, type: 'basic', attributes: {} },
}),
),
);
allowedPermissions = permissionNames.filter(
(_, i) => responses[i].result === 'ALLOW',
);
} catch (error) {
assertError(error);
throw new ForwardedError(
'Failed to authorize extension permissions',
error,
);
}
}
return {