This is an automated email from the ASF dual-hosted git repository.

rahulvats pushed a commit to branch py-client-sync
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit 423837e7e20859888b449fb7979898892989de4a
Author: Abhishek Mishra <[email protected]>
AuthorDate: Tue Mar 24 22:34:58 2026 +0530

    UI: Show clear permission toast for 403 errors on user actions (#61588)
---
 .../airflow/ui/public/i18n/locales/en/common.json  | 10 ++-
 .../ui/src/components/ui/Toaster/createToaster.ts  |  8 +-
 .../ui/src/components/ui/createErrorToaster.ts     | 40 ----------
 .../src/airflow/ui/src/queries/useClearRun.ts      | 17 ++--
 .../ui/src/queries/useClearTaskInstances.ts        |  9 +++
 .../src/airflow/ui/src/queries/useDagParsing.ts    | 12 +--
 .../airflow/ui/src/queries/useDeleteConnection.ts  | 18 +++--
 .../src/airflow/ui/src/queries/useDeleteDag.ts     | 18 +++--
 .../src/airflow/ui/src/queries/useDeleteDagRun.ts  | 16 ++--
 .../src/airflow/ui/src/queries/useDeletePool.ts    | 18 +++--
 .../ui/src/queries/useDeleteTaskInstance.ts        | 16 ++--
 .../airflow/ui/src/queries/useDeleteVariable.ts    | 18 +++--
 .../src/airflow/ui/src/queries/usePatchDagRun.ts   | 19 ++---
 .../airflow/ui/src/queries/usePatchTaskInstance.ts | 19 ++---
 .../src/airflow/ui/src/queries/useTogglePause.ts   | 10 +--
 .../src/airflow/ui/src/queries/useTrigger.ts       | 11 +--
 .../airflow/ui/src/queries/useUpdateHITLDetail.ts  |  9 +--
 airflow-core/src/airflow/ui/src/queryClient.ts     | 30 +++++++-
 .../src/airflow/ui/src/utils/errorHandling.ts      | 90 ++++++++++++++++++++++
 airflow-core/src/airflow/ui/src/utils/index.ts     |  1 +
 20 files changed, 248 insertions(+), 141 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 4176a915062..0bbea8fc42d 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -103,6 +103,12 @@
     "notFound": "Page Not Found",
     "title": "Error"
   },
+  "errors": {
+    "forbidden": {
+      "description": "You do not have permission to perform this action.",
+      "title": "Access Denied"
+    }
+  },
   "expand": {
     "collapse": "Collapse",
     "expand": "Expand",
@@ -314,10 +320,6 @@
         "title": "Delete {{resourceName}} Request Submitted"
       }
     },
