Skip to main content
RunBook Academy

AnsibleXXIII · Tags, Blocks and Error HandlingError handling

rescue and always

Advanced⏱ ~22 minansible-playbook

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

Not yet marked complete on this device.

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

Read-only / Safethe full sequence
$ ansible-playbook -i inventory.ini rescue.yml
TASK [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=0

Four things to take from that recap, in order of how often they are got wrong:

  1. never reached did not run. A failure abandons the rest of the block. This is the opposite of block-level ignore_errors from lesson 4, where the remaining tasks continue.
  2. failed=0. The failed status was reverted. As far as the failure accounting is concerned, this host did not fail.
  3. 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 the failed column.
  4. Exit code 0, and after the block ran. The play continues as if the original task had succeeded.

A rescue that itself fails

Read-only / Safefailure inside the rescue
$ 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=2

failed=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. .name is the useful field.
  • ansible_failed_result — the module’s return value, including msg, rc, stdout and stderr where the module provides them.
Read-only / Safeboth variables in a rescue message
$ ansible-playbook -i inventory.ini rescue.yml
TASK [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.

Read-only / Safeflushing handlers from inside a rescue
$ ansible-playbook -i inventory.ini tasksfrom.yml
TASK [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=0

Decide 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

  1. 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?

  2. Q2. Which are true when a task inside a rescue fails? Select all that apply.

  3. 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.

  4. 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.