Skip to content

Commit 514b83c

Browse files
feat(orchestrator-form-widgets): add widget fetch retries (#2601)
Introduce optional retry settings for widget fetch calls with exponential backoff and status code filtering. Made-with: Cursor
1 parent 55226c2 commit 514b83c

File tree

5 files changed

+171
-3
lines changed

5 files changed

+171
-3
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch
3+
---
4+
5+
add configurable retry options for widget fetch requests

workspaces/orchestrator/docs/orchestratorFormWidgets.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ The widget supports following `ui:props`:
216216
- fetch:method
217217
- fetch:body
218218
- fetch:retrigger
219+
- fetch:retry:maxAttempts
220+
- fetch:retry:delay
221+
- fetch:retry:backoff
222+
- fetch:retry:statusCodes
219223
- fetch:error:ignoreUnready
220224
- fetch:error:silent
221225
- fetch:response:value
@@ -301,6 +305,10 @@ The widget supports following `ui:props`:
301305
- fetch:method
302306
- fetch:body
303307
- fetch:retrigger
308+
- fetch:retry:maxAttempts
309+
- fetch:retry:delay
310+
- fetch:retry:backoff
311+
- fetch:retry:statusCodes
304312
- fetch:error:ignoreUnready
305313
- fetch:error:silent
306314
- fetch:skipInitialValue
@@ -349,6 +357,10 @@ The widget supports following `ui:props`:
349357
- fetch:method
350358
- fetch:body
351359
- fetch:retrigger
360+
- fetch:retry:maxAttempts
361+
- fetch:retry:delay
362+
- fetch:retry:backoff
363+
- fetch:retry:statusCodes
352364
- fetch:error:ignoreUnready
353365
- fetch:error:silent
354366
- fetch:skipInitialValue
@@ -400,6 +412,10 @@ The widget supports following `ui:props`:
400412
- fetch:method
401413
- fetch:body
402414
- fetch:retrigger
415+
- fetch:retry:maxAttempts
416+
- fetch:retry:delay
417+
- fetch:retry:backoff
418+
- fetch:retry:statusCodes
403419
- fetch:error:ignoreUnready
404420
- fetch:error:silent
405421
- fetch:skipInitialValue
@@ -522,6 +538,10 @@ The widget supports the following `ui:props` (for detailed information on each,
522538
- `fetch:body`: HTTP body for the fetch request
523539
- `fetch:retrigger`: Array of field paths that trigger a refetch when their values change
524540
- `fetch:clearOnRetrigger`: Clears the field value when retrigger dependencies change
541+
- `fetch:retry:maxAttempts`: Enables retry logic for the fetch call
542+
- `fetch:retry:delay`: Initial delay (ms) before the first retry
543+
- `fetch:retry:backoff`: Backoff multiplier applied to the delay
544+
- `fetch:retry:statusCodes`: Optional list of status codes to retry
525545

526546
## Content of `ui:props`
527547

@@ -542,6 +562,10 @@ Various selectors (like `fetch:response:*`) are processed by the [jsonata](https
542562
| fetch:body | An object representing the body of an HTTP POST request. Not used with the GET method. Property value can be a string template or an array of strings. templates. | `{“foo”: “bar $${{identityApi.token}}”, "myArray": ["constant", "$${{current.solutionName}}"]}` |
543563
| fetch:retrigger | An array of keys/key families as described in the Backstage API Exposed Parts. If the value referenced by any key from this list is changed, the fetch is triggered. | `["current.solutionName", "identityApi.profileName"]` |
544564
| fetch:clearOnRetrigger | When set to `true`, clears the field value as soon as any `fetch:retrigger` dependency changes, before the fetch completes. Useful to avoid stale values while refetching. | `true`, `false` (default: `false`) |
565+
| fetch:retry:maxAttempts | Enables retry logic for the widget fetch. The value is the maximum number of retries after the initial failure. If not set or `0`, retries are disabled. | `3` (retries) |
566+
| fetch:retry:delay | Initial delay in milliseconds before the first retry. Combined with `fetch:retry:backoff` for exponential backoff. | `1000` (ms) |
567+
| fetch:retry:backoff | Multiplier applied to the delay for each subsequent retry. Use `2` for exponential backoff, or `1` for fixed delay. | `2` |
568+
| fetch:retry:statusCodes | Optional list of HTTP status codes that should be retried. If omitted, any non-OK response is eligible for retry. | `[408, 429, 500, 502, 503, 504]` |
545569
| fetch:error:ignoreUnready | When set to `true`, suppresses fetch error display until all `fetch:retrigger` dependencies have non-empty values. This is useful when fetch depends on other fields that are not filled yet, preventing expected errors from being displayed during initial load. | `true`, `false` (default: `false`) |
546570
| fetch:error:silent | When set to `true`, suppresses fetch error display when the fetch request returns a non-OK status (4xx/5xx). Use this when you want to handle error states via conditional UI instead of showing the widget error. | `true`, `false` (default: `false`) |
547571
| fetch:skipInitialValue | When set to `true`, prevents applying the initial value from `fetch:response:value`, keeping the field empty until the user selects or types a value. | `true`, `false` (default: `false`) |

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export type UiProps = {
2626
'fetch:body'?: Record<string, JsonValue>;
2727
'fetch:retrigger'?: string[];
2828
'fetch:clearOnRetrigger'?: boolean;
29+
'fetch:retry:maxAttempts'?: number;
30+
'fetch:retry:delay'?: number;
31+
'fetch:retry:backoff'?: number;
32+
'fetch:retry:statusCodes'?: number[];
2933
'fetch:error:ignoreUnready'?: boolean;
3034
'fetch:error:silent'?: boolean;
3135
'fetch:skipInitialValue'?: boolean;
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export type FetchRetryOptions = {
18+
maxAttempts?: number;
19+
delayMs?: number;
20+
backoff?: number;
21+
statusCodes?: number[];
22+
};
23+
24+
const sleep = (ms: number) =>
25+
new Promise<void>(resolve => {
26+
setTimeout(resolve, ms);
27+
});
28+
29+
const DEFAULT_RETRY_DELAY_MS = 1000;
30+
const DEFAULT_RETRY_BACKOFF = 2;
31+
32+
const normalizeRetryOptions = (options?: FetchRetryOptions) => {
33+
if (!options) {
34+
return undefined;
35+
}
36+
37+
const maxAttempts = Number(options.maxAttempts);
38+
if (!Number.isFinite(maxAttempts) || maxAttempts <= 0) {
39+
return undefined;
40+
}
41+
42+
const delayMs = Number(options.delayMs ?? DEFAULT_RETRY_DELAY_MS);
43+
const backoff = Number(options.backoff ?? DEFAULT_RETRY_BACKOFF);
44+
const statusCodes =
45+
Array.isArray(options.statusCodes) && options.statusCodes.length > 0
46+
? new Set(options.statusCodes)
47+
: undefined;
48+
49+
return {
50+
maxAttempts: Math.floor(maxAttempts),
51+
delayMs: Math.max(0, delayMs),
52+
backoff: Math.max(1, backoff),
53+
statusCodes,
54+
};
55+
};
56+
57+
const getErrorStatus = (error: unknown): number | undefined => {
58+
if (!error || typeof error !== 'object') {
59+
return undefined;
60+
}
61+
62+
const maybeError = error as {
63+
status?: unknown;
64+
response?: { status?: unknown };
65+
};
66+
67+
const status = maybeError.status ?? maybeError.response?.status;
68+
return typeof status === 'number' ? status : undefined;
69+
};
70+
71+
export const fetchWithRetry = async (
72+
fetchFn: () => Promise<Response>,
73+
options?: FetchRetryOptions,
74+
): Promise<Response> => {
75+
const retryOptions = normalizeRetryOptions(options);
76+
if (!retryOptions) {
77+
return fetchFn();
78+
}
79+
80+
for (let attempt = 0; attempt <= retryOptions.maxAttempts; attempt += 1) {
81+
try {
82+
const response = await fetchFn();
83+
if (response.ok) {
84+
return response;
85+
}
86+
87+
if (
88+
retryOptions.statusCodes &&
89+
!retryOptions.statusCodes.has(response.status)
90+
) {
91+
return response;
92+
}
93+
94+
if (attempt >= retryOptions.maxAttempts) {
95+
return response;
96+
}
97+
} catch (error) {
98+
const status = getErrorStatus(error);
99+
if (
100+
status !== undefined &&
101+
retryOptions.statusCodes &&
102+
!retryOptions.statusCodes.has(status)
103+
) {
104+
throw error;
105+
}
106+
107+
if (attempt >= retryOptions.maxAttempts) {
108+
throw error;
109+
}
110+
}
111+
112+
const waitMs =
113+
retryOptions.delayMs * Math.pow(retryOptions.backoff, attempt);
114+
if (waitMs > 0) {
115+
await sleep(waitMs);
116+
}
117+
}
118+
119+
throw new Error('Retry attempts exceeded without a terminal response.');
120+
};

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { useRetriggerEvaluate } from './useRetriggerEvaluate';
2525
import { useDebounce } from 'react-use';
2626
import { DEFAULT_DEBOUNCE_LIMIT } from '../widgets/constants';
2727
import isEqual from 'lodash/isEqual';
28+
import { fetchWithRetry } from './retry';
2829

2930
/**
3031
* Checks if all fetch:retrigger dependencies have non-empty values.
@@ -57,6 +58,10 @@ export const useFetch = (
5758
const fetchUrl = uiProps['fetch:url'];
5859
const skipErrorWhenDepsEmpty = uiProps['fetch:error:ignoreUnready'] === true;
5960
const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true;
61+
const retryMaxAttempts = uiProps['fetch:retry:maxAttempts'];
62+
const retryDelay = uiProps['fetch:retry:delay'];
63+
const retryBackoff = uiProps['fetch:retry:backoff'];
64+
const retryStatusCodes = uiProps['fetch:retry:statusCodes'];
6065
const evaluatedRequestInit = useRequestInit({
6166
uiProps,
6267
prefix: 'fetch',
@@ -103,6 +108,12 @@ export const useFetch = (
103108

104109
const hasFetchInputs =
105110
!!fetchUrl && !!evaluatedFetchUrl && !!evaluatedRequestInit && !!retrigger;
111+
const retryOptions = {
112+
maxAttempts: retryMaxAttempts,
113+
delayMs: retryDelay,
114+
backoff: retryBackoff,
115+
statusCodes: retryStatusCodes,
116+
};
106117

107118
// Set loading immediately on dependency changes so UI shows a spinner during debounce.
108119
useEffect(() => {
@@ -158,9 +169,9 @@ export const useFetch = (
158169

159170
setLoading(true);
160171

161-
const response = await fetchApi.fetch(
162-
evaluatedFetchUrl,
163-
evaluatedRequestInit,
172+
const response = await fetchWithRetry(
173+
() => fetchApi.fetch(evaluatedFetchUrl, evaluatedRequestInit),
174+
retryOptions,
164175
);
165176
if (!response.ok) {
166177
throw new Error(
@@ -204,6 +215,10 @@ export const useFetch = (
204215
evaluatedRequestInit,
205216
fetchApi,
206217
fetchUrl,
218+
retryMaxAttempts,
219+
retryDelay,
220+
retryBackoff,
221+
retryStatusCodes,
207222
// no need to expand the "retrigger" array here since its identity changes only if an item changes
208223
retrigger,
209224
],

0 commit comments

Comments
 (0)