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()