diff --git a/docs/mint.json b/docs/mint.json index cce7cb52f2..0b48677bcd 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -115,6 +115,7 @@ "pages": [ "workflows/examples/autosupress", "workflows/examples/buisnesshours", + "workflows/examples/continue-on-error", "workflows/examples/create-servicenow-tickets", "workflows/examples/highsev", "workflows/examples/update-servicenow-tickets" diff --git a/docs/workflows/examples/continue-on-error.mdx b/docs/workflows/examples/continue-on-error.mdx new file mode 100644 index 0000000000..bd6048b363 --- /dev/null +++ b/docs/workflows/examples/continue-on-error.mdx @@ -0,0 +1,75 @@ +--- +title: "Continue on Error" +--- + +This workflow demonstrates `continue_on_error`, which lets a step or action fail without stopping the rest of the workflow — similar to `continue-on-error` in GitHub Actions. + +**Use case:** enrich an alert with optional data from an external API, then always send a Slack notification regardless of whether the enrichment succeeded. + +```yaml +workflow: + id: continue-on-error-example + description: "Fetch optional enrichment data and always notify via Slack" + triggers: + - type: alert + cel: source.contains("grafana") + + steps: + - name: fetch-optional-enrichment + continue_on_error: true + provider: + type: http + config: "{{ providers.internal-api }}" + with: + url: "https://api.example.com/enrich/{{ alert.fingerprint }}" + method: GET + + actions: + - name: notify-slack + provider: + type: slack + config: "{{ providers.slack-demo }}" + with: + message: > + Alert: {{ alert.name }} + Enrichment: {{ steps.fetch-optional-enrichment.results | default('unavailable') }} +``` + +**How it works:** + +1. The `fetch-optional-enrichment` step calls an external API. If the API is down or returns an error, the failure is logged as a warning and execution continues. +2. The `notify-slack` action always runs, using the enrichment results if available or falling back gracefully if not. + +The same flag works on actions. In the example below, a best-effort ticket is created in ServiceNow and a Slack message is sent regardless of whether the ticket creation succeeded: + +```yaml +workflow: + id: best-effort-ticket + description: "Create a ServiceNow ticket (best-effort) and always notify Slack" + triggers: + - type: alert + cel: severity == "critical" + + actions: + - name: create-ticket + continue_on_error: true + provider: + type: servicenow + config: "{{ providers.servicenow }}" + with: + table_name: INCIDENT + payload: + short_description: "{{ alert.name }}" + description: "{{ alert.description }}" + + - name: notify-slack + provider: + type: slack + config: "{{ providers.slack-demo }}" + with: + message: "Critical alert: {{ alert.name }}. A ServiceNow ticket was attempted." +``` + + +When `continue_on_error: true` is set, the step or action failure is logged as a warning but does **not** mark the overall workflow execution as failed. + diff --git a/docs/workflows/syntax/steps-and-actions.mdx b/docs/workflows/syntax/steps-and-actions.mdx index 4b75e087ff..b88c4692f5 100644 --- a/docs/workflows/syntax/steps-and-actions.mdx +++ b/docs/workflows/syntax/steps-and-actions.mdx @@ -211,3 +211,51 @@ steps: # Retry every 5 seconds interval: 5 ``` + +### Continue on error + +By default, if a step or action fails the workflow stops immediately and reports a failure. Set `continue_on_error: true` on any step or action to swallow its failure and continue executing the remaining steps/actions — similar to `continue-on-error` in GitHub Actions. + +```yaml +steps: + - name: optional-enrichment + continue_on_error: true + provider: + type: http + config: "{{ providers.some-api }}" + with: + url: "https://api.example.com/optional-data" + + - name: always-runs + provider: + type: slack + config: "{{ providers.slack-demo }}" + with: + message: "Runs even if optional-enrichment failed" +``` + +The same attribute works on actions: + +```yaml +actions: + - name: best-effort-ticket + continue_on_error: true + provider: + type: servicenow + config: "{{ providers.servicenow }}" + with: + table_name: INCIDENT + payload: + short_description: "Automated incident" + + - name: fallback-slack + provider: + type: slack + config: "{{ providers.slack-demo }}" + with: + message: "Ticket creation may have failed, check ServiceNow" +``` + + +When `continue_on_error: true` is set, the step/action failure is logged as a warning but does **not** mark the overall workflow execution as failed. + diff --git a/keep-ui/entities/workflows/model/yaml.schema.ts b/keep-ui/entities/workflows/model/yaml.schema.ts index a6efcb7026..fe2d8cb7c9 100644 --- a/keep-ui/entities/workflows/model/yaml.schema.ts +++ b/keep-ui/entities/workflows/model/yaml.schema.ts @@ -203,6 +203,7 @@ export const YamlStepOrActionSchema = z .optional(), foreach: z.string().optional(), continue: z.boolean().optional(), + continue_on_error: z.boolean().optional(), "on-failure": OnFailureSchema.optional(), }) .strict(); diff --git a/keep-ui/shared/ui/MonacoCELEditor/MonacoCel.tsx b/keep-ui/shared/ui/MonacoCELEditor/MonacoCel.tsx index 1f0b942e90..fa53400fc5 100644 --- a/keep-ui/shared/ui/MonacoCELEditor/MonacoCel.tsx +++ b/keep-ui/shared/ui/MonacoCELEditor/MonacoCel.tsx @@ -8,6 +8,20 @@ interface MonacoCelProps extends EditorProps { onMonacoLoadFailure?: (error: Error) => void; } +// Monaco Editor - imported as an npm package instead of loading from the +// CDN to support air-gapped environments. +// https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#use-monaco-editor-as-an-npm-package +// +// The dynamic import is started at module evaluation (not inside useEffect) so +// that it does not interfere with React's render cycle. A typeof-window guard +// makes it SSR-safe without causing "window is not defined" crashes. +const monacoConfigPromise: Promise | null = + typeof window !== "undefined" + ? import("monaco-editor").then((monaco) => { + loader.config({ monaco }); + }) + : null; + export function MonacoCelBase(props: MonacoCelProps) { const [isLoaded, setIsLoaded] = useState(false); const onMonacoLoadedRef = useRef( @@ -20,14 +34,8 @@ export function MonacoCelBase(props: MonacoCelProps) { onMonacoLoadFailureRef.current = props.onMonacoLoadFailure; useEffect(() => { - // Monaco Editor - imported as an npm package instead of loading from the - // CDN to support air-gapped environments. - // https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#use-monaco-editor-as-an-npm-package - import("monaco-editor") - .then((monaco) => { - loader.config({ monaco }); - return loader.init(); - }) + (monacoConfigPromise ?? Promise.resolve()) + .then(() => loader.init()) .then((monacoInstance) => { onMonacoLoadedRef.current?.(monacoInstance); setIsLoaded(true); diff --git a/keep/step/step.py b/keep/step/step.py index 4a80afa1f6..20b508d722 100644 --- a/keep/step/step.py +++ b/keep/step/step.py @@ -46,6 +46,7 @@ def __init__( self.__retry_count = self.__retry.get("count", 0) self.__retry_interval = self.__retry.get("interval", 0) self.__continue_to_next_step = self.config.get("continue", True) + self.__continue_on_error = self.config.get("continue_on_error", False) @property def foreach(self): @@ -59,6 +60,10 @@ def name(self): def continue_to_next_step(self): return self.__continue_to_next_step + @property + def continue_on_error(self): + return self.__continue_on_error + def _dont_render(self): # special case for Keep provider on _notify with "if" - it should render the parameters itself return self.step_type == StepType.ACTION and "KeepProvider" in str( diff --git a/keep/workflowmanager/workflow.py b/keep/workflowmanager/workflow.py index 16ca7dce22..e80e17b2e5 100644 --- a/keep/workflowmanager/workflow.py +++ b/keep/workflowmanager/workflow.py @@ -6,6 +6,7 @@ from keep.contextmanager.contextmanager import ContextManager from keep.identitymanager.rbac import Roles from keep.iohandler.iohandler import IOHandler +from keep.exceptions.action_error import ActionError from keep.step.step import Step, StepError @@ -93,9 +94,16 @@ def run_steps(self): extra={"step_id": step.step_id}, ) break - except StepError as e: - self.logger.error(f"Step {step.step_id} failed: {e}") + except (StepError, ActionError) as e: threading.current_thread().step_id = None + if step.continue_on_error: + self.logger.warning( + "Step %s failed but continue_on_error is True, continuing", + step.step_id, + extra={"step_id": step.step_id}, + ) + continue + self.logger.error(f"Step {step.step_id} failed: {e}") raise self.logger.debug(f"Steps for workflow {self.workflow_id} ran successfully") @@ -146,8 +154,15 @@ def run_actions(self): action_status, action_error, action_stop = self.run_action(action) threading.current_thread().step_id = None if action_error: - actions_firing.append(action_status) - actions_errors.append(action_error) + if action.continue_on_error: + self.logger.warning( + "Action %s failed but continue_on_error is True, continuing", + action.name, + extra={"step_id": action.step_id}, + ) + else: + actions_firing.append(action_status) + actions_errors.append(action_error) # if the action ran + the action configured to stop the workflow: elif action_status and action_stop: self.logger.info("Action stop, stopping the workflow") diff --git a/tests/test_steps.py b/tests/test_steps.py index c98b1a15bf..1975a7b138 100644 --- a/tests/test_steps.py +++ b/tests/test_steps.py @@ -106,3 +106,42 @@ def test_run_single_exception(sample_step): # _run_single should take around RETRY_COUNT*RETRT_INTERVAL time due to retries assert execution_time >= RETRY_COUNT * RETRY_INTERVAL + + +def test_continue_on_error_default_is_false(): + context_manager = Mock() + step = Step( + context_manager, + "test_step", + {}, + StepType.STEP, + Mock(), + {}, + ) + assert step.continue_on_error is False + + +def test_continue_on_error_set_to_true(): + context_manager = Mock() + step = Step( + context_manager, + "test_step", + {"continue_on_error": True}, + StepType.STEP, + Mock(), + {}, + ) + assert step.continue_on_error is True + + +def test_continue_on_error_explicit_false(): + context_manager = Mock() + step = Step( + context_manager, + "test_step", + {"continue_on_error": False}, + StepType.STEP, + Mock(), + {}, + ) + assert step.continue_on_error is False diff --git a/tests/test_workflowmanager.py b/tests/test_workflowmanager.py index 0c1d44ae1b..b86fc18ed1 100644 --- a/tests/test_workflowmanager.py +++ b/tests/test_workflowmanager.py @@ -8,6 +8,8 @@ from keep.parser.parser import Parser # Assuming WorkflowParser is the class containing the get_workflow_from_dict method +from keep.exceptions.action_error import ActionError +from keep.step.step import StepError from keep.workflowmanager.workflow import Workflow from keep.workflowmanager.workflowmanager import WorkflowManager from keep.workflowmanager.workflowscheduler import WorkflowScheduler @@ -182,3 +184,136 @@ def test_handle_manual_event_workflow_test_run(): ) assert workflow_scheduler.workflows_to_run[0]["test_run"] == True assert workflow_scheduler.workflows_to_run[0]["workflow"] == mock_workflow + + +# --- Helpers --- + + +def _make_workflow(steps=None, actions=None): + context_manager = Mock() + return Workflow( + context_manager=context_manager, + workflow_id="test-workflow", + workflow_revision=1, + workflow_name="Test Workflow", + workflow_owners=[], + workflow_tags=[], + workflow_interval=0, + workflow_triggers=[], + workflow_steps=steps or [], + workflow_actions=actions or [], + ) + + +def _mock_step(step_id, *, continue_on_error=False, continue_to_next_step=True, run_return=True, run_raises=None): + step = Mock() + step.step_id = step_id + step.continue_on_error = continue_on_error + step.continue_to_next_step = continue_to_next_step + if run_raises is not None: + step.run.side_effect = run_raises + else: + step.run.return_value = run_return + return step + + +def _mock_action(name, *, continue_on_error=False, run_raises=None): + action = Mock() + action.step_id = name + action.name = name + action.continue_on_error = continue_on_error + action.continue_to_next_step = True + if run_raises is not None: + action.run.side_effect = run_raises + else: + action.run.return_value = True + return action + + +# --- run_steps() tests --- + + +def test_run_steps_continue_on_error_swallows_failure_and_continues(): + """A failing step with continue_on_error=True should not raise and the next step should run.""" + failing = _mock_step("failing", continue_on_error=True, run_raises=StepError("boom")) + next_step = _mock_step("next") + + _make_workflow(steps=[failing, next_step]).run_steps() + + next_step.run.assert_called_once() + + +def test_run_steps_continue_on_error_swallows_action_error(): + """step.run() raises ActionError in practice — continue_on_error must catch it too.""" + failing = _mock_step("failing", continue_on_error=True, run_raises=ActionError("boom")) + next_step = _mock_step("next") + + _make_workflow(steps=[failing, next_step]).run_steps() + + next_step.run.assert_called_once() + + +def test_run_steps_without_continue_on_error_raises_step_error(): + """A failing step without continue_on_error should propagate StepError and stop execution.""" + failing = _mock_step("failing", continue_on_error=False, run_raises=StepError("boom")) + next_step = _mock_step("next") + + with pytest.raises(StepError): + _make_workflow(steps=[failing, next_step]).run_steps() + + next_step.run.assert_not_called() + + +def test_run_steps_without_continue_on_error_raises_action_error(): + """A failing step without continue_on_error should propagate ActionError and stop execution.""" + failing = _mock_step("failing", continue_on_error=False, run_raises=ActionError("boom")) + next_step = _mock_step("next") + + with pytest.raises(ActionError): + _make_workflow(steps=[failing, next_step]).run_steps() + + next_step.run.assert_not_called() + + +def test_run_steps_only_failed_step_is_skipped(): + """With continue_on_error, only the failing step is skipped; subsequent steps still run.""" + step1 = _mock_step("step1") + step2 = _mock_step("step2", continue_on_error=True, run_raises=ActionError("mid-fail")) + step3 = _mock_step("step3") + + _make_workflow(steps=[step1, step2, step3]).run_steps() + + step1.run.assert_called_once() + step3.run.assert_called_once() + + +# --- run_actions() tests --- + + +def test_run_actions_continue_on_error_swallows_error(): + """A failing action with continue_on_error=True should not appear in actions_errors.""" + failing = _mock_action("failing", continue_on_error=True, run_raises=Exception("boom")) + + _, actions_errors = _make_workflow(actions=[failing]).run_actions() + + assert actions_errors == [] + + +def test_run_actions_without_continue_on_error_records_error(): + """A failing action without continue_on_error should appear in actions_errors.""" + failing = _mock_action("failing", continue_on_error=False, run_raises=Exception("boom")) + + _, actions_errors = _make_workflow(actions=[failing]).run_actions() + + assert len(actions_errors) == 1 + assert "failing" in actions_errors[0] + + +def test_run_actions_continue_on_error_next_action_still_runs(): + """After a continue_on_error action failure, the next action should still execute.""" + failing = _mock_action("failing", continue_on_error=True, run_raises=Exception("boom")) + next_action = _mock_action("next") + + _make_workflow(actions=[failing, next_action]).run_actions() + + next_action.run.assert_called_once()