Skip to main content
RunBook Academy

AnsibleXXIII · Tags, Blocks and Error HandlingError handling

When a retry makes it worse

Advanced⏱ ~20 minansible-playbook

What you'll learn

  • State how many times retries: N actually executes a task
  • Decide whether a failure is transient enough to be worth retrying
  • Recognise a retry that multiplies damage rather than absorbing a fault
  • Use meta: clear_host_errors correctly, including what it cannot do

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 retry is a claim: this will probably work if I wait. When the claim is true, a retry loop turns a flaky dependency into a non-event. When it is false, it converts a fast failure into a slow one, or — worse — repeats a change that was not safe to repeat.

The keywords:

Read-only / Safethe three keywords, as documented by the installed version
$ ansible-doc -t keyword until retries delay
delay:
applies_to:
- Task
- Handler
description: Number of seconds to delay between retries. This setting is only used
  in combination with `until`.
priority: 0
template: explicit
type: float
retries:
applies_to:
- Task
- Handler
description: Number of retries before giving up in a `until` loop. This setting
  is only used in combination with `until`.
priority: 0
template: explicit
type: int
until:
applies_to:
- Task
- Handler
description: This keyword implies a '`retries` loop' that will go on until
  the condition supplied here is met or we hit the `retries` limit.
priority: 0
template: implicit
type: list

until is the one that turns the loop on. retries and delay do nothing without it.

How many attempts you actually get

The word “retries” suggests attempts in addition to the first one. Measured on 2.21.3, it is the total.

A task with until: false — a condition that can never be satisfied — wrapped in a block whose rescue reports ansible_failed_result.attempts:

Read-only / Saferetries: 1, 2 and 3, measured
$ for n in 1 2 3; do ansible-playbook -i inventory.ini r$n.yml; done
--- retries: 1 ---
RETRYING lines: 1
retries=1 -> attempts=1

--- retries: 2 ---
RETRYING lines: 2
retries=2 -> attempts=2

--- retries: 3 ---
RETRYING lines: 3
retries=3 -> attempts=3

retries: N gives N attempts, not N + 1. And omitting retries while using until gives you three:

Read-only / Safethe default
$ ansible-playbook -i inventory.ini rdef.yml
default retries -> attempts=3

One display quirk to expect: the countdown in the retry message reaches zero before the task gives up, so the last line before the failure reads (0 retries left). That is cosmetic — the attempt count above is the number that matters.

Read-only / Safea retry loop that succeeds on the third attempt
$ ansible-playbook -i inventory.ini failedwhen.yml
TASK [retries and until] *******************************************************
FAILED - RETRYING: [localhost]: retries and until (2 retries left).
FAILED - RETRYING: [localhost]: retries and until (1 retries left).
ok: [localhost]

TASK [show attempts] ***********************************************************
ok: [localhost] => {
  "msg": "attempts=3"
}

The attempts field is on the registered result, which makes it available for a report — “this node needed four attempts to come back” is a useful thing to log.

The honest retry

A retry is honest when the failure is transient by nature — the thing you are waiting for is genuinely in the process of becoming true.

- name: wait for the service to answer its health endpoint
  ansible.builtin.uri:
    url: "http://127.0.0.1:{{ webapp_listen_port }}/healthz"
    status_code: 200
  register: health
  until: health.status == 200
  retries: 12
  delay: 5
  changed_when: false

Sixty seconds of patience for a service that was just restarted. The condition is a state the system is moving towards, the action is read-only, and repeating it costs nothing.

The three properties that make this one honest:

  1. The action is idempotent, ideally read-only. A GET on a health endpoint can run a hundred times.
  2. The condition can become true without further intervention. The service is starting. Nobody has to do anything.
  3. The total wait is bounded and stated. 12 × 5 = sixty seconds, in the file, where a reviewer can see whether that is the right number for this service.

The dishonest retries

Retrying a deterministic failure

The credential is wrong. The package name is misspelled. The path does not exist. None of these become true by waiting, and a retry loop around them does exactly one thing: it delays the alert by retries × delay.

