BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

Poll until ready, the right way

The until-loop you copy into every deploy script is a hang waiting to happen. OrchStep has no unbounded while — every 'wait for X' maps to a primitive with a built-in stop: func: wait, retry, or loop until.

Jun 9, 2026 OrchStep Team 6 minROLE: AnySCALE: Any
RUNNABLE DEMO
Full source for this post: blog/poll-until-ready
VIEW SOURCE

Here's the snippet that lives in everyone's deploy script:

until curl -sf "$URL/health" > /dev/null; do
  echo "waiting..."
  sleep 5
done

It works on your laptop. In CI it's a time bomb: if the service never comes up — bad image, wrong port, crash loop — that until loop runs forever. The runner burns minutes, then hits the job's global timeout, and you get a red build that says "timed out" instead of "health check failed." You've turned a clear failure into a vague one, and paid for the privilege.

OrchStep removes the foot-gun by removing the loop. There is no unbounded while — on purpose. Every "wait until X" you'd reach for maps to a primitive that cannot run forever. A workflow that's safe to run unattended in CI should never be able to hang, so the bound is built in, not bolted on.

Here are the three shapes, all bounded, all in one runnable task.

The three bounded primitives

orchstep.yml
name: poll-demo
defaults:
  service: checkout

tasks:
  release:
    steps:
      - name: deploy
        func: shell
        do: echo "rolling out {{ vars.service }}"

      # 1) Fixed settle pause (func: wait, duration mode).
      - name: settle
        func: wait
        args:
          duration: "1s"
          message: "letting {{ vars.service }} settle"

      # A health probe whose result the next step polls.
      - name: probe
        func: shell
        do: 'echo "200"'
        outputs:
          status: "{{ result.output }}"

      # 2) Poll until a condition holds (func: wait, condition mode).
      #    Bounded by `timeout` - it FAILS the step instead of hanging.
      - name: wait_until_healthy
        func: wait
        args:
          condition: '{{ eq steps.probe.status "200" }}'
          timeout: "30s"
          interval: "2s"

      # 3) Keep trying an action until it succeeds (retry, not a loop).
      #    Bounded by `max_attempts`.
      - name: smoke
        func: shell
        do: 'echo "smoke check {{ vars.service }} -> ok"'
        retry:
          max_attempts: 5
          interval: "1s"
          backoff_rate: 2.0
          when: '!result.success'

      - name: done
        func: shell
        do: echo "{{ vars.service }} is ready"
orchstep run release

Three different "waits," three different built-in stops:

  1. func: wait (duration) — a plain pause. duration: "1s". The honest replacement for sleep, with a message so the log says why you're waiting.
  2. func: wait (condition) — the real "poll until ready." It evaluates condition every interval and stops the moment it's true. If it never becomes true, timeout fires and fails the step — the exact opposite of the until loop, which would spin forever.
  3. retry — "keep trying this command until it works." max_attempts is the hard ceiling; when: '!result.success' retries only on failure, with backoff_rate spacing out the attempts.

The demo's condition is true immediately so it runs anywhere, but in production you point it at a real health signal and let timeout be the safety net:

- name: wait_for_rollout
  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

That reads as intent — "wait until healthy" — polls on a schedule, and is guaranteed to terminate. The bash version reads as mechanism and is guaranteed to do no such thing.

"But I need to re-run a check until it passes"

Use retry. "While this keeps failing, try again" is not a loop — it's a retry block on the step:

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

Flaky network, slow rollout, eventual consistency — whenever the "condition" is really "did this command work?", retry is the tool, and max_attempts is the bound.

And the third case: stop early over a known list

loop ... until is for when you are iterating a finite collection but want to bail the moment a condition holds. The list is the bound; until just exits early:

- name: find_active
  loop:
    items: "{{ vars.servers }}"
    as: server
    until: '{{ eq loop.index1 2 }}'   # stop after the 2nd item
  func: shell
  do: echo "checking {{ loop.server.host }} ({{ loop.index1 }}/{{ loop.length }})"

It can't run longer than servers, and it stops early when the condition is met. Bounded twice over.

Pick by intent

You want to...UseBounded by
Pause a fixed amountfunc: wait (duration)the duration
Poll external state until readyfunc: wait (condition)timeout
Re-run an action until it succeedsretrymax_attempts
Iterate a list, stop earlyloop + untilthe list

Notice what's not in that table: a way to wait forever. That's the feature. GitHub Actions has no loops, Ansible bounds until with retries, Airflow gives sensors a timeout — every serious automation tool avoids the unbounded while, because a stuck runner is wasted money and a confusing failure. OrchStep just makes the bound non-optional.

Where to go next

The demo is echo and wait only, so it runs anywhere: orchstep run release.

#POLLING#WAIT#RETRY#CI#RELIABILITY
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY