diff --git a/backend/windmill-api/src/flows.rs b/backend/windmill-api/src/flows.rs index d2c93adc9209c..fc4fd9c1db5ae 100644 --- a/backend/windmill-api/src/flows.rs +++ b/backend/windmill-api/src/flows.rs @@ -85,6 +85,38 @@ pub fn global_service() -> Router { .route("/hub/get/:id", get(get_hub_flow_by_id)) } +fn validate_flow_value(flow_value: &serde_json::Value) -> Result<()> { + #[cfg(not(feature = "enterprise"))] + if flow_value + .get("ws_error_handler_muted") + .map(|val| val.as_bool().unwrap_or(false)) + .is_some_and(|val| val) + { + return Err(Error::BadRequest( + "Muting the error handler for certain flow is only available in enterprise version" + .to_string(), + )); + } + + if let Some(modules) = flow_value.get("modules").and_then(|m| m.as_array()) { + for module in modules { + if let Some(retry) = module.get("retry") { + if let Some(exponential) = retry.get("exponential") { + let seconds = exponential.get("seconds").and_then(|s| s.as_u64()).ok_or( + Error::BadRequest("Exponential backoff base (seconds) must be a valid positive integer".to_string()), + )?; + if seconds == 0 { + return Err(Error::BadRequest( + "Exponential backoff base (seconds) must be greater than 0. A base of 0 would cause immediate retries.".to_string(), + )); + } + } + } + } + } + Ok(()) +} + #[derive(Serialize, FromRow)] pub struct SearchFlow { path: String, @@ -411,18 +443,8 @@ async fn create_flow( )); } } - #[cfg(not(feature = "enterprise"))] - if nf - .value - .get("ws_error_handler_muted") - .map(|val| val.as_bool().unwrap_or(false)) - .is_some_and(|val| val) - { - return Err(Error::BadRequest( - "Muting the error handler for certain flow is only available in enterprise version" - .to_string(), - )); - } + + validate_flow_value(&nf.value)?; // cron::Schedule::from_str(&ns.schedule).map_err(|e| error::Error::BadRequest(e.to_string()))?; let authed = maybe_refresh_folders(&nf.path, &w_id, authed, &db).await; @@ -744,18 +766,7 @@ async fn update_flow( let flow_path = flow_path.to_path(); check_scopes(&authed, || format!("flows:write:{}", flow_path))?; - #[cfg(not(feature = "enterprise"))] - if nf - .value - .get("ws_error_handler_muted") - .map(|val| val.as_bool().unwrap_or(false)) - .is_some_and(|val| val) - { - return Err(Error::BadRequest( - "Muting the error handler for certain flow is only available in enterprise version" - .to_string(), - )); - } + validate_flow_value(&nf.value)?; let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await; let mut tx = user_db.clone().begin(&authed).await?; diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 22e22f2b7a5f4..598474521ee8c 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -80,6 +80,7 @@ import { StepsInputArgs } from './flows/stepsInputArgs.svelte' import { aiChatManager } from './copilot/chat/AIChatManager.svelte' import type { GraphModuleState } from './graph' + import { validateRetryConfig } from '$lib/utils' import { setStepHistoryLoaderContext, StepHistoryLoader, @@ -427,6 +428,15 @@ loadingSave = true try { const flow = cleanInputs(flowStore.val) + + if (flow.value?.modules) { + for (const module of flow.value.modules) { + const error = validateRetryConfig(module.retry) + if (error) { + throw new Error(error) + } + } + } // console.log('flow', computeUnlockedSteps(flow)) // del // loadingSave = false // del // return diff --git a/frontend/src/lib/components/flows/content/FlowRetries.svelte b/frontend/src/lib/components/flows/content/FlowRetries.svelte index 8d14890961317..94c5822334c9b 100644 --- a/frontend/src/lib/components/flows/content/FlowRetries.svelte +++ b/frontend/src/lib/components/flows/content/FlowRetries.svelte @@ -14,6 +14,7 @@ import type { FlowEditorContext } from '../types' import { getStepPropPicker } from '../previousResults' import { NEVER_TESTED_THIS_FAR } from '../models' + import { validateRetryConfig } from '$lib/utils' interface Props { flowModuleRetry: Retry | undefined @@ -59,6 +60,10 @@ : NEVER_TESTED_THIS_FAR ) + let validationError = $derived.by(() => { + return validateRetryConfig(flowModuleRetry) + }) + function setConstantRetries() { flowModuleRetry = { ...flowModuleRetry, @@ -247,7 +252,20 @@ delay = multiplier * base ^ (number of attempt)
Base (in seconds)
- + + {#if validationError} + {validationError} + {:else} + Must be ≥ 1. A base of 0 would cause immediate retries. + {/if}
Randomization factor (percentage)
{#if !$enterpriseLicense} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index f37549a127e4f..6b9719daacf35 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -10,7 +10,7 @@ import { deepEqual } from 'fast-equals' import YAML from 'yaml' import { type UserExt } from './stores' import { sendUserToast } from './toast' -import type { Job, RunnableKind, Script, ScriptLang } from './gen' +import type { Job, RunnableKind, Script, ScriptLang, Retry } from './gen' import type { EnumType, SchemaProperty } from './common' import type { Schema } from './common' export { sendUserToast } @@ -1624,3 +1624,13 @@ export function createCache, T, InitialKeys ext export async function wait(ms: number) { return new Promise((resolve) => setTimeout(() => resolve(undefined), ms)) } + +export function validateRetryConfig(retry: Retry | undefined): string | null { + if (retry?.exponential?.seconds !== undefined) { + const seconds = retry.exponential.seconds + if (typeof seconds !== 'number' || !Number.isInteger(seconds) || seconds < 1) { + return 'Exponential backoff base (seconds) must be a valid positive integer ≥ 1' + } + } + return null +} diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 7aee72475f024..ec7d2291b247f 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -87,6 +87,7 @@ components: type: integer seconds: type: integer + minimum: 1 random_factor: type: integer minimum: 0