The symptom is a play that used to fail in ten seconds and now fails in four minutes, with the operator watching a spinner. Worse, on a large fleet the delay is often long enough that somebody cancels the run, so the failure is never even reported — it is inferred from a Ctrl-C.

Test: if you cannot name the thing that will change between attempts, the retry is not doing anything.

Retrying a non-idempotent action

This is the one that causes damage rather than delay.

# Do not do this.
- name: create the reporting user
  ansible.builtin.command: /usr/local/bin/create-user reporting
  register: r
  until: r.rc == 0
  retries: 5
  delay: 10

If the command creates the user and then fails — on a slow post-creation step, on a notification to a downstream service, on a timeout after the work was done — the retry runs it again. Five times. You now have five reporting users, or one user and four confusing failures, or a downstream system that received five conflicting notifications.

The retry did not absorb a fault. It amplified one.

Retrying to paper over a race

A retry loop added because “sometimes the config file is not there yet” is a missing ordering constraint wearing a disguise. The fix is to make the producing task run first, or to wait for the specific thing with ansible.builtin.wait_for, which states what it is waiting for instead of retrying something that happens to fail while it waits.

meta: clear_host_errors

A host that fails is removed from the play and does not appear in subsequent plays of the same run. meta: clear_host_errors clears that state — and it is narrower than people expect.

Read-only / Safewhat the module documentation says
$ ansible-doc ansible.builtin.meta | grep -A4 clear_host_errors
'clear_host_errors' (added in Ansible 2.1) clears the
failed state (if any) from hosts specified in the play's
list of hosts. This will make them available for
targeting in subsequent plays, but not continue

It does not resume the current play for the failed host. Verified: a two-host play where one fails, a meta: clear_host_errors immediately after, and a task following that:

Read-only / Safeclear_host_errors does not bring the host back into this play
$ ansible-playbook -i two.ini clearerr4.yml
TASK [node-a fails] ************************************************************
fatal: [node-a]: FAILED! => {"changed": false, "msg": "boom"}
skipping: [node-b]

TASK [reactivate failed hosts] *************************************************

TASK [both hosts should reach here] ********************************************
ok: [node-b] => {
  "msg": "node-b is in play"
}

PLAY RECAP *********************************************************************
node-a                     : ok=0    changed=0    unreachable=0    failed=1
node-b                     : ok=1    changed=0    unreachable=0    failed=0

What it does do is restore the host for the next play:

Read-only / Safewith and without, across two plays
$ ansible-playbook -i two.ini clearerr5.yml; ansible-playbook -i two.ini clearerr6.yml
=== WITH clear_host_errors ===
ok: [node-a] => { "msg": "node-a in play two" }
ok: [node-b] => { "msg": "node-b in play two" }
node-a  : ok=1  changed=0  unreachable=0  failed=1
node-b  : ok=1  changed=0  unreachable=0  failed=0

=== WITHOUT ===
ok: [node-b] => { "msg": "node-b in play two" }
node-a  : ok=0  changed=0  unreachable=0  failed=1
node-b  : ok=1  changed=0  unreachable=0  failed=0

node-a appears in play two only when the meta task ran. And note the recap in both cases: failed=1 for node-a. Clearing the error state does not erase the failure from the statistics, which is correct — the run did contain a failure and the recap says so.

Where it genuinely earns its place: a first play that probes an estate and tolerates failures, followed by a second play that should still consider every host. Used to make a failing host “come back”, it is a way of continuing past a failure without deciding to — which is the pattern lesson 7 spent its length arguing against.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A task carries until, retries: 3 and delay: 5, and the condition is never satisfied. How many times does the module execute, and roughly how long does the task take?

  2. Q2. meta: clear_host_errors placed immediately after a failing task brings that host back into the current play, so subsequent tasks run for it again.

  3. Q3. Which properties make a retry loop honest rather than harmful? Select all that apply.

  4. Q4. A command creates a user, waits for a downstream confirmation, times out, and exits non-zero. Someone wraps it in until r.rc == 0 with retries: 5. What is the result?

Passing score: 75%. Answers are checked in this browser.