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
Settings → Menu → Restart Home Assistant → Restart Home Assistant → Restart
to restart Home Assistant Core, future changes to the list of Python scripts
only require a
Settings → Menu → 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:
