diff --git a/.changeset/app-defaults-clarify-failures.md b/.changeset/app-defaults-clarify-failures.md new file mode 100644 index 0000000000..c6321a3a3a --- /dev/null +++ b/.changeset/app-defaults-clarify-failures.md @@ -0,0 +1,5 @@ +--- +'@backstage/app-defaults': patch +--- + +Added `FetchMiddlewares.clarifyFailures()` to the default fetch API middleware stack. diff --git a/.changeset/clarify-fetch-failures.md b/.changeset/clarify-fetch-failures.md new file mode 100644 index 0000000000..245f790f64 --- /dev/null +++ b/.changeset/clarify-fetch-failures.md @@ -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. diff --git a/.changeset/frontend-app-api-permission-error.md b/.changeset/frontend-app-api-permission-error.md new file mode 100644 index 0000000000..851304fcf2 --- /dev/null +++ b/.changeset/frontend-app-api-permission-error.md @@ -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. diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts index 97989dff91..b591154246 100644 --- a/packages/app-defaults/src/defaults/apis.ts +++ b/packages/app-defaults/src/defaults/apis.ts @@ -113,6 +113,7 @@ export const apis = [ identityApi, config: configApi, }), + FetchMiddlewares.clarifyFailures(), ], }); }, diff --git a/packages/core-app-api/report.api.md b/packages/core-app-api/report.api.md index 35b16ad475..4d141b2fa9 100644 --- a/packages/core-app-api/report.api.md +++ b/packages/core-app-api/report.api.md @@ -438,6 +438,7 @@ export interface FetchMiddleware { // @public export class FetchMiddlewares { + static clarifyFailures(): FetchMiddleware; static injectIdentityAuth(options: { identityApi: IdentityApi; config?: Config; diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.test.ts b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.test.ts new file mode 100644 index 0000000000..7f6f9581dd --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.test.ts @@ -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); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts new file mode 100644 index 0000000000..37951294cc --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts @@ -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; + } + }; + } +} diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts b/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts index 8073571a04..a6ef0b6506 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts @@ -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() {} } diff --git a/packages/frontend-app-api/src/wiring/predicates.ts b/packages/frontend-app-api/src/wiring/predicates.ts index 819036fc17..f0a91814ee 100644 --- a/packages/frontend-app-api/src/wiring/predicates.ts +++ b/packages/frontend-app-api/src/wiring/predicates.ts @@ -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 {