From b6be888afb9377aea784da4974b8d8d5c47e12a1 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 11 Aug 2026 12:57:42 +0300 Subject: [PATCH 1/2] Show retry budget usage and why a retry was refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Budget usage panel to the retry policy page listing each integration's spent total, flagging exhausted ones, with per-row and bulk Reset — the only way back for a group that has hit its ceiling. The xchange retry dialog now shows the policy's refusal reason, and the "Max attempts total" tooltip is corrected: the total is a lifetime ceiling counted per integration, not a shared one across all messages. --- src/client/apis/retryPoliciesApi.ts | 22 +++- .../RetryPolicies/AddEditRetryGroupModal.tsx | 2 +- .../RetryPolicies/RetryBudgetUsage.tsx | 105 ++++++++++++++++++ src/components/RetryPolicy.tsx | 5 + src/components/exchanges/ExchangeList.tsx | 1 + src/components/exchanges/RetryModal.tsx | 9 +- src/types/retryPolicies.ts | 18 +++ src/types/xchange.ts | 2 + 8 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 src/components/RetryPolicies/RetryBudgetUsage.tsx diff --git a/src/client/apis/retryPoliciesApi.ts b/src/client/apis/retryPoliciesApi.ts index a9b8002..008b114 100644 --- a/src/client/apis/retryPoliciesApi.ts +++ b/src/client/apis/retryPoliciesApi.ts @@ -4,6 +4,8 @@ import { RetryPoliciesSearchModel, RetryPolicyModel, RetryPolicyRow, + RetryGroupUsageRow, + RetryPolicyResetUsage, TestRetryPolicyRequest, TestRetryPolicyResponse } from "src/types/retryPolicies"; @@ -12,7 +14,7 @@ import {ApiPagedResponse} from "src/types/common"; export const RetryPoliciesApi = createApi({ baseQuery: customFetchBase, reducerPath: "RetryPoliciesApi", - tagTypes: ["retryPolicies"], + tagTypes: ["retryPolicies", "retryPolicyUsage"], endpoints: (builder) => ({ retryPolicies: builder.query, RetryPoliciesSearchModel>({ providesTags: ['retryPolicies'], @@ -68,6 +70,22 @@ export const RetryPoliciesApi = createApi({ method: "POST", body }) + }), + retryPolicyUsage: builder.query({ + providesTags: ['retryPolicyUsage'], + query: id => ({ + url: `RetryPolicies/${id}/usage`, + method: "POST", + body: {} + }) + }), + resetRetryPolicyUsage: builder.mutation<{}, { id: number } & RetryPolicyResetUsage>({ + invalidatesTags: ['retryPolicyUsage'], + query: ({id, ...body}) => ({ + url: `RetryPolicies/${id}/resetusage`, + method: "POST", + body + }) }) }) }); @@ -81,4 +99,6 @@ export const { useDeleteRetryPolicyMutation, useRetryPoliciesLookupQuery, useTestRetryPolicyMutation, + useRetryPolicyUsageQuery, + useResetRetryPolicyUsageMutation, } = RetryPoliciesApi; diff --git a/src/components/RetryPolicies/AddEditRetryGroupModal.tsx b/src/components/RetryPolicies/AddEditRetryGroupModal.tsx index ffc741d..d50a2a1 100644 --- a/src/components/RetryPolicies/AddEditRetryGroupModal.tsx +++ b/src/components/RetryPolicies/AddEditRetryGroupModal.tsx @@ -169,7 +169,7 @@ const AddEditRetryGroupModal: React.FC = ({visible, onClose, onAdd, initi onChangeBudgetField("maxAttemptsPerError", Number(v))}/> - + onChangeBudgetField("maxAttemptsTotal", Number(v))}/> diff --git a/src/components/RetryPolicies/RetryBudgetUsage.tsx b/src/components/RetryPolicies/RetryBudgetUsage.tsx new file mode 100644 index 0000000..474c59e --- /dev/null +++ b/src/components/RetryPolicies/RetryBudgetUsage.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import {useResetRetryPolicyUsageMutation, useRetryPolicyUsageQuery} from "src/client/apis/retryPoliciesApi"; +import Button from "src/components/common/forms/Button"; +import FormField from "src/components/common/forms/FormField"; +import Authorize from "src/components/common/authorize/authorize"; +import dayjs from "dayjs"; + +interface Props { + policyId: number +} + +// "Max attempts total" never resets on its own, so an integration that reaches its ceiling stops +// being retried until someone clears the counter here. Without this panel that state is invisible. +const RetryBudgetUsage: React.FC = ({policyId}) => { + + const {data, isLoading} = useRetryPolicyUsageQuery(policyId) + const [reset] = useResetRetryPolicyUsageMutation() + + const rows = data ?? [] + const exhaustedCount = rows.filter(r => r.exhausted).length + + return ( + +