-    "forbidden": {
-      "description": "You do not have permission to perform this action.",
-      "title": "Access Denied"
-    },
     "import": {
       "error": "Import {{resourceName}} Request Failed",
       "success": {
diff --git 
a/airflow-core/src/airflow/ui/src/components/ui/Toaster/createToaster.ts 
b/airflow-core/src/airflow/ui/src/components/ui/Toaster/createToaster.ts
index 9fa1c393c77..b7c78202bdf 100644
--- a/airflow-core/src/airflow/ui/src/components/ui/Toaster/createToaster.ts
+++ b/airflow-core/src/airflow/ui/src/components/ui/Toaster/createToaster.ts
@@ -18,7 +18,13 @@
  */
 import { createToaster } from "@chakra-ui/react";
 
-export const toaster = createToaster({
+const baseToaster = createToaster({
   pauseOnPageIdle: true,
   placement: "bottom-end",
 });
+
+// Extend toaster with isActive alias for consistency
+export const toaster = {
+  ...baseToaster,
+  isActive: (id: string) => baseToaster.isVisible(id),
+};
diff --git 
a/airflow-core/src/airflow/ui/src/components/ui/createErrorToaster.ts 
b/airflow-core/src/airflow/ui/src/components/ui/createErrorToaster.ts
deleted file mode 100644
index c59cabbf3b6..00000000000
--- a/airflow-core/src/airflow/ui/src/components/ui/createErrorToaster.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/*!
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you 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 type { TFunction } from "i18next";
-
-import type { ExpandedApiError } from "src/components/ErrorAlert";
-import { toaster } from "src/components/ui";
-
-type ErrorToastMessage = {
-  readonly description: string;
-  readonly title: string;
-};
-
-export const createErrorToaster =
-  (translate: TFunction, fallbackMessage: ErrorToastMessage) => (error: 
unknown) => {
-    const isForbidden = (error as ExpandedApiError).status === 403;
-
-    toaster.create({
-      description: isForbidden
-        ? translate("toaster.forbidden.description", { ns: "common" })
-        : fallbackMessage.description,
-      title: isForbidden ? translate("toaster.forbidden.title", { ns: "common" 
}) : fallbackMessage.title,
-      type: "error",
-    });
-  };
diff --git a/airflow-core/src/airflow/ui/src/queries/useClearRun.ts 
b/airflow-core/src/airflow/ui/src/queries/useClearRun.ts
index af234cc39fc..1749955525e 100644
--- a/airflow-core/src/airflow/ui/src/queries/useClearRun.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useClearRun.ts
@@ -28,7 +28,7 @@ import {
   useTaskInstanceServiceGetTaskInstancesKey,
   UseGridServiceGetGridRunsKeyFn,
 } from "openapi/queries";
-import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
 
 import { useClearDagRunDryRunKey } from "./useClearDagRunDryRun";
 
@@ -44,12 +44,15 @@ export const useClearDagRun = ({
   const queryClient = useQueryClient();
   const { t: translate } = useTranslation("dags");
 
-  const onError = (error: Error) => {
-    toaster.create({
-      description: error.message,
-      title: translate("dags:runAndTaskActions.clear.error", { type: 
translate("dagRun_one") }),
-      type: "error",
-    });
+  const onError = (error: unknown) => {
+    createErrorToaster(
+      error,
+      {
+        params: { type: translate("dagRun_one") },
+        titleKey: "dags:runAndTaskActions.clear.error",
+      },
+      translate,
+    );
   };
 
   const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useClearTaskInstances.ts 
b/airflow-core/src/airflow/ui/src/queries/useClearTaskInstances.ts
index e8d13ad000e..b3dc26c9de7 100644
--- a/airflow-core/src/airflow/ui/src/queries/useClearTaskInstances.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useClearTaskInstances.ts
@@ -50,6 +50,15 @@ export const useClearTaskInstances = ({
     let detail: string;
     let description: string;
 
+    // Get status from error
+    const status =
+      (error as { status?: number }).status ?? (error as { response?: { 
status?: number } }).response?.status;
+
+    // Skip 403 errors as they are handled by MutationCache
+    if (status === 403) {
+      return;
+    }
+
     // Narrow the type safely
     if (typeof error === "object" && error !== null) {
       const apiError = error as ApiError;
diff --git a/airflow-core/src/airflow/ui/src/queries/useDagParsing.ts 
b/airflow-core/src/airflow/ui/src/queries/useDagParsing.ts
index 2fc8c43c0e3..8557c271587 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDagParsing.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDagParsing.ts
@@ -25,15 +25,15 @@ import {
   UseDagSourceServiceGetDagSourceKeyFn,
 } from "openapi/queries";
 import { toaster } from "src/components/ui";
-import { createErrorToaster } from "src/components/ui/createErrorToaster";
+import { createErrorToaster } from "src/utils";
 
 export const useDagParsing = ({ dagId }: { readonly dagId: string }) => {
   const queryClient = useQueryClient();
-  const { t: translate } = useTranslation(["dag", "common"]);
-  const onError = createErrorToaster(translate, {
-    description: translate("parse.toaster.error.description"),
-    title: translate("parse.toaster.error.title"),
-  });
+  const { t: translate } = useTranslation("dag");
+
+  const onError = (error: unknown) => {
+    createErrorToaster(error, { titleKey: "dag:parse.toaster.error.title" }, 
translate);
+  };
 
   const onSuccess = async () => {
     await queryClient.invalidateQueries({
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteConnection.ts 
b/airflow-core/src/airflow/ui/src/queries/useDeleteConnection.ts
index 51e27589fc0..2b54384f8a3 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteConnection.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteConnection.ts
@@ -21,19 +21,21 @@ import { useTranslation } from "react-i18next";
 
 import { useConnectionServiceDeleteConnection, 
useConnectionServiceGetConnectionsKey } from "openapi/queries";
 import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
 
 export const useDeleteConnection = ({ onSuccessConfirm }: { onSuccessConfirm: 
() => void }) => {
   const queryClient = useQueryClient();
   const { t: translate } = useTranslation(["admin", "common"]);
 
-  const onError = (error: Error) => {
-    toaster.create({
-      description: error.message,
-      title: translate("common:toaster.delete.error", {
-        resourceName: translate("admin:connections.connection_one"),
-      }),
-      type: "error",
-    });
+  const onError = (error: unknown) => {
+    createErrorToaster(
+      error,
+      {
+        params: { resourceName: translate("admin:connections.connection_one") 
},
+        titleKey: "common:toaster.delete.error",
+      },
+      translate,
+    );
   };
 
   const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteDag.ts 
b/airflow-core/src/airflow/ui/src/queries/useDeleteDag.ts
index bdd61379a2a..28518dd01ef 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteDag.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteDag.ts
@@ -22,6 +22,7 @@ import { useTranslation } from "react-i18next";
 import { useDagServiceDeleteDag, useDagServiceGetDagsUiKey } from 
"openapi/queries";
 import { useDagServiceGetDagKey } from "openapi/queries";
 import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
 
 export const useDeleteDag = ({
   dagId,
@@ -33,14 +34,15 @@ export const useDeleteDag = ({
   const queryClient = useQueryClient();
   const { t: translate } = useTranslation();
 
-  const onError = (error: Error) => {
-    toaster.create({
-      description: error.message,
-      title: translate("toaster.delete.error", {
-        resourceName: translate("dag_one"),
-      }),
-      type: "error",
-    });
+  const onError = (error: unknown) => {
+    createErrorToaster(
+      error,
+      {
+        params: { resourceName: translate("dag_one") },
+        titleKey: "toaster.delete.error",
+      },
+      translate,
+    );
   };
 
   const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts 
b/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
index c5c75320551..91de2ba4f75 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
@@ -27,6 +27,7 @@ import {
   useTaskInstanceServiceGetHitlDetailsKey,
 } from "openapi/queries";
 import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
 
 type DeleteDagRunParams = {
   dagId: string;
@@ -38,12 +39,15 @@ export const useDeleteDagRun = ({ dagId, dagRunId, 
onSuccessConfirm }: DeleteDag
   const { t: translate } = useTranslation();
   const queryClient = useQueryClient();
 
-  const onError = (error: Error) => {
-    toaster.create({
-      description: error.message,
-      title: translate("dags:runAndTaskActions.delete.error", { type: 
translate("dagRun_one") }),
-      type: "error",
-    });
+  const onError = (error: unknown) => {
+    createErrorToaster(
+      error,
+      {
+        params: { type: translate("dagRun_one") },
+        titleKey: "dags:runAndTaskActions.delete.error",
+      },
+      translate,
+    );
   };
 
   const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeletePool.ts 
b/airflow-core/src/airflow/ui/src/queries/useDeletePool.ts
index 87af3f9f7e2..7ce3857846a 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeletePool.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeletePool.ts
@@ -21,19 +21,21 @@ import { useTranslation } from "react-i18next";
 
 import { usePoolServiceDeletePool, usePoolServiceGetPoolsKey } from 
"openapi/queries";
 import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
 
 export const useDeletePool = ({ onSuccessConfirm }: { onSuccessConfirm: () => 
void }) => {
   const queryClient = useQueryClient();
   const { t: translate } = useTranslation(["common", "admin"]);
 
-  const onError = (error: Error) => {
-    toaster.create({
-      description: error.message,
-      title: translate("toaster.delete.error", {
-        resourceName: translate("admin:pools.pool_one"),
-      }),
-      type: "error",
-    });
+  const onError = (error: unknown) => {
+    createErrorToaster(
+      error,
+      {
+        params: { resourceName: translate("admin:pools.pool_one") },
+        titleKey: "toaster.delete.error",
+      },
+      translate,
+    );
   };
 
   const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts 
b/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
index 0d7e8f266c3..cdf07e49056 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
@@ -28,6 +28,7 @@ import {
   useTaskInstanceServiceGetHitlDetailsKey,
 } from "openapi/queries";
 import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
 
 type DeleteTaskInstanceParams = {
   dagId: string;
@@ -47,12 +48,15 @@ export const useDeleteTaskInstance = ({
   const queryClient = useQueryClient();
   const { t: translate } = useTranslation(["common", "dags"]);
 
-  const onError = (error: Error) => {
-    toaster.create({
-      description: error.message,
-      title: translate("dags:runAndTaskActions.delete.error", { type: 
translate("taskInstance_one") }),
-      type: "error",
-    });
+  const onError = (error: unknown) => {
+    createErrorToaster(
+      error,
+      {
+        params: { type: translate("taskInstance_one") },
+        titleKey: "dags:runAndTaskActions.delete.error",
+      },
+      translate,
+    );
   };
 
   const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteVariable.ts 
b/airflow-core/src/airflow/ui/src/queries/useDeleteVariable.ts
index a82544cb20f..9aee06d7f05 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteVariable.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteVariable.ts
@@ -21,19 +21,21 @@ import { useTranslation } from "react-i18next";
 
 import { useVariableServiceDeleteVariable, useVariableServiceGetVariablesKey } 
from "openapi/queries";
 import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
 
 export const useDeleteVariable = ({ onSuccessConfirm }: { onSuccessConfirm: () 
=> void }) => {
   const queryClient = useQueryClient();
   const { t: translate } = useTranslation(["common", "admin"]);
 
-  const onError = (error: Error) => {
-    toaster.create({
-      description: error.message,
-      title: translate("toaster.delete.error", {
-        resourceName: translate("admin:variables.variable_one"),
-      }),
-      type: "error",
-    });
+  const onError = (error: unknown) => {
+    createErrorToaster(
+      error,
+      {
+        params: { resourceName: translate("admin:variables.variable_one") },
+        titleKey: "toaster.delete.error",
+      },
+      translate,
+    );
   };
 
   const onSuccess = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/usePatchDagRun.ts 
b/airflow-core/src/airflow/ui/src/queries/usePatchDagRun.ts
index a49fcba8592..0c46147a7b4 100644
--- a/airflow-core/src/airflow/ui/src/queries/usePatchDagRun.ts
+++ b/airflow-core/src/airflow/ui/src/queries/usePatchDagRun.ts
@@ -26,7 +26,7 @@ import {
   useTaskInstanceServiceGetTaskInstancesKey,
   UseGridServiceGetGridRunsKeyFn,
 } from "openapi/queries";
-import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
 
 import { useClearDagRunDryRunKey } from "./useClearDagRunDryRun";
 
@@ -42,14 +42,15 @@ export const usePatchDagRun = ({
   const queryClient = useQueryClient();
   const { t: translate } = useTranslation();
 
-  const onError = (error: Error) => {
-    toaster.create({
-      description: error.message,
-      title: translate("toaster.update.error", {
-        resourceName: translate("dagRun_one"),
-      }),
-      type: "error",
-    });
+  const onError = (error: unknown) => {
+    createErrorToaster(
+      error,
+      {
+        params: { resourceName: translate("dagRun_one") },
+        titleKey: "toaster.update.error",
+      },
+      translate,
+    );
   };
 
   const onSuccessFn = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts 
b/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts
index b94fadf8e13..402e8d63540 100644
--- a/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts
+++ b/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts
@@ -26,7 +26,7 @@ import {
   useTaskInstanceServicePatchTaskInstance,
   UseGridServiceGetGridRunsKeyFn,
 } from "openapi/queries";
-import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
 
 import { useClearTaskInstancesDryRunKey } from "./useClearTaskInstancesDryRun";
 import { usePatchTaskInstanceDryRunKey } from "./usePatchTaskInstanceDryRun";
@@ -47,14 +47,15 @@ export const usePatchTaskInstance = ({
   const queryClient = useQueryClient();
   const { t: translate } = useTranslation();
 
-  const onError = (error: Error) => {
-    toaster.create({
-      description: error.message,
-      title: translate("toaster.update.error", {
-        resourceName: translate("taskInstance_one"),
-      }),
-      type: "error",
-    });
+  const onError = (error: unknown) => {
+    createErrorToaster(
+      error,
+      {
+        params: { resourceName: translate("taskInstance_one") },
+        titleKey: "toaster.update.error",
+      },
+      translate,
+    );
   };
 
   const onSuccessFn = async () => {
diff --git a/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts 
b/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts
index c19b5124586..04a7c510acb 100644
--- a/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts
@@ -27,7 +27,7 @@ import {
   useDagServiceGetDagsUiKey,
   UseTaskInstanceServiceGetTaskInstancesKeyFn,
 } from "openapi/queries";
-import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
 
 export const useTogglePause = ({ dagId }: { dagId: string }) => {
   const queryClient = useQueryClient();
@@ -45,12 +45,8 @@ export const useTogglePause = ({ dagId }: { dagId: string }) 
=> {
     await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({ 
queryKey: key })));
   };
 
-  const onError = (error: Error) => {
-    toaster.create({
-      description: error.message,
-      title: translate("error.title"),
-      type: "error",
-    });
+  const onError = (error: unknown) => {
+    createErrorToaster(error, { titleKey: "common:error.title" }, translate);
   };
 
   return useDagServicePatchDag({
diff --git a/airflow-core/src/airflow/ui/src/queries/useTrigger.ts 
b/airflow-core/src/airflow/ui/src/queries/useTrigger.ts
index 8d1b24afb52..16252358afa 100644
--- a/airflow-core/src/airflow/ui/src/queries/useTrigger.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useTrigger.ts
@@ -31,6 +31,7 @@ import {
 import type { TriggerDagRunResponse } from "openapi/requests/types.gen";
 import type { DagRunTriggerParams } from "src/components/TriggerDag/types";
 import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
 
 export const useTrigger = ({ dagId, onSuccessConfirm }: { dagId: string; 
onSuccessConfirm: () => void }) => {
   const queryClient = useQueryClient();
@@ -62,13 +63,9 @@ export const useTrigger = ({ dagId, onSuccessConfirm }: { 
dagId: string; onSucce
     }
   };
 
-  const onError = (_error: Error) => {
-    toaster.create({
-      description: _error.message,
-      title: translate("triggerDag.toaster.error.title"),
-      type: "error",
-    });
-    setError(_error);
+  const onError = (apiError: unknown) => {
+    createErrorToaster(apiError, { titleKey: 
"components:triggerDag.toaster.error.title" }, translate);
+    setError(apiError);
   };
 
   const { isPending, mutate } = useDagRunServiceTriggerDagRun({
diff --git a/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts 
b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
index 390651b7849..bb00c8466f3 100644
--- a/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
@@ -30,6 +30,7 @@ import {
   useTaskInstanceServiceGetTaskInstancesKey,
 } from "openapi/queries";
 import { toaster } from "src/components/ui/Toaster";
+import { createErrorToaster } from "src/utils";
 import type { HITLResponseParams } from "src/utils/hitl";
 
 export const useUpdateHITLDetail = ({
@@ -64,12 +65,8 @@ export const useUpdateHITLDetail = ({
     });
   };
 
-  const onError = (_error: Error) => {
-    toaster.create({
-      description: _error.message,
-      title: translate("response.error"),
-      type: "error",
-    });
+  const onError = (apiError: unknown) => {
+    createErrorToaster(apiError, { titleKey: "hitl:response.error" }, 
translate);
   };
 
   const { isPending, mutate } = useTaskInstanceServiceUpdateHitlDetail({
diff --git a/airflow-core/src/airflow/ui/src/queryClient.ts 
b/airflow-core/src/airflow/ui/src/queryClient.ts
index 7ebc52c55d5..e465402c308 100644
--- a/airflow-core/src/airflow/ui/src/queryClient.ts
+++ b/airflow-core/src/airflow/ui/src/queryClient.ts
@@ -16,9 +16,12 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { QueryClient } from "@tanstack/react-query";
+import { MutationCache, QueryClient } from "@tanstack/react-query";
 
 import { OpenAPI } from "openapi/requests/core/OpenAPI";
+import { toaster } from "src/components/ui";
+import i18n from "src/i18n/config";
+import { getErrorStatus } from "src/utils";
 
 // Dynamically set the base URL for XHR requests based on the meta tag.
 OpenAPI.BASE = document.querySelector("head>base")?.getAttribute("href") ?? "";
@@ -39,6 +42,28 @@ const retryFunction = (failureCount: number, error: unknown) 
=> {
   return failureCount < RETRY_COUNT;
 };
 
+// Track active 403 toast to prevent duplicates when multiple mutations fail
+let active403ToastId: string | undefined;
+
+// Error handler for 403 (Forbidden) responses on user-initiated actions
+const handle403Error = (error: unknown) => {
+  // Check for 403 (Forbidden) only to avoid interfering with 401 (Auth) logic
+  const status = getErrorStatus(error);
+
+  if (status === 403) {
+    // Only show one 403 toast at a time to prevent toast spam
+    // when multiple mutations fail simultaneously
+    if (active403ToastId === undefined || !toaster.isActive(active403ToastId)) 
{
+      active403ToastId = toaster.create({
+        description: i18n.t("errors.forbidden.description"),
+        title: i18n.t("errors.forbidden.title"),
+        type: "error",
+      });
+    }
+  }
+  // For other errors, let them bubble up to individual mutation handlers
+};
+
 export const client = new QueryClient({
   defaultOptions: {
     mutations: {
@@ -52,4 +77,7 @@ export const client = new QueryClient({
       staleTime: 5 * 60 * 1000, // 5 minutes
     },
   },
+  mutationCache: new MutationCache({
+    onError: handle403Error,
+  }),
 });
diff --git a/airflow-core/src/airflow/ui/src/utils/errorHandling.ts 
b/airflow-core/src/airflow/ui/src/utils/errorHandling.ts
new file mode 100644
index 00000000000..f19e5d4dc98
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/errorHandling.ts
@@ -0,0 +1,90 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 type { TFunction } from "i18next";
+
+import { toaster } from "src/components/ui";
+
+/**
+ * Type guard to check if an error has a status property
+ */
+type ErrorWithStatus = {
+  message?: string;
+  response?: {
+    status?: number;
+  };
+  status?: number;
+};
+
+/**
+ * Safely extracts the HTTP status code from an error object
+ */
+export const getErrorStatus = (error: unknown): number | undefined => {
+  if (typeof error !== "object" || error === null) {
+    return undefined;
+  }
+
+  const errorObj = error as ErrorWithStatus;
+
+  return errorObj.status ?? errorObj.response?.status;
+};
+
+/**
+ * Safely extracts the error message from an error object
+ */
+const getErrorMessage = (error: unknown): string => {
+  if (typeof error !== "object" || error === null) {
+    return String(error);
+  }
+
+  const errorObj = error as ErrorWithStatus;
+
+  return errorObj.message ?? "An error occurred";
+};
+
+/**
+ * Creates an error toaster notification with standardized behavior.
+ * Skips 403 errors as they are handled by MutationCache.
+ *
+ * @param error - The error object to process
+ * @param options - Configuration options
+ * @param options.params - Optional parameters for translation interpolation
+ * @param options.titleKey - The translation key for the error title
+ * @param translate - The i18next translate function
+ */
+export const createErrorToaster = (
+  error: unknown,
+  options: { params?: Record<string, string>; titleKey: string },
+  translate: TFunction,
+): void => {
+  const status = getErrorStatus(error);
+
+  // Skip 403 errors as they are handled by MutationCache
+  if (status === 403) {
+    return;
+  }
+
+  const message = getErrorMessage(error);
+  const title = translate(options.titleKey, options.params);
+
+  toaster.create({
+    description: message,
+    title,
+    type: "error",
+  });
+};
diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts 
b/airflow-core/src/airflow/ui/src/utils/index.ts
index e9f6904d96e..486cc4bee66 100644
--- a/airflow-core/src/airflow/ui/src/utils/index.ts
+++ b/airflow-core/src/airflow/ui/src/utils/index.ts
@@ -19,6 +19,7 @@
 
 export { capitalize } from "./capitalize";
 export { getDuration, renderDuration } from "./datetimeUtils";
+export { createErrorToaster, getErrorStatus } from "./errorHandling";
 export { getMetaKey } from "./getMetaKey";
 export { useContainerWidth } from "./useContainerWidth";
 export { useFiltersHandler, type FilterableSearchParamsKeys } from 
"./useFiltersHandler";

Reply via email to