Chapter 7: Control Flow
Topic 41

Blocks and Error Handling

ConceptControl Flow

A block groups a set of tasks so a directive applies to all of them at once, and paired with rescue and always it is Ansible's try/except/finally. Tasks in the block run in order; if any fails, control jumps to rescue to handle the error, and always runs no matter what — clean shutdown, unlock, or notification.

This is how you turn a deploy that aborts mid-flight into one that drains, recovers, and leaves the host in a known state. Without it, a failed task halts the play and walks away from whatever half-applied mess it left behind. With it, you get a defined recovery path and a guaranteed cleanup.

Ansible's try / except / finally
block (try)
rescue (on error)
always (finally)

block as a Group

Wrap related tasks — configure nginx, template the vhost, validate the config — in one block, then attach when:, become:, or tags: to the block and it applies to every task inside, instead of repeating the directive on each. A block is the unit of shared context: one become: true covers the whole group, one when: gates all of it, and the tasks read cleanly because the common keyword lives in one place.

rescue as except

If any task in the block fails, the remaining block tasks are skipped and rescue runs. Inside rescue the variables ansible_failed_task and ansible_failed_result name exactly what broke, so you can log it precisely or drive a rollback off it rather than emitting a generic "something failed" message. The rescue is your except clause — it only runs when the try block actually threw.

always as finally

always runs whether the block succeeded or the rescue fired. It is the place for cleanup that must happen on every path: return the host to the HAProxy pool, remove a lock file, restart what you stopped. Anything you put in the last task of the block instead runs only on the success path — if an earlier task fails, that trailing task is skipped along with the rest, and the cleanup never happens. always is the only place that survives a mid-block failure.

Recovery, Not Suppression

A successful rescue marks the play as not-failed for that host, and the play continues — that is recovery. It differs sharply from ignore_errors, which simply swallows the failure with no handling step and no rollback. Rescue handles the error and brings the host back to a sane state; ignore_errors pretends nothing went wrong. People expecting a rescued play to still exit non-zero are surprised, because once a host is rescued it is considered recovered and reports success.

The Larkspur Pattern

The web deploy block stops gunicorn, swaps the release symlink, and restarts. If the restart fails, rescue rolls back to the previous release symlink so the host serves the old code instead of nothing. And always re-enables the host in HAProxy on every path, so a failed deploy never leaves a host drained and dark. The result is that a botched release degrades to "still running the last good build" rather than "out of the pool with a stopped service."

block / rescue / always — roll back on failure, always re-enable the host
- name: Deploy the new release with rollback
  block:
    - name: Stop gunicorn
      ansible.builtin.service: { name: gunicorn, state: stopped }
    - name: Point current at the new release
      ansible.builtin.file:
        src: "{{ new_release }}"
        dest: /srv/larkspur/current
        state: link
    - name: Start gunicorn on the new release
      ansible.builtin.service: { name: gunicorn, state: started }
  rescue:
    - name: Roll back to the previous release
      ansible.builtin.file:
        src: "{{ previous_release }}"
        dest: /srv/larkspur/current
        state: link
  always:
    - name: Re-enable the host in HAProxy
      ansible.builtin.command: /usr/local/bin/haproxy-enable {{ inventory_hostname }}

One detail worth internalizing: rescue catches task failures, not connection failures. A host Ansible cannot SSH into drops out of the play entirely and never reaches the rescue — the block can only recover from things that happen after a successful connection.

Common Mistakes
  • Expecting rescue to fire on an unreachable host — it catches task failures, not connection failures; a host Ansible cannot SSH into drops out of the play entirely and never reaches the rescue.
  • Putting cleanup in the last task of a block instead of in always — if an earlier block task fails, that last task is skipped and the cleanup never runs, leaving a host drained or a service stopped.
  • Assuming a successful rescue still fails the play — it does not; once rescued, the host is considered recovered and continues, which surprises people expecting a non-zero exit.
  • Nesting business logic so deep in one giant block that a mid-block failure leaves half-applied state with no rollback path in rescue — keep blocks focused enough that the rescue can actually undo them.
  • Reaching for block/rescue to mask a recurring failure instead of fixing it — a rescue that papers over the same error every run hides a real problem behind a green play.
Best Practices
  • Wrap any multi-step change that can fail partway — a release swap, a schema migration — in block/rescue/always, with the rollback in rescue and the un-drain in always.
  • Put every must-run cleanup in always, never in a trailing block task, so it executes on both the success and the failure path.
  • Read ansible_failed_task and ansible_failed_result inside rescue to log precisely what failed rather than emitting a generic message.
  • Apply shared become: / when: / tags: at the block level to cut repetition across the grouped tasks.
  • Keep each block small enough that its rescue can fully reverse it, rather than letting one block grow past the point its rollback can cover.
Comparable tools Puppet · Chef · Salt no direct equivalent — error handling there is per-resource, not a grouped try/except/finally Shell trap and set -e discipline is the closest analog blocks formalize

Knowledge Check

A task in a block fails, the rescue handles it, and the play continues. What is the play's status for that host?

  • Not-failed — a successful rescue marks the host as recovered, and execution continues normally
  • Failed — the original block failure still propagates past the rescue, and the host exits non-zero
  • Skipped — once a rescue runs, the host is dropped from every remaining task in the play
  • Unreachable — a completed rescue re-flags the host as a connection-level failure

Why does cleanup belong in always rather than the last task of the block?

  • If an earlier block task fails, the trailing task is skipped, but always runs on every path
  • The last task of a block always runs twice, so putting cleanup there would double-apply it
  • always runs before the block executes, which makes it the right place to stage cleanup
  • A trailing block task cannot call modules, so only the always section may invoke them

A host cannot be reached over SSH during a play with a block/rescue. What happens?

  • The host drops out of the play entirely — rescue catches task failures, not connection failures, so it never runs
  • The rescue fires to handle the unreachable host gracefully, reopens the SSH session, and resumes the rest of the block
  • The always section reconnects the host automatically over a fresh transport and re-runs the whole block from the top
  • The whole play aborts for every host in the batch the instant any single host turns unreachable mid-run

How does a rescue differ from ignore_errors: true?

  • rescue runs a handling step that can recover or roll back; ignore_errors swallows the failure with no handling at all
  • They are identical — rescue is just the block-level spelling of a task's ignore_errors, expanded to cover the whole block
  • ignore_errors runs the rollback steps automatically, while rescue only logs the failure to the recap for later review
  • rescue suppresses the failure silently with no log entry, while ignore_errors still fails the play at the very end

You got correct