It is rather sad that the default YAML script system lacks support for this

Problem statement

If you’re writing HomeAssistant scripts, you might have noticed that there is no obvious way to detect whether an action failed. By default failing actions will cause the calling script to fail (with good errors in the execution trace!), setting continue_on_error attribute on an action allows continuing rather than terminating but there is no way to actually check if the step succeeded or not.

Workarounds

State tests

A commonly suggested workaround is to do something like the following:

sequence:
  - alias: "Turn on flaky light, continuing on error"
    action: light.turn_on
    target:
      entity_id: light.flaky
    continue_on_error: true

  - alias: "Wait to see if the light actually turned on"
    wait_template: "{{ is_state('light.flaky', 'on') }}"
    timeout: 10
    continue_on_timeout: true

  - alias: "Check if turning on light failed"
    if:
      - condition: template
        value_template: "{{ not wait.completed }}"
    then:
      - # Didn’t work

  # Further steps

Essentially, it attempts to perform the target action with continue_on_error then polls for whether the given action actually was performed or not.

It may work, but:

  • It is slow: There is no way to know how long to wait for the given action to be applied so generous timeouts are needed
  • It is fragile: Since there is no way to know how long to wait, a too low timeout will lead to false-negatives
  • It is ugly: This is just not how we do proper software development…
  • It relies on state changes: This is the actual deal-breaker in some cases as some actions simply do not update state – for instance, in my specific case I wanted to send a push notification and that produces exactly zero locally observable state!

Sub-script response variable hackery

A more involved trick is to create a sub-script and (ab)use the fact that scripts can return a response, but only if they succeed!

First the primed wrapper script turn_on_light_flaky_with_response, it is possible to make the action, target entity_id and possible extra arguments dynamic using a fields: list (not shown here):

sequence:
  - alias: "Attempt the action, FAILING on error"
    action: light.turn_on
    target:
      entity_id: light.flaky


  - alias: "Define response variable"
    variables:
      ok: true

  - alias: "Return value of `ok` variable"
    stop: Action Succeeded
    response_variable: ok

# Allow many parallel invocations of this script
mode: parallel
max: 100000000

Now the main script:

sequence:
  - alias: "Turn on flaky light with response"
    action: script.turn_on_light_flaky_with_response
    response_variable: turn_on_flaky_light_result
    continue_on_error: true  # We don’t fail here if the sub-script failed

  - alias: "Check if turning on light failed"
    if:
      - condition: template
        value_template: "{{ turn_on_flaky_light_result is not defined }}"
    then:
      - # Didn’t work

The trick here is that if the turn_on_light_flaky_with_response script fails on step 1 (turning on the flaky light), it will never get to the stop action that allows it to return true so the response_variable will only be set in the calling script if and only if the sub-script did not fail. Jinja2 templates can test for the existence of a variable, so this serves as a canary to decide whether the previous step actually failed or not.

It works, but:

  • It is ugly: Creating a subtemplate for every action, or at least action class, is not exactly pretty.
  • It is really non-obvious: If you’re not that deep into the underlying technologies, you’ll probably have to re-read the above twice to figure out exactly what the heck is going on – we’re abusing an undocumented loop-hole here and it really shows.
  • It is repetitive: If you have multiple different actions, you need to either create one sub-script per action or try grouping them in a way that maximally makes use of fields.

Specifically for using fields to avoid repetition:

  • For designing fields you effectively have to use the visual script editor since there does not appear to be any documentation on the type and shape of script fields.
  • The “Action” field type allows callers to enter complete actions when calling the sub-script, however there is no way to actually insert these action lists into the sub-script (inserting only works for blueprint apparently…)! This makes that rather cool feature absolutely useless!

That said, this solution might be “good enough” if all you need is just a single script to handle that one stupid light!

Repeating failing actions wrapper for this approach

This is an example of using the above response variable hackery to implement a generic action/entity class retry logic that can be used from regular scripts:

# Script: `action_with_result`

alias: Perform Failable Action with Result
description: >-
  Invoke the given action with the given entity, *returning* when the action
  succeeded or failing otherwise.