+ Counted separately for each integration. Integrations that have never failed under this + policy do not appear. +

+ + {isLoading &&

Loading…

} + + {!isLoading && rows.length === 0 && +

+ No budget spent — every group has its full allowance. +

} + + {rows.length > 0 &&
+ {exhaustedCount > 0 && +

+ {exhaustedCount === 1 + ? "1 integration has exhausted its budget and is no longer being retried." + : `${exhaustedCount} integrations have exhausted their budget and are no longer being retried.`} +

} + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
IntegrationGroupUsedLast retry
+ {r.subscriptionName} + + {r.groupName} + + + {r.attemptsUsed} / {r.maxAttemptsTotal} + + {r.exhausted && + + Exhausted + } + + {dayjs(r.lastAttemptOn).format("YYYY-MM-DD HH:mm")} + + + + +
+ + +
+ +
+
+
} +
+ ); +} + +export default RetryBudgetUsage; diff --git a/src/components/RetryPolicy.tsx b/src/components/RetryPolicy.tsx index c772a45..0504436 100644 --- a/src/components/RetryPolicy.tsx +++ b/src/components/RetryPolicy.tsx @@ -7,6 +7,7 @@ import Authorize from "src/components/common/authorize/authorize"; import React, {useEffect, useState} from "react"; import {RetryPolicyModel} from "src/types/retryPolicies"; import RetryGroupsEditor from "src/components/RetryPolicies/RetryGroupsEditor"; +import RetryBudgetUsage from "src/components/RetryPolicies/RetryBudgetUsage"; import TestRetryPolicyModal from "src/components/RetryPolicies/TestRetryPolicyModal"; import {MdPlayCircleOutline} from "react-icons/md"; @@ -62,6 +63,10 @@ const RetryPolicy = () => { onChange={(g) => onChange("groups", g)}/> +
+ +
+
diff --git a/src/components/exchanges/ExchangeList.tsx b/src/components/exchanges/ExchangeList.tsx index 63ca478..93a599f 100644 --- a/src/components/exchanges/ExchangeList.tsx +++ b/src/components/exchanges/ExchangeList.tsx @@ -224,6 +224,7 @@ export const ExchangeList: React.FC = ({ xid={showExceptionFor} exception={data.find((i) => i.id == showExceptionFor)?.exception} scheduledRetryOn={data.find((i) => i.id == showExceptionFor)?.scheduledRetryOn} + retryBlockedReason={data.find((i) => i.id == showExceptionFor)?.retryBlockedReason} onClose={() => setShowExceptionFor(null)} onRefresh={refresh} /> diff --git a/src/components/exchanges/RetryModal.tsx b/src/components/exchanges/RetryModal.tsx index c541541..cf2cad9 100644 --- a/src/components/exchanges/RetryModal.tsx +++ b/src/components/exchanges/RetryModal.tsx @@ -11,9 +11,10 @@ type Props = { onClose: () => void xid?: string scheduledRetryOn?: string | null + retryBlockedReason?: string | null onRefresh?: () => void } -const RetryModal: React.FC = ({exception, onClose, xid, scheduledRetryOn, onRefresh}) => { +const RetryModal: React.FC = ({exception, onClose, xid, scheduledRetryOn, retryBlockedReason, onRefresh}) => { const [resetForRetry, setResetForRetry] = useState(false); const [runNow] = useRunDelayedRetryNowMutation(); @@ -71,6 +72,12 @@ const RetryModal: React.FC = ({exception, onClose, xid, scheduledRetryOn, Use "Run Now" to execute it immediately, or wait for it to run automatically.
} + { + !hasScheduledRetry && retryBlockedReason && +
+ Not retried automatically — {retryBlockedReason}. Use "Retry" to run it manually. +
+ } { exception &&
diff --git a/src/types/retryPolicies.ts b/src/types/retryPolicies.ts index d123b50..9562940 100644 --- a/src/types/retryPolicies.ts +++ b/src/types/retryPolicies.ts @@ -97,6 +97,24 @@ export interface RetryPolicyRow { groupCount: number } +// "Max attempts total" is counted per integration, so a policy shared by several +// integrations reports one row for each that has spent any of its budget. +export interface RetryGroupUsageRow { + subscriptionId: number + subscriptionName: string + groupId: string + groupName: string + attemptsUsed: number + maxAttemptsTotal: number + exhausted: boolean + lastAttemptOn: string +} + +export interface RetryPolicyResetUsage { + subscriptionId?: number + groupId?: string +} + export interface RetryPoliciesSearchModel { limit?: number offset?: number diff --git a/src/types/xchange.ts b/src/types/xchange.ts index 5737130..181a971 100644 --- a/src/types/xchange.ts +++ b/src/types/xchange.ts @@ -39,6 +39,8 @@ export interface IXchange { correlationId: string; partnerId: number | null; scheduledRetryOn?: string | null; + // Why the retry policy declined another attempt, when it declined. + retryBlockedReason?: string | null; } From 0846a83c01d3fe0b435b17417bb415d1d4a8d22b Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 11 Aug 2026 15:12:31 +0300 Subject: [PATCH 2/2] Show a usage load error instead of a false empty state A failed usage request fell through to "No budget spent", claiming every group was untouched when the truth was unknown. Render an error with a retry action instead, and keep the empty state for successful empty responses. Refetch usage after a policy is saved, since saving drops the counters of any removed group. Encode the quotation marks in the retry dialog. --- src/client/apis/retryPoliciesApi.ts | 3 ++- src/components/RetryPolicies/RetryBudgetUsage.tsx | 14 +++++++++++--- src/components/exchanges/RetryModal.tsx | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/client/apis/retryPoliciesApi.ts b/src/client/apis/retryPoliciesApi.ts index 008b114..deea538 100644 --- a/src/client/apis/retryPoliciesApi.ts +++ b/src/client/apis/retryPoliciesApi.ts @@ -42,7 +42,8 @@ export const RetryPoliciesApi = createApi({ }) }), updateRetryPolicy: builder.mutation<{}, { id: number } & RetryPolicyModel>({ - invalidatesTags: ['retryPolicies'], + // Saving drops the counters of any removed group, so the usage panel must refetch. + invalidatesTags: ['retryPolicies', 'retryPolicyUsage'], query: body => ({ url: `RetryPolicies/${body.id}`, method: "POST", diff --git a/src/components/RetryPolicies/RetryBudgetUsage.tsx b/src/components/RetryPolicies/RetryBudgetUsage.tsx index 474c59e..04015c9 100644 --- a/src/components/RetryPolicies/RetryBudgetUsage.tsx +++ b/src/components/RetryPolicies/RetryBudgetUsage.tsx @@ -13,7 +13,7 @@ interface Props { // being retried until someone clears the counter here. Without this panel that state is invisible. const RetryBudgetUsage: React.FC = ({policyId}) => { - const {data, isLoading} = useRetryPolicyUsageQuery(policyId) + const {data, isLoading, isError, refetch} = useRetryPolicyUsageQuery(policyId) const [reset] = useResetRetryPolicyUsageMutation() const rows = data ?? [] @@ -29,12 +29,20 @@ const RetryBudgetUsage: React.FC = ({policyId}) => { {isLoading &&

Loading…

} - {!isLoading && rows.length === 0 && + {/* Never fall through to the empty state on failure: "no budget spent" would claim + every group is untouched when the truth is that we could not find out. */} + {isError && +
+ Could not load budget usage. + +
} + + {!isLoading && !isError && rows.length === 0 &&

No budget spent — every group has its full allowance.

} - {rows.length > 0 &&
+ {!isError && rows.length > 0 &&
{exhaustedCount > 0 &&

{exhaustedCount === 1 diff --git a/src/components/exchanges/RetryModal.tsx b/src/components/exchanges/RetryModal.tsx index cf2cad9..e819264 100644 --- a/src/components/exchanges/RetryModal.tsx +++ b/src/components/exchanges/RetryModal.tsx @@ -75,7 +75,7 @@ const RetryModal: React.FC = ({exception, onClose, xid, scheduledRetryOn, { !hasScheduledRetry && retryBlockedReason &&

- Not retried automatically — {retryBlockedReason}. Use "Retry" to run it manually. + Not retried automatically — {retryBlockedReason}. Use "Retry" to run it manually.
} {