DOC_INDEX
THEME
DOCS/Learn OrchStep/While Loops & Waiting

While Loops & Waiting

OrchStep has no unbounded while loop by design. Here's how to express every "repeat until" intent safely - poll until ready with func wait, retry until success, and loop ... until for early-exit iteration.

OrchStep has no unbounded while loop - on purpose. Loops always iterate a finite set (items / count / range), and "keep going until X" is expressed with bounded, time-boxed primitives. A workflow that runs the same in CI should never be able to hang forever, so every "while" you'd reach for maps to a primitive with a built-in stop.

This page shows how to do each one.

Which one do I want?

You want to...UseBounded by
Wait until a condition is true (poll something)func: wait with conditiontimeout
Keep retrying an action until it succeedsretry: on the stepmax_attempts
Iterate a known list but stop as soon as a condition holdsloop: + until:the list (items/count/range)
Repeat a check until ready, never foreverfunc: wait, or loop: { count, until }timeout / count

Wait until a condition is true (polling)

The closest thing to while not ready: sleep is func: wait in condition mode. It polls a condition on an interval and fails the step at timeout, so it can't hang.

- name: deploy
  func: shell
  do: kubectl rollout restart deploy/api -n {{ vars.namespace }}

- name: wait_until_healthy
  func: wait
  args:
    condition: '{{ eq steps.health.status_code 200 }}'
    timeout: "5m"      # give up (and fail) after 5 minutes
    interval: "10s"    # check every 10 seconds

Why this instead of a while loop: it reads as intent ("wait until healthy"), polls on a schedule, and is guaranteed to terminate at the timeout. See the wait function for the full parameter list.

Retry until it succeeds

"While this keeps failing, try again" is a retry: block, not a loop. It re-runs the step with backoff until it passes or runs out of attempts.

- name: smoke_test
  func: shell
  do: curl -fsS https://{{ vars.host }}/health
  retry:
    max_attempts: 5       # the hard stop
    interval: 3s          # initial delay
    backoff_rate: 2       # 3s, 6s, 12s, ...
    when: '!result.success'   # keep retrying while it fails

retry is the right tool whenever the "condition" is really "did this command work?" - flaky networks, slow rollouts, eventual consistency.

Loop until a condition (early exit)

When you are iterating a known collection but want to bail out the moment a condition is met, add until: to the loop. It still can't run longer than the list, but it stops early.

defaults:
  servers:
    - { host: web1, status: pending }
    - { host: web2, status: active }
    - { host: web3, status: active }

tasks:
  main:
    steps:
      - name: find_active
        loop:
          items: "{{ vars.servers }}"
          as: server
          until: '{{ eq loop.server.status "active" }}'   # stop at the first active one
        func: shell
        do: echo "checking {{ loop.server.host }} ({{ loop.index1 }}/{{ loop.length }})"

The loop runs web1, sees web2 is active, and breaks - it never touches web3. The until expression sees the normal loop context (loop.item / loop.<as>, loop.index1, loop.length, loop.first, loop.last).

"Bounded while": repeat up to N times until ready

For "keep doing this until ready, but never forever," reach for func: wait (above) when you're polling external state. When each attempt must re-run a command, use a count loop with until: and a delay: between iterations - the count is your safety cap.

- name: poll_migration
  loop:
    count: 30                                  # never more than 30 tries
    delay: 10s                                 # wait between checks
    until: '{{ contains "READY" result.output }}'   # stop when ready
  func: shell
  do: ./scripts/migration-status.sh

This is the bounded equivalent of while not ready: { check; sleep } - it polls up to count times, delay apart, and exits as soon as until is satisfied.

Pagination & unknown counts

The one case where a true while feels missing is "loop while there's a next page" - you don't know the page count up front. Express it as a generous count cap plus an until break:

- name: drain_pages
  loop:
    count: 1000                                # safety cap - you'll never hit it
    until: '{{ eq result.data_object.next_cursor "" }}'   # stop when there's no next page
  func: shell
  do: ./fetch-page.sh

The count isn't your intent - it's a guardrail. Pick a number you're confident exceeds any real run, and let until end the loop normally.

Why no unbounded while?

It's a deliberate omission, not a gap:

  • It can't hang. OrchStep is built to run unattended in CI. An unbounded loop is a stuck runner, wasted minutes, and runaway cost. Every loop here has a hard stop.
  • It matches the category. GitHub Actions has no loops at all; Ansible bounds "until" with retries; Airflow polls with sensor timeouts; Terraform is for_each only. Unbounded while is the path these tools avoid.
  • It's safe for AI-authored workflows. When an agent captures or designs a workflow, a missing bound would be an easy way to ship an accidental infinite loop. Bounded-by- default keeps generated workflows safe and replay deterministic.

Where to go next