Skip to main content
RunBook Academy

AnsibleXV · Conditionals and LoopsLoops

Retry loops for genuinely transient faults

Intermediate⏱ ~22 minansible-playbook

What you'll learn

  • Write an until loop with a condition that can actually become true
  • State the defaults for retries and delay and compute the worst-case wall time
  • Distinguish a transient fault from a hard fault a retry will mask
  • Read the attempts key to tell a first-time success from a fifth-attempt one

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.

until is the third kind of loop. It does not iterate a list; it repeats one task until a condition holds or the retry budget runs out.

It is the right tool for a narrow class of problem and it is routinely used for a much wider one, which is how a fleet ends up with automation that “works, you just have to run it twice sometimes”.

The mechanics

Read-only / Safethe shape
- name: Wait for the application to report healthy
ansible.builtin.uri:
  url: "http://{{ inventory_hostname }}:8080/health"
  status_code: 200
register: health
until: health.status == 200
retries: 12
delay: 5

until is a conditional evaluated after each attempt, in the same raw Jinja as when. It reads the registered result, which means register is mandatory — without it there is nothing for the condition to consult.

The defaults, confirmed by execution rather than assumed:

Read-only / Safethe default budget, timed
$ /usr/bin/time -f 'elapsed %e s' ansible-playbook retry2.yml
TASK [No retries or delay specified] *******************************************
FAILED - RETRYING: [localhost]: No retries or delay specified (2 retries left).
FAILED - RETRYING: [localhost]: No retries or delay specified (1 retries left).
FAILED - RETRYING: [localhost]: No retries or delay specified (0 retries left).
fatal: [localhost]: FAILED! => {"attempts": 3, "changed": false,
                              "stat": {"exists": false}}
...ignoring

elapsed 15.86 s

retries defaults to 3 and delay to 5 seconds. The result carries attempts: 3, and the 15.86 seconds of wall clock confirms three delays of five seconds rather than two — the delay is applied after each failed attempt, including the last one before giving up.

That last detail matters when you are budgeting: the worst case is retries x delay of pure waiting, not (retries - 1) x delay.

The condition has to be able to become true

An until whose condition can never become true is a way of spending retries x delay seconds to arrive at the same failure.

Three conditions that cannot resolve themselves:

  • Anything depending on state only this play changes, where the changing task comes after the wait.
  • A URL that is wrong. It will be wrong on attempt twelve too.
  • A credential that is rejected. Authentication failures are not transient, and retrying them is how accounts get locked out.

Before writing an until, answer: what event, external to this task, would make the condition become true? If you cannot name one, the retry loop is not going to help.

Good answers: a service is starting and will finish; a package repository mirror is briefly unavailable; a cloud API is rate-limiting; a cluster is electing a leader; a device is rebooting.

Waiting versus retrying

These are different intents and they want different tools.

Waiting is “the condition is not true yet and will become true”. The task is read-only, and repeating it is free.

Retrying is “the operation failed and might succeed if repeated”. The task changes something, and repeating it is only safe if the module is idempotent.

Read-only / Safewaiting, done with a read-only probe
- name: Wait for the service to accept connections
ansible.builtin.wait_for:
  host: "{{ inventory_hostname }}"
  port: 8080
  state: started
  timeout: 60
  delay: 2

Where a purpose-built wait module exists — wait_for, wait_for_connection — prefer it. It is one task with one result and a single timeout, rather than a retry loop whose budget you compute in your head. until is for waits that no module covers.

For retrying a change, the requirement is stricter:

The diagnostic damage

Here is the operational case against casual retries, and it is the reason this lesson exists.

A task fails intermittently — one host in twenty, once a week. Someone adds retries: 5. The failure stops appearing. The run is green.

What actually happened:

The fault is still there. It now takes longer and reports success.

The evidence is gone. The failure was the only signal that something in the estate was degraded. A DNS resolver dropping one query in twenty is a real problem with a real cause, and now nothing will report it until it drops five in a row.

The blast radius grew quietly. The condition worsens over months. One in twenty becomes one in five. The retries still absorb it, until the day they do not, and the incident starts with “this has never failed before” — which is false, and would have been visibly false.

The runs got slower. Every affected host pays delay seconds on every run, and nobody attributes the slowdown to the retry because the retry is working.

The discipline: a retry loop is a statement that you have diagnosed the fault and it is transient. Not a way of avoiding the diagnosis.

Where you add one, make it visible:

Read-only / Safea retry that leaves evidence
- name: Wait for the application to report healthy
ansible.builtin.uri:
  url: "http://{{ inventory_hostname }}:8080/health"
  status_code: 200
register: health
until: health.status == 200
retries: 12
delay: 5
# Transient: the app takes up to 60s to warm its cache after a
# restart. Confirmed against the application team, CHG-004417.

- name: Flag hosts that needed more than one attempt
ansible.builtin.debug:
  msg: >-
    {{ inventory_hostname }} needed {{ health.attempts }} attempts
    to become healthy - investigate if this is climbing
when: health.attempts | default(1) | int > 1

Two things there are the whole point. The comment naming why the fault is transient, with a ticket, so a reviewer in a year can tell whether the reason still applies. And the task that reports when the retry did work, so “the retry budget is being consumed more than it used to be” is a visible trend rather than an invisible one.

attempts is present on the result whenever a retry loop ran. On a first-attempt success it is absent, hence the default(1).

Budgeting

The worst case is retries x delay seconds per host, and hosts run in parallel up to forks.

For 400 hosts at forks: 20, with retries: 12 and delay: 5:

  • Worst case per host: 60 seconds of waiting.
  • Batches: 400 / 20 = 20.
  • Worst case for the play: 20 minutes of waiting, if every host exhausts its budget.

That is fine for a deployment with a maintenance window and it is not fine for a check that runs every fifteen minutes. Compute the number before you set the values, and remember that the case where every host exhausts its budget is exactly the case where something is badly wrong and you would rather find out quickly.

A shorter budget that fails fast is often the better operational choice: it turns a slow degraded run into a fast clear failure, and a fast clear failure is something you can act on.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A task specifies until with retries: 2 and no delay. How many times does the task execute, and how long does the waiting take at worst?

  2. Q2. A task fails on roughly one host in twenty, once a week. Someone adds retries: 5 and the failures stop appearing. What has changed?

  3. Q3. Which conditions are genuinely transient and therefore reasonable to retry? Select all that apply.

  4. Q4. Wrapping a shell task that appends a line to a file in an until loop is safe, because Ansible only repeats the task if it failed.

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