Merge pull request #33658 from backstage/freben/clarify-fetch-failures

Add FetchMiddlewares.clarifyFailures and improve permission error handling
This commit is contained in:
Fredrik Adelöw
2026-03-29 12:51:04 +02:00
committed by GitHub
9 changed files with 177 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,68 @@
/*
* 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('handles Request object input without constructing a new Request', async () => {
const inner = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'));
const middleware = new ClarifyFailuresFetchMiddleware();
const request = new Request('https://example.com/api/data', {
method: 'POST',
body: JSON.stringify({ key: 'value' }),
});
await expect(middleware.apply(inner)(request)).rejects.toThrow(
new TypeError('Failed to fetch: POST https://example.com/api/data'),
);
});
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,64 @@
/*
* 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 HTTP method and target URL.
*/
export class ClarifyFailuresFetchMiddleware implements FetchMiddleware {
apply(next: typeof fetch): typeof fetch {
return async (input, init) => {
try {
// NOTE: The "as any" cast is because of subtle undici type differences
// that happened in a node types bump. Immaterial at runtime.
return await next(input as any, init);
} catch (e) {
if (e instanceof TypeError && e.message === 'Failed to fetch') {
try {
let method: string;
let url: string;
if (isRequestLike(input)) {
method = input.method;
url = input.url;
} else {
method = init?.method || 'GET';
url = String(input);
}
e.message = `Failed to fetch: ${method} ${url}`;
} catch {
// intentionally ignored - clarification is best-effort
}
}
throw e;
}
};
}
}
function isRequestLike(input: unknown): input is Request {
return (
input !== null &&
typeof input === 'object' &&
'method' in input &&
'url' in input &&
typeof input.method === 'string' &&
typeof input.url === 'string'
);
}
@@ -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 { ForwardedError } from '@backstage/errors';
export type ExtensionPredicateContext = {
featureFlags: string[];
@@ -84,17 +85,24 @@ 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) {
throw new ForwardedError(
'Failed to authorize extension permissions',
error,
);
}
}
return {