diff --git a/workspaces/orchestrator/.changeset/widget-fetch-retry.md b/workspaces/orchestrator/.changeset/widget-fetch-retry.md new file mode 100644 index 0000000000..b9afb5bff0 --- /dev/null +++ b/workspaces/orchestrator/.changeset/widget-fetch-retry.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch +--- + +add configurable retry options for widget fetch requests diff --git a/workspaces/orchestrator/docs/orchestratorFormWidgets.md b/workspaces/orchestrator/docs/orchestratorFormWidgets.md index 248c4a31bb..b9042b37c3 100644 --- a/workspaces/orchestrator/docs/orchestratorFormWidgets.md +++ b/workspaces/orchestrator/docs/orchestratorFormWidgets.md @@ -216,6 +216,10 @@ The widget supports following `ui:props`: - fetch:method - fetch:body - fetch:retrigger +- fetch:retry:maxAttempts +- fetch:retry:delay +- fetch:retry:backoff +- fetch:retry:statusCodes - fetch:error:ignoreUnready - fetch:error:silent - fetch:response:value @@ -301,6 +305,10 @@ The widget supports following `ui:props`: - fetch:method - fetch:body - fetch:retrigger +- fetch:retry:maxAttempts +- fetch:retry:delay +- fetch:retry:backoff +- fetch:retry:statusCodes - fetch:error:ignoreUnready - fetch:error:silent - fetch:skipInitialValue @@ -349,6 +357,10 @@ The widget supports following `ui:props`: - fetch:method - fetch:body - fetch:retrigger +- fetch:retry:maxAttempts +- fetch:retry:delay +- fetch:retry:backoff +- fetch:retry:statusCodes - fetch:error:ignoreUnready - fetch:error:silent - fetch:skipInitialValue @@ -400,6 +412,10 @@ The widget supports following `ui:props`: - fetch:method - fetch:body - fetch:retrigger +- fetch:retry:maxAttempts +- fetch:retry:delay +- fetch:retry:backoff +- fetch:retry:statusCodes - fetch:error:ignoreUnready - fetch:error:silent - fetch:skipInitialValue @@ -522,6 +538,10 @@ The widget supports the following `ui:props` (for detailed information on each, - `fetch:body`: HTTP body for the fetch request - `fetch:retrigger`: Array of field paths that trigger a refetch when their values change - `fetch:clearOnRetrigger`: Clears the field value when retrigger dependencies change +- `fetch:retry:maxAttempts`: Enables retry logic for the fetch call +- `fetch:retry:delay`: Initial delay (ms) before the first retry +- `fetch:retry:backoff`: Backoff multiplier applied to the delay +- `fetch:retry:statusCodes`: Optional list of status codes to retry ## Content of `ui:props` @@ -542,6 +562,10 @@ Various selectors (like `fetch:response:*`) are processed by the [jsonata](https | 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}}"]}` | | 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"]` | | 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`) | +| 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) | +| fetch:retry:delay | Initial delay in milliseconds before the first retry. Combined with `fetch:retry:backoff` for exponential backoff. | `1000` (ms) | +| fetch:retry:backoff | Multiplier applied to the delay for each subsequent retry. Use `2` for exponential backoff, or `1` for fixed delay. | `2` | +| 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]` | | 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`) | | 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`) | | 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`) | diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts index 385f932d3f..ab72c974c6 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/uiPropTypes.ts @@ -26,6 +26,10 @@ export type UiProps = { 'fetch:body'?: Record; 'fetch:retrigger'?: string[]; 'fetch:clearOnRetrigger'?: boolean; + 'fetch:retry:maxAttempts'?: number; + 'fetch:retry:delay'?: number; + 'fetch:retry:backoff'?: number; + 'fetch:retry:statusCodes'?: number[]; 'fetch:error:ignoreUnready'?: boolean; 'fetch:error:silent'?: boolean; 'fetch:skipInitialValue'?: boolean; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/retry.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/retry.ts new file mode 100644 index 0000000000..e3acde25b1 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/retry.ts @@ -0,0 +1,120 @@ +/* + * Copyright Red Hat, Inc. + * + * 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. + */ + +export type FetchRetryOptions = { + maxAttempts?: number; + delayMs?: number; + backoff?: number; + statusCodes?: number[]; +}; + +const sleep = (ms: number) => + new Promise(resolve => { + setTimeout(resolve, ms); + }); + +const DEFAULT_RETRY_DELAY_MS = 1000; +const DEFAULT_RETRY_BACKOFF = 2; + +const normalizeRetryOptions = (options?: FetchRetryOptions) => { + if (!options) { + return undefined; + } + + const maxAttempts = Number(options.maxAttempts); + if (!Number.isFinite(maxAttempts) || maxAttempts <= 0) { + return undefined; + } + + const delayMs = Number(options.delayMs ?? DEFAULT_RETRY_DELAY_MS); + const backoff = Number(options.backoff ?? DEFAULT_RETRY_BACKOFF); + const statusCodes = + Array.isArray(options.statusCodes) && options.statusCodes.length > 0 + ? new Set(options.statusCodes) + : undefined; + + return { + maxAttempts: Math.floor(maxAttempts), + delayMs: Math.max(0, delayMs), + backoff: Math.max(1, backoff), + statusCodes, + }; +}; + +const getErrorStatus = (error: unknown): number | undefined => { + if (!error || typeof error !== 'object') { + return undefined; + } + + const maybeError = error as { + status?: unknown; + response?: { status?: unknown }; + }; + + const status = maybeError.status ?? maybeError.response?.status; + return typeof status === 'number' ? status : undefined; +}; + +export const fetchWithRetry = async ( + fetchFn: () => Promise, + options?: FetchRetryOptions, +): Promise => { + const retryOptions = normalizeRetryOptions(options); + if (!retryOptions) { + return fetchFn(); + } + + for (let attempt = 0; attempt <= retryOptions.maxAttempts; attempt += 1) { + try { + const response = await fetchFn(); + if (response.ok) { + return response; + } + + if ( + retryOptions.statusCodes && + !retryOptions.statusCodes.has(response.status) + ) { + return response; + } + + if (attempt >= retryOptions.maxAttempts) { + return response; + } + } catch (error) { + const status = getErrorStatus(error); + if ( + status !== undefined && + retryOptions.statusCodes && + !retryOptions.statusCodes.has(status) + ) { + throw error; + } + + if (attempt >= retryOptions.maxAttempts) { + throw error; + } + } + + const waitMs = + retryOptions.delayMs * Math.pow(retryOptions.backoff, attempt); + if (waitMs > 0) { + await sleep(waitMs); + } + } + + throw new Error('Retry attempts exceeded without a terminal response.'); +}; diff --git a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts index 8d16a8fb81..537ab3d4d3 100644 --- a/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts +++ b/workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/useFetch.ts @@ -25,6 +25,7 @@ import { useRetriggerEvaluate } from './useRetriggerEvaluate'; import { useDebounce } from 'react-use'; import { DEFAULT_DEBOUNCE_LIMIT } from '../widgets/constants'; import isEqual from 'lodash/isEqual'; +import { fetchWithRetry } from './retry'; /** * Checks if all fetch:retrigger dependencies have non-empty values. @@ -57,6 +58,10 @@ export const useFetch = ( const fetchUrl = uiProps['fetch:url']; const skipErrorWhenDepsEmpty = uiProps['fetch:error:ignoreUnready'] === true; const clearOnRetrigger = uiProps['fetch:clearOnRetrigger'] === true; + const retryMaxAttempts = uiProps['fetch:retry:maxAttempts']; + const retryDelay = uiProps['fetch:retry:delay']; + const retryBackoff = uiProps['fetch:retry:backoff']; + const retryStatusCodes = uiProps['fetch:retry:statusCodes']; const evaluatedRequestInit = useRequestInit({ uiProps, prefix: 'fetch', @@ -103,6 +108,12 @@ export const useFetch = ( const hasFetchInputs = !!fetchUrl && !!evaluatedFetchUrl && !!evaluatedRequestInit && !!retrigger; + const retryOptions = { + maxAttempts: retryMaxAttempts, + delayMs: retryDelay, + backoff: retryBackoff, + statusCodes: retryStatusCodes, + }; // Set loading immediately on dependency changes so UI shows a spinner during debounce. useEffect(() => { @@ -158,9 +169,9 @@ export const useFetch = ( setLoading(true); - const response = await fetchApi.fetch( - evaluatedFetchUrl, - evaluatedRequestInit, + const response = await fetchWithRetry( + () => fetchApi.fetch(evaluatedFetchUrl, evaluatedRequestInit), + retryOptions, ); if (!response.ok) { throw new Error( @@ -204,6 +215,10 @@ export const useFetch = ( evaluatedRequestInit, fetchApi, fetchUrl, + retryMaxAttempts, + retryDelay, + retryBackoff, + retryStatusCodes, // no need to expand the "retrigger" array here since its identity changes only if an item changes retrigger, ],