icon: mdi:alert-circle-check-outline

fields:
  action:
    name: Target action to call
    selector:
      text: {}
    required: true

  entity:
    name: Target entity to call action on
    selector:
      entity: {}
    required: true

sequence:
  - alias: "Attempt the action, FAILING on error"
    action: "{{ action }}"
    target:
      entity_id: "{{ entity }}"

  - alias: "Define response variable"
    variables:
      ok: true

  - alias: "Return value of `ok` variable"
    stop: Action Succeeded
    response_variable: ok

mode: parallel
max: 100000000
# Script: `action_with_retries`

alias: Repeat Failing Action
description: >-
  Repeats the given action with the given entity until it either succeeds
  or the number of retries has been exhausted.

  Return structure:
    * `ok` (boolean): `true` if all actions succeeded at some point, `false` otherwise
    * `retries` (integer): How often the actions were retried before succeeding (in range 0 to `retries`)
icon: mdi:repeat-variant

fields:
  action:
    name: Target action to call
    selector:
      text: {}
    required: true

  entity:
    name: Target entity to call action on
    selector:
      entity: {}
    required: true

  retries:
    name: Number of Retries
    selector:
      number:
        min: 1
    default: 3

  delay:
    name: Retry Delay
    selector:
      duration:
        enable_millisecond: true
    default:
      hours: 0
      minutes: 0
      seconds: 1
      milliseconds: 0

  fail_on_error:
    name: Fail on Too Many Retries
    description: >-
      Note that you must still set a “Response Variable” when using this
      option or reported errors will not cause the calling script to exit.
    selector:
      boolean: {}
    default: false


sequence:
  - alias: "Define Repetition Counter and Set Default Success State and Field Defaults"
    variables:
      counter: 0
      succeeded: false
      retries: "{{ retries | default(3) }}"
      delay: "{{ delay | default({'seconds': 1}) }}"
      fail_on_error: "{{ fail_on_error | default(false) }}"

  - alias: "Repeat `retries` times"
    repeat:
      count: "{{ retries }}"
      sequence:
        - action: script.action_with_result
          data:
            action: "{{ action }}"
            entity: "{{ entity }}"
          response_variable: succeeded
          continue_on_error: true

        - alias: "Stop if Successful"
          if:
            - alias: "If Result was OK"
              condition: template
              value_template: "{{ succeeded }}"
          then:
            - alias: "Create Result Variable"
              variables:
                result:
                  ok: true
                  retries: counter

            - stop: Execution Successful
              response_variable: result
          else:
            - alias: "Increment Repetition Counter and Reset Success State"
              variables:
                counter: "{{ counter + 1 }}"
                succeeded: false

            - alias: Perform Delay unless Last Retry
              if:
                - alias: "If is not Last Retry"
                  condition: template
                  value_template: "{{ counter < retries - 1}}"
              then:
                - alias: "Perform Retry Delay"
                  delay: "{{ delay }}"

  - if:
      - alias: "If `fail_on_error` is True"
        condition: template
        value_template: "{{ fail_on_error }}"
    then:
      - stop: "Too Many Retries"
        error: true
    else:
      - alias: "Create Result Variable"
        variables:
          result:
            ok: false
            retries: counter

      - stop: "Too Many Retries"
        response_variable: result

mode: parallel
max: 100000000

This could be used, for example, like in the following:

sequence:
  - alias: "Turn on Flaky Light with Retries"
    action: script.action_with_retries
    data:
      action: light.turn_on
      entity: light.flaky
      fail_on_error: true  # Fails if it didn’t work after 3 retries with 1 second delay between retries

  - # Other steps that assume the light is ON

Using a native Python script

As an alternative to the above, which relies entirely on YAML scripts only, I created a more convenient solution that instead uses the python_script integration’s restricted sandbox to expose the equivalent of the second solution as a fully dynamic building block that executes an arbitrary list of tasks created using the visual script editor only (something that neither of these solutions can provide).

