From 400aa2313a6eda45dc21b7e35d89832dc9b0cc6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 28 Mar 2026 16:55:33 +0100 Subject: [PATCH 1/6] Add FetchMiddlewares.clarifyFailures and improve permission error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .changeset/app-defaults-clarify-failures.md | 5 ++ .changeset/clarify-fetch-failures.md | 5 ++ .../frontend-app-api-permission-error.md | 5 ++ packages/app-defaults/src/defaults/apis.ts | 1 + packages/core-app-api/report.api.md | 1 + .../ClarifyFailuresFetchMiddleware.test.ts | 56 +++++++++++++++++++ .../ClarifyFailuresFetchMiddleware.ts | 39 +++++++++++++ .../FetchApi/FetchMiddlewares.ts | 9 +++ .../frontend-app-api/src/wiring/predicates.ts | 31 ++++++---- 9 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 .changeset/app-defaults-clarify-failures.md create mode 100644 .changeset/clarify-fetch-failures.md create mode 100644 .changeset/frontend-app-api-permission-error.md create mode 100644 packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.test.ts create mode 100644 packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts 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 { From 9f1ac047657696d15f3b7657f07ba47a59246237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 28 Mar 2026 17:00:56 +0100 Subject: [PATCH 2/6] Update packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Fredrik Adelöw --- .../FetchApi/ClarifyFailuresFetchMiddleware.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts index 37951294cc..2b1b6553d1 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts @@ -28,9 +28,8 @@ export class ClarifyFailuresFetchMiddleware implements FetchMiddleware { } 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}`, - ); + e.message = `Failed to fetch: ${request.method} ${request.url}`; + throw e; } throw e; } From 18c3dbadd6de20c6dd80aa70e1042177c14c9256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 28 Mar 2026 17:01:07 +0100 Subject: [PATCH 3/6] Update packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Fredrik Adelöw --- .../implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts index 2b1b6553d1..38f6605428 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts @@ -18,7 +18,7 @@ import { FetchMiddleware } from './types'; /** * Replaces the generic "TypeError: Failed to fetch" error with a more - * informative message that includes the target URL. + * informative message that includes the HTTP method and target URL. */ export class ClarifyFailuresFetchMiddleware implements FetchMiddleware { apply(next: typeof fetch): typeof fetch { From cba19b13de674e213003cc0a9940054902563118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 28 Mar 2026 17:04:02 +0100 Subject: [PATCH 4/6] Avoid new Request() in catch to prevent disturbed body errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts method/url directly from the input instead of constructing a new Request in the catch block, which could fail if the body was already consumed. Also adds a test with Request object input. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../ClarifyFailuresFetchMiddleware.test.ts | 12 ++++++++++++ .../FetchApi/ClarifyFailuresFetchMiddleware.ts | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) 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 index 7f6f9581dd..ff00e979cf 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.test.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.test.ts @@ -36,6 +36,18 @@ describe('ClarifyFailuresFetchMiddleware', () => { ); }); + 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); diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts index 38f6605428..cee9b902aa 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts @@ -27,8 +27,18 @@ export class ClarifyFailuresFetchMiddleware implements FetchMiddleware { 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); - e.message = `Failed to fetch: ${request.method} ${request.url}`; + let method: string; + let url: string; + + if (input instanceof Request) { + method = input.method; + url = input.url; + } else { + method = init?.method || 'GET'; + url = String(input); + } + + e.message = `Failed to fetch: ${method} ${url}`; throw e; } throw e; From c38610bd9dcd939a59c03aea03d60e70d618e916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 28 Mar 2026 17:07:00 +0100 Subject: [PATCH 5/6] Address review feedback: add cast rationale, drop redundant assertError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts | 2 ++ packages/frontend-app-api/src/wiring/predicates.ts | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts index cee9b902aa..46985bb244 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts @@ -24,6 +24,8 @@ 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') { diff --git a/packages/frontend-app-api/src/wiring/predicates.ts b/packages/frontend-app-api/src/wiring/predicates.ts index f0a91814ee..4321b869c8 100644 --- a/packages/frontend-app-api/src/wiring/predicates.ts +++ b/packages/frontend-app-api/src/wiring/predicates.ts @@ -24,7 +24,7 @@ import type { EvaluatePermissionRequest, EvaluatePermissionResponse, } from '@backstage/plugin-permission-common'; -import { assertError, ForwardedError } from '@backstage/errors'; +import { ForwardedError } from '@backstage/errors'; export type ExtensionPredicateContext = { featureFlags: string[]; @@ -98,7 +98,6 @@ export function createPredicateContextLoader(options: { (_, i) => responses[i].result === 'ALLOW', ); } catch (error) { - assertError(error); throw new ForwardedError( 'Failed to authorize extension permissions', error, From d19e32ccadbeabf401e1134768957558b140cdb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sun, 29 Mar 2026 10:30:22 +0200 Subject: [PATCH 6/6] Use duck-typing for Request check and make clarification best-effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace instanceof Request with a duck-type isRequestLike helper to avoid cross-realm issues, and wrap the clarification logic in a try/catch so it can never mask the original error. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../ClarifyFailuresFetchMiddleware.ts | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts index 46985bb244..8f63452454 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/ClarifyFailuresFetchMiddleware.ts @@ -29,22 +29,36 @@ export class ClarifyFailuresFetchMiddleware implements FetchMiddleware { return await next(input as any, init); } catch (e) { if (e instanceof TypeError && e.message === 'Failed to fetch') { - let method: string; - let url: string; + try { + let method: string; + let url: string; - if (input instanceof Request) { - method = input.method; - url = input.url; - } else { - method = init?.method || 'GET'; - url = String(input); + 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 } - - e.message = `Failed to fetch: ${method} ${url}`; - throw e; } 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' + ); +}