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.