Unlike the Retry and PyScript HACS integrations it is fully covered by HomeAssistant stability guarantees and also does not require connecting your HomeAssistant installation to GitHub!

Unlike AppDaemon it does not require an external add-on and moving all your logic from the internal HomeAssistant scripting engine into that.

With these in place, it is pretty easy to forget that this isn’t a native feature when looking through scripts!

Preparation

Make sure you have the File Editor or Samba add-on installed to get access to the HomeAssistant “config” directory!

Enabling the python_script integration

The python_script integration cannot be enabled in the HomeAssistant web interface, so you need to use File Editor or Samba (see previous step) to open the “config” directory and manually edit the “configuration.yaml” file.

After you have located and opened the file, add the following lines at the end of the file, making sure that there are no additional spaces in front of any of the lines:

python_script:
logger:
  default: info

This enables the python_script integration, as well as logging from the installed Python scripts in case things go wrong.

Adding the script descriptions

Inside the “config” directory, create a new directory called “python_scripts” and open it.

Inside create a new file called “services.yaml”, this contains the descriptions for the installed scripts displayed in the visual HomeAssistant script editor (it is essentially a list of YAML scripts without the action sequence part):

actions_with_error_state:
  name: Call Actions returning Error State
  icon: mdi:alert-circle-check-outline
  description: >-
    Invoke the given action list, *returning* whether the given action list
    succeeded or information about the failing error.

    Return structure:
      * `ok` (boolean): `true` if all actions succeeded, `false` otherwise
      * `error` (`None` or object): If an action failed, information about the error
         * `action` (string): Name of the failing action
         * `idx` (integer): 0-based index of the failing action in the list of actions
         * `exc` (exception): Caught service exception, can be stringified to get error message
  fields:
    actions:
      name: Actions to execute
      selector:
        action: {}
      required: true

actions_with_retries:
  name: Call Actions and Restart on Error
  icon: mdi:repeat-variant
  description: >-
    Repeats the given action steps until they either succeed or the number of
    retries has been exhausted.

    Return structure:
      * `ok` (boolean): `true` if all actions succeeded at some point, `false` otherwise
      * `retries` (integer): How often the actions were retried before succeeding (in range 0 to `retries`)
      * `error` (`None` or object): If an action failed, information about the error
          * `action` (string): Name of the failing action
          * `idx` (integer): 0-based index of the failing action in the list of actions
          * `exc` (exception): Caught service exception, can be stringified to get error message

    If `fail_on_error` is true, this action will fail and no value is returned.
  fields:
    actions:
      name: Actions to execute
      selector:
        action: {}
      required: true

    retries:
      name: Number of Retries
      description: >-
        How often to retry *all* given actions before returning the first
        encountered error.
      selector:
        number:
          min: 1
      default: 3

    delay:
      name: Retry Delay
      description: >-
        Delay between retries of the action list.
      selector:
        duration:
          enable_millisecond: true
      default:
        hours: 0
        minutes: 0
        seconds: 1
        milliseconds: 0

    fail_on_error:
      name: Fail on Too Many Retries
      description: >-
        Note that you must still set a “Response Variable” when using this
        option or reported errors will not cause the calling script to exit.
      selector:
        boolean: {}
      default: false

Thanks to extended capabilities that Python scripts offer over YAML scripts, the result types from the scripts are a little nicer and can use the flexible action field selector but otherwise it is essentially the same.

Note that as of HomeAssistant Core 2026.7.4 there is an issue that the above descriptions will not be displayed correctly in the visual script editor but this does not affect their functionality.

Adding a nicer version of action_with_result

Inside the “config/python_scripts” directory create a file named “actions_with_error_state.py”:

actions = data["actions"]  # Required action list

for idx, action in enumerate(actions):
    domain, name = action["action"].split(".", 1)  # "light.turn_on"
    payload = action.get("data", {})
    payload.update(action.get("target", {}))

    try:
        logger.info(f"Running action “{domain}.{name}”")
        hass.services.call(domain, name, payload, blocking=True)
    except Exception as exc:
        logger.warning(f"Action “{domain}.{name}” failed: {exc}")
        output["ok"] = False
        output["error"] = {"action": action["action"], "idx": idx, "exc": exc}
        break
else:
    output["ok"] = True
    output["error"] = None

This simply calls (using “hass.services.call”) each of the given actions in the action field selector format the visual editor creates, catching (using “except”) any errors encountered and turning them into a result with details about which action failed and why. No unfortunate result hacks needed!

Adding a nicer version of action_with_retries

Inside “config/python_scripts” create a file named “actions_with_retries.py”:

actions = data["actions"]  # Required action list
retries = data.get("retries", 3)
delay = data.get("delay", 1)
fail_on_error = data.get("fail_on_error", False)

# Convert delay to fraction of second without violating sandbox
try:
    delay = float(delay)
except TypeError:
    delay = datetime.timedelta(
        hours = delay.get("hours", 0),
        minutes = delay.get("minutes", 0),
        seconds = delay.get("seconds", 0),
        milliseconds = delay.get("milliseconds", 0),
    ).total_seconds()

first_error = None
for retry in range(retries):
    # Call `actions_with_error_state` to perform failable action run
    result = hass.services.call(
        "python_script", "actions_with_error_state",
        { "actions": actions },
        blocking=True,
        return_response=True,
    )
    if result["ok"]:
        # Forward success with added retry count
        output.update(result)
        output["retries"] = retry
        break

    # Save first error to forward it in case of ultimate failure
    if first_error is None:
        first_error = result

    # Apply delay only if not the last iteration
    if retry < retries - 1:  
        time.sleep(delay)

# Handle if `break` was not reached / we are out of retries
else:
    # Forward first encountered error either as exception (“action failure”)
    # or structured valued interpretable by caller
    if fail_on_error:
        raise first_error["error"]["exc"]
    else:
        output.update(first_error)
        output["retries"] = retries

This delegates the failable action task to the other action, then adds the number of retries required on success and also preserves the first encountered error on error. Unlike the YAML based solution fail_on_error will actually cause the (first) error encountered by the invoked action, rather than a generic “Too Many Retries” to be logged in the invoking script trace!

Applying changes

To load the newly enabled python_script integration, go to
SettingsMenu → Restart Home AssistantRestart Home AssistantRestart
to restart Home Assistant Core, future changes to the list of Python scripts only require a
SettingsMenu → Quick Reload
though.

Usage example

Here’s the script I’m using the above python_script action with:

alias: "Door Bell: Send and Dismiss Notification (Background Task)"
icon: mdi:alarm-bell

sequence:
  - alias: "Ntfy: Publish Notification (with Retries)"
    action: python_script.actions_with_retries
    data:
      actions:
        - action: ntfy.publish
          data:
            priority: "5"
            sequence_id: doorbell
            tags:
              - bell
            title: Doorbell
            message: Doorbell button has been pressed
          target:
            device_id: be3fc64c497985de2d8705e856a9d3fb
      fail_on_error: true
    response_variable: result  # Unused variable, but property is required for failure to be reported

  - delay:
      hours: 0
      minutes: 1
      seconds: 0
      milliseconds: 0

  - alias: "Ntfy: Dismiss Notification (with Retries)"
    action: python_script.actions_with_retries
    data:
      actions:
        - action: ntfy.clear
          target:
            device_id: be3fc64c497985de2d8705e856a9d3fb
          data:
            sequence_id: doorbell
          enabled: true
      delay:
        hours: 0
        minutes: 0
        seconds: 1
        milliseconds: 0
      retries: 3
      fail_on_error: true
    response_variable: result  # Unused variable, but property is required for failure to be reported

mode: restart

It was entirely created using the Visual Editor:

Visual Editor showing a three-step script called “Door Bell: Send and Dismiss Notification (Background Task)” consisting of the “Ntfy: Publish Notification (with Retries)”, “Delay for 1:00” and “Ntfy: Dismiss Notification (with Retries)” steps. The first step is selected showing how it contains a regular “Ntfy: Publish notification” section with target, title and other properties.

Was this article useful? Consider leaving a star!