Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/mint.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
75 changes: 75 additions & 0 deletions docs/workflows/examples/continue-on-error.mdx
Original file line number Diff line number Diff line change
@@ -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."
```

<Note>
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.
</Note>
48 changes: 48 additions & 0 deletions docs/workflows/syntax/steps-and-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```

<Note>
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.
</Note>
1 change: 1 addition & 0 deletions keep-ui/entities/workflows/model/yaml.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
24 changes: 16 additions & 8 deletions keep-ui/shared/ui/MonacoCELEditor/MonacoCel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | 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<MonacoCelProps["onMonacoLoaded"] | null>(
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions keep/step/step.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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(
Expand Down
23 changes: 19 additions & 4 deletions keep/workflowmanager/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")
Expand Down
39 changes: 39 additions & 0 deletions tests/test_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading