Ansible Deep Dive Part 11: Error Handling in Anger — Blocks, Rescue, and Partial Failures

Every playbook in this series so far has assumed the happy path — every task succeeds, in order, on every host. Production doesn’t work that way. This post covers what Ansible gives you for the moment something goes wrong partway through a run, and how to fail deliberately instead of accidentally.

block / rescue / always: try/except for playbooks

- name: Attempt a risky database migration
  block:
    - name: Take a backup first
      ansible.builtin.command: pg_dump webapp > /backups/pre-migration.sql
      args:
        creates: /backups/pre-migration.sql

    - name: Run the migration
      ansible.builtin.command: /opt/app/migrate.sh
      register: migration_result

    - name: Verify migration succeeded
      ansible.builtin.command: /opt/app/verify_schema.sh
  rescue:
    - name: Restore from backup on failure
      ansible.builtin.command: psql webapp < /backups/pre-migration.sql

    - name: Alert that a rollback happened
      ansible.builtin.debug:
        msg: "Migration failed on {{ inventory_hostname }}, rolled back"
  always:
    - name: Clean up temp files regardless of outcome
      ansible.builtin.file:
        path: /tmp/migration_workdir
        state: absent

block groups related tasks. If any task inside it fails, execution jumps straight to rescue — the tasks there run instead of the remaining block tasks, not in addition to them. always runs unconditionally, whether the block succeeded outright or failed and was rescued — the natural place for cleanup that has to happen either way. This is the direct Ansible equivalent of try/except/finally, and it’s the right tool any time “if this fails, undo what we just did” matters more than just reporting red and stopping.

Registered variables inside a rescue block include a special one worth knowing: ansible_failed_task and ansible_failed_result, giving the rescue logic access to exactly what failed and why, rather than having to guess.

ignore_errors vs failed_when vs ignore_unreachable

Three different tools for three genuinely different situations, and using the wrong one is a common source of playbooks that either hide real failures or stop for ones that don’t matter.

- name: A failure here is fine, keep going
  ansible.builtin.command: /opt/app/optional_cleanup.sh
  ignore_errors: true

ignore_errors: true is the bluntest instrument — the task can fail entirely and the play continues regardless. I use this rarely and reluctantly; it silences the exit code along with any output, so a genuine problem in that task disappears just as quietly as an expected, harmless one.

- name: Check a service, but a non-zero exit isn't necessarily a failure
  ansible.builtin.command: /opt/app/healthcheck.sh
  register: health
  failed_when: health.rc not in [0, 2]   # rc=2 means "degraded but running"

failed_when redefines what “failed” even means for this specific task, based on the actual registered result — the right tool when a command’s exit code doesn’t map cleanly onto Ansible’s pass/fail assumption, which is common with tools that use exit codes to convey more than binary success.

- name: Configure hosts, but don't stop the whole run if one is down
  hosts: all
  ignore_unreachable: true
  tasks: ...

ignore_unreachable is specifically for a host that can’t be reached at all — SSH connection refused, DNS failure, timeout — as distinct from a host that connects fine but a task on it fails. Useful for maintenance-window playbooks against a large fleet where one or two boxes being temporarily offline for unrelated reasons shouldn’t abort the whole run.

Controlling how much failure is acceptable across a fleet

- hosts: webservers
  max_fail_percentage: 30
  tasks: ...

By default, one host failing a task doesn’t stop the play on the other hosts — but the play as a whole is still marked failed at the end. max_fail_percentage sets a threshold: if more than 30% of targeted hosts fail, the entire play aborts immediately rather than limping through the remaining hosts with a known-bad failure rate. This is the setting I’d want on any playbook that’s genuinely a canary-style rollout — stop the whole thing early if it’s clearly going wrong, rather than finding out only after every host has been touched.

- hosts: dbservers
  any_errors_fatal: true
  tasks: ...

any_errors_fatal is the opposite extreme: a single failure on a single host immediately aborts the play everywhere, including hosts still mid-task. Appropriate for tightly-coupled operations — a database cluster where a failed step on one node genuinely means the others shouldn’t proceed either, because the cluster is now in an inconsistent state no further task should build on top of.

retries and until: polling for a condition, not just failing fast

- name: Wait for the app to report healthy after restart
  ansible.builtin.uri:
    url: "http://{{ inventory_hostname }}:{{ app_port }}/healthz"
    status_code: 200
  register: health_result
  until: health_result.status == 200
  retries: 10
  delay: 5

until combined with retries and delay polls a task repeatedly — up to 10 attempts, 5 seconds apart — until the condition passes, rather than treating the first non-200 response as a hard failure. This is the correct pattern for anything with a startup delay: a service that takes a few seconds to bind its port after systemd reports it started, a database that needs a moment to accept connections after a restart, a FortiGate reboot after a firmware upgrade. Reaching for a flat pause: seconds: 30 guess instead is the more common mistake — it’s either too short under load or wastes real time when the service comes up faster than the worst case you guessed for.

Part 12 pulls all of this — plus everything from Parts 1 through 10 — into the actual style guide I follow day to day: the conventions, the things I’ve learned to avoid, and the shape a “good” Ansible project has when someone other than me has to pick it up cold.