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.
blog/poll-until-readyHere's the snippet that lives in everyone's deploy script:
until curl -sf "$URL/health" > /dev/null; do
echo "waiting..."
sleep 5
doneIt 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
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 releaseThree different "waits," three different built-in stops:
func: wait(duration) — a plain pause.duration: "1s". The honest replacement forsleep, with amessageso the log says why you're waiting.func: wait(condition) — the real "poll until ready." It evaluatesconditioneveryintervaland stops the moment it's true. If it never becomes true,timeoutfires and fails the step — the exact opposite of theuntilloop, which would spin forever.retry— "keep trying this command until it works."max_attemptsis the hard ceiling;when: '!result.success'retries only on failure, withbackoff_ratespacing 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 secondsThat 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... | Use | Bounded by |
|---|---|---|
| Pause a fixed amount | func: wait (duration) | the duration |
| Poll external state until ready | func: wait (condition) | timeout |
| Re-run an action until it succeeds | retry | max_attempts |
| Iterate a list, stop early | loop + until | the 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
- While Loops & Waiting — every "while" intent and the primitive it maps to
- wait function — duration vs condition mode, full parameter list
- Error Handling —
retry,when, backoff, and timeouts
The demo is echo and wait only, so it runs anywhere: orchstep run release.
curl -fsSL https://orchstep.dev/install.sh | sh