AnsibleXXIII · Tags, Blocks and Error HandlingError handling
rescue and always
What you'll learn
- Predict the recap and exit code for a rescued failure and for a failed rescue
- Write a rescue that leaves the host in a state you can name
- Use ansible_failed_task and ansible_failed_result for a diagnosable message
- Decide what belongs in always rather than in rescue
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
A block can carry two more sections:
- name: guarded change
block:
- name: this one works
- name: this one fails
- name: never reached
rescue:
- name: report what failed
- name: compensating action
always:
- name: verify state
rescue runs when a task in block fails. always runs regardless — after
a clean block, after a rescue, and after a rescue that itself failed.
The semantics are worth executing rather than recalling, because two of them are counter-intuitive and both matter for how a run is monitored.
A successful rescue
$ ansible-playbook -i inventory.ini rescue.ymlTASK [this one works] **********************************************************
ok: [localhost] => {
"msg": "step-one"
}
TASK [this one fails] **********************************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "simulated failure"}
TASK [report what failed] ******************************************************
ok: [localhost] => {
"msg": "failed task was this one fails / result msg simulated failure"
}
TASK [compensating action] *****************************************************
ok: [localhost] => {
"msg": "restored"
}
TASK [verify state] ************************************************************
ok: [localhost] => {
"msg": "verified"
}
TASK [after the block] *********************************************************
ok: [localhost] => {
"msg": "play continues"
}
PLAY RECAP *********************************************************************
localhost : ok=5 changed=0 unreachable=0 failed=0 skipped=0 rescued=1 ignored=0Four things to take from that recap, in order of how often they are got wrong:
never reacheddid not run. A failure abandons the rest of the block. This is the opposite of block-levelignore_errorsfrom lesson 4, where the remaining tasks continue.failed=0. The failed status was reverted. As far as the failure accounting is concerned, this host did not fail.rescued=1. The failure is still recorded, in its own counter. This is what the documentation means by the statistics still reporting a failure — it is not in thefailedcolumn.- Exit code
0, andafter the blockran. The play continues as if the original task had succeeded.
A rescue that itself fails
$ ansible-playbook -i inventory.ini rescue-fails.yml; echo "exit=$?"TASK [this one fails] **********************************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "original failure"}
TASK [rescue also fails] *******************************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "rescue failure"}
TASK [always still runs] *******************************************************
ok: [localhost] => {
"msg": "always-ran"
}
PLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=1 skipped=0 rescued=1 ignored=0
exit=2failed=1 rescued=1 together. The rescue was entered — hence rescued=1 —
and did not complete, so the failure stands. after the block did not run:
the host is out of the play. Exit code 2.
always ran anyway. That is the guarantee always gives you and the
reason to put verification and cleanup there rather than in rescue.
What belongs in a rescue
Not logging. A rescue whose entire content is a debug saying “something
went wrong” is a comment with a runtime cost.
A rescue is where compensating actions go: code that puts the host back into a state you can name.
- name: swap in the new configuration
block:
- name: back up the running config
ansible.builtin.copy:
src: /etc/webapp/app.conf
dest: /etc/webapp/app.conf.prev
remote_src: true
mode: '0640'
- name: write the new config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/webapp/app.conf
mode: '0640'
notify: webapp restart webapp
- name: restart now rather than at the end of the play
ansible.builtin.meta: flush_handlers
- name: confirm the service is serving
ansible.builtin.uri:
url: "http://127.0.0.1:{{ webapp_listen_port }}/healthz"
status_code: 200
retries: 6
delay: 5
rescue:
- name: say what failed, with detail an operator can act on
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}: {{ ansible_failed_task.name }} failed
({{ ansible_failed_result.msg | default('no message') }}).
Restoring the previous configuration.
- name: restore the previous configuration
ansible.builtin.copy:
src: /etc/webapp/app.conf.prev
dest: /etc/webapp/app.conf
remote_src: true
mode: '0640'
- name: restart on the restored config
ansible.builtin.systemd_service:
name: webapp
state: restarted
- name: fail the host so the batch stops here
ansible.builtin.fail:
msg: >-
Configuration rollout failed on {{ inventory_hostname }} and the
previous config was restored. Investigate before continuing.
always:
- name: record the state the host was left in
ansible.builtin.command: systemctl is-active webapp
register: webapp_final_state
changed_when: false
failed_when: false
- name: report it
ansible.builtin.debug:
msg: "{{ inventory_hostname }} webapp is {{ webapp_final_state.stdout }}"
Read the rescue in order: say what happened, restore, restart, then fail
deliberately.
That last task is the one people leave out. Without it, the rescue succeeded,
the recap shows failed=0, the exit code is zero, and the rolling deployment
carries on to the next batch — having rolled the change back on this node and
told nobody. ansible.builtin.fail at the end of the rescue is how you say
I handled it, and I still want this to count as a failure.
ansible_failed_task and ansible_failed_result
Both are available inside a rescue and nowhere else.
ansible_failed_task— the task object..nameis the useful field.ansible_failed_result— the module’s return value, includingmsg,rc,stdoutandstderrwhere the module provides them.
$ ansible-playbook -i inventory.ini rescue.ymlTASK [report what failed] ******************************************************
ok: [localhost] => {
"msg": "failed task was this one fails / result msg simulated failure"
}Use | default(...) on the fields. Not every module returns msg, and an
undefined-variable error inside a rescue is an unusually confusing thing to
debug — the rescue fails, the original failure stands, and the message points
at your error handling rather than at the fault.
meta: flush_handlers inside a rescue
Handlers notified before the failure are still pending when the rescue runs. If the rescue is about to restore a config, you usually want those handlers to run first — or you want them not to run at all.
$ ansible-playbook -i inventory.ini tasksfrom.ymlTASK [notify then fail] ********************************************************
"msg": "changing"
TASK [fail after notify] *******************************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "boom"}
TASK [flush handlers before compensating] **************************************
RUNNING HANDLER [multi : multi handler] ****************************************
"msg": "handler-fired"
TASK [compensate] **************************************************************
"msg": "compensated"
PLAY RECAP *********************************************************************
localhost : ok=7 changed=1 unreachable=0 failed=0 skipped=0 rescued=1 ignored=0Decide deliberately which you want. Flushing first means the service restarts on the half-written config and then your rescue restores and restarts again — two restarts. Not flushing means the pending handler fires at the end of the play, after your rescue restored the old config, restarting on the restored file, which is usually harmless and occasionally exactly wrong.
The version above is explicit, which is the point.
Where this goes next
The pattern here — back up, change, verify, restore on failure — is the building block of a rollback strategy, not the strategy itself. Part XLVIII covers rollback properly: what state to restore from, how far back, how to verify the restore worked, and what to do when the compensating action is itself impossible.
What this lesson owns is the mechanism. A rescue is where a compensating
action goes, always is where verification goes, and fail at the end of a
rescue is how you keep the failure visible.
Knowledge check
Knowledge check · 4 questions
Q1. A block fails on its second of three tasks. A rescue is defined and completes successfully. What does the recap show and what is the exit code?
Q2. Which are true when a task inside a rescue fails? Select all that apply.
Q3. An always task that references ansible_failed_task.name without a guard is safe, because always only ever runs after a rescue has populated it.
Q4. A rescue restores the previous configuration, restarts the service, and ends there. What is the operational problem with that?
Passing score: 75%. Answers are checked in this browser.