Retries And Backoff¶
A task that fails twice and succeeds on the third attempt, then a task that runs out of retries and takes the run down with it.
The interesting part is not that retries happen. It is that every attempt is a journal event, so the retry schedule is durable state you can read back, weeks later, from a run that failed at 3am. It is not a log line you have to hope somebody kept.
Source: examples/retries_backoff_demo.py
Get It And Run It¶
pip install 'satay[studio]'
curl -fsSL -O https://raw.githubusercontent.com/leejianrong/satay-runtime/v0.1.0/examples/retries_backoff_demo.py
SATAY_DATA_DIR=.satay-demo python retries_backoff_demo.py
The Flaky Task¶
@satay.task(retries=2)
async def fetch_rate(pair: str) -> float:
"""A flaky read: the upstream times out twice, then answers (three attempts)."""
record("fetch_rate")
ctx = satay.task_context()
if ctx.attempt < SUCCEEDS_ON_ATTEMPT:
raise RuntimeError(f"upstream rate API timed out (attempt {ctx.attempt})")
return 1.35
@satay.task()
async def convert(amount: float, rate: float) -> float:
"""Apply the fetched rate. Runs once — the retries upstream are invisible here."""
record("convert")
return round(amount * rate, 2)
@satay.workflow
async def quote(amount: float) -> float:
rate = await fetch_rate("USD/SGD")
return await convert(amount, rate)
retries=2 means two retries on top of the first attempt, so three physical attempts at most.
The failure decision reads ctx.attempt off the task context, which is why the schedule comes
out the same on every machine.
Notice what quote does not contain: no try, no attempt counter, no backoff loop. The
workflow body reads as if fetch_rate simply works. Retrying is the executor's job.
What It Printed¶
$ SATAY_DATA_DIR=.satay-demo python retries_backoff_demo.py
Satay — retries and capped exponential backoff
data dir: …/.satay-demo
1) fetch_rate fails twice then succeeds — run 360ca4293b34439f80e0c3bb8486cb9e
attempt 1 FAILED RuntimeError: upstream rate API timed out (attempt 1) (backoff 0.966s)
attempt 2 FAILED RuntimeError: upstream rate API timed out (attempt 2) (backoff 0.881s)
attempt 3 SUCCEEDED
result: 135.0 SGD status: completed
fetch_rate bodies executed: 3 (at-least-once, by design)
convert bodies executed: 1 (the retries never reach it)
backoff scheduled: 1.848s in total — none of it real time
Run 360ca4293b34439f80e0c3bb8486cb9e — 12 event(s)
1 2026-01-01T00:00:00+00:00 WorkflowCreated workflow=quote code_version=git:4d22d57c0a914532d987bc7df2af0f65530cdce6
2 2026-01-01T00:00:00+00:00 TaskScheduled task=fetch_rate ordinal=0
3 2026-01-01T00:00:00+00:00 TaskAttemptStarted task=fetch_rate ordinal=0 attempt=1
4 2026-01-01T00:00:00+00:00 TaskAttemptFailed task=fetch_rate ordinal=0 attempt=1 error=RuntimeError: upstream rate API timed out (attempt 1) next_delay=0.966s
5 2026-01-01T00:01:01+00:00 TaskAttemptStarted task=fetch_rate ordinal=0 attempt=2
6 2026-01-01T00:01:01+00:00 TaskAttemptFailed task=fetch_rate ordinal=0 attempt=2 error=RuntimeError: upstream rate API timed out (attempt 2) next_delay=0.881s
7 2026-01-01T00:02:02+00:00 TaskAttemptStarted task=fetch_rate ordinal=0 attempt=3
8 2026-01-01T00:02:02+00:00 TaskCompleted task=fetch_rate ordinal=0
9 2026-01-01T00:02:02+00:00 TaskScheduled task=convert ordinal=0
10 2026-01-01T00:02:02+00:00 TaskAttemptStarted task=convert ordinal=0 attempt=1
11 2026-01-01T00:02:02+00:00 TaskCompleted task=convert ordinal=0
12 2026-01-01T00:02:02+00:00 WorkflowCompleted
2) fetch_from_dead_host exhausts retries=1 — run 0f8183810914424b9a2339943d038c59
raised ConnectionError: no route to rates.invalid while fetching USD/SGD
status: failed
attempts made: 2 (retries=1 → 1 + 1 retry)
the last error is what the run fails with; earlier attempts stay on the journal
attempt 1 FAILED ConnectionError: no route to rates.invalid while fetching USD/SGD (backoff 0.966s)
attempt 2 FAILED ConnectionError: no route to rates.invalid while fetching USD/SGD (no retry left)
journal kept in …/.satay-demo
browse both runs: satay dev --data-dir …/.satay-demo
or as text: satay runs show 360ca4293b34439f80e0c3bb8486cb9e --data-dir …/.satay-demo
Reading The Retry Schedule¶
Three things are worth pulling out of that timeline.
One TaskScheduled, three TaskAttemptStarted. The task was scheduled once and attempted
three times, all at ordinal=0. One logical durable call, several physical attempts. That
distinction is the reason convert ran exactly once: it is downstream of the logical call, and
the logical call succeeded once.
next_delay on each TaskAttemptFailed. That is the computed backoff before the next
attempt, recorded on the journal. 0.966s, then 0.881s. The formula is
base * 2 ** (failure - 1) with full jitter and a 60-second cap, and the jitter is drawn
from the injected RNG, which is why the example pins SeededRng(1234) and gets the same two
numbers every run.
Full jitter is why the second delay is smaller than the first. The exponential term doubles the ceiling, and the actual delay is a uniform draw below it. A run of delays that only ever grows is not full jitter, it is a thundering herd waiting to happen.
convert bodies executed: 1, fetch_rate bodies executed: 3. At-least-once execution, out
loud. Satay guarantees your task body runs at least once per logical call, never that it runs
exactly once. Three attempts means the body ran three times, and if that body writes
somewhere, it wrote three times. See Guarantees for the idempotency contract
that goes with this, and the ELT pipeline recipe for the damage when you skip
it.
Exhaustion Is A Different Story¶
The second run never succeeds:
@satay.task(retries=1)
async def fetch_from_dead_host(pair: str) -> float:
"""A read that never succeeds: two attempts, then the run fails terminally."""
record("fetch_from_dead_host")
raise ConnectionError(f"no route to rates.invalid while fetching {pair}")
retries=1 allows two attempts. After the second failure there is no retry left, the delay
column says no retry left, and the run goes to failed with the last error. Earlier
attempts are not overwritten. They stay on the journal, each with its own error and its own
recorded delay, so a post-mortem can see whether the first failure differed from the last.
A failed run is terminal. Calling satay.start(..., run_id=...) on it re-raises rather than
resuming. To get back in you fork it, which the agentic DAG recipe does at the
end.
Nobody Waited 1.8 Seconds¶
Look at the timestamps: the run jumps a minute between attempts, while the recorded delays are under a second. Both are true, and the gap is the point.
from satay.testing import ManualClock, SeededRng, settle
handle = satay.start(quote, 100.0, store=store, clock=clock, rng=SeededRng(JITTER_SEED))
result = await settle(handle.result, clock)
Backoff waits go through the injected clock. Pass satay.testing.ManualClock and nothing
moves until someone calls clock.advance(...), so a retry schedule replays in zero wall-clock
time. Somebody still has to move that clock, and that somebody ships with Satay: settle drives
an awaitable and advances the clock through every wait the drive suspends on.
Its default step is a deliberately coarse 61 seconds. One advance clears the 60-second backoff
cap, which is why the journal timestamps move a minute at a time while the recorded delays stay
sub-second. Virtual time is free, so precision buys nothing.
Two behaviours worth knowing before you lean on it. A drive that parks — on a durable timer
or an event wait — returns satay.PARKED normally, because parking is a result rather than a
stall, and only the caller can produce the worker tick that would unpark it. A drive that never
finishes raises
NeverSettledError after max_steps passes, having cancelled the drive first, so a test that
accidentally waits on real time fails with a diagnosis instead of hanging your suite.
This is the loop your tests want
settle is not demo scaffolding — it is the same helper your own tests want, and the drain
fixture in satay.testing.fixtures hands you this exact function. Test a retry policy this
way and your suite asserts the real recorded delays without sleeping for them.
Testing workflows has the full pattern.
The Guard On Side-Effecting Retries¶
fetch_rate is a read, which is why it declares no side_effect=. Its docstring flags the rule
that applies when a retryable task writes:
"""
This task is a read, which is why it declares no ``side_effect=``. A *retryable*
side-effecting task has to promise ``idempotent=True`` (that it keys its effect on
``ctx.idempotency_key``) or ``effect_safety=strict`` rejects it at schedule time.
"""
effect_safety defaults to warn, so by default you get a log line rather than a refusal. Set
it to strict and a retryable side_effect=True task that has not promised idempotent=True
will not be scheduled at all. The ELT pipeline recipe runs both loaders side
by side and counts the duplicated rows.
Open It In Studio¶
Open the printed URL with its ?token= query string. Both runs are in the run list, one
completed and one failed.
Click into quote, then into the fetch_rate call in the execution tree. Task detail is the
view that earns its keep here: it stacks all three attempts in one place, each with its error
and its recorded backoff, instead of making you scan a flat log for the ones that share an
ordinal.
Recap¶
retries=NgivesN + 1physical attempts, and the executor owns the loop. Your workflow body stays free of retry plumbing.- One logical durable call, several physical attempts, all at the same ordinal.
- Every attempt and every computed
next_delaylands on the journal, so the schedule is readable after the fact. - Backoff is capped exponential with full jitter off the injected RNG. Delays can shrink; that is the jitter working.
- Exhausting retries fails the run with the last error. Earlier attempts survive on the journal, and the run is terminal.
- Three attempts means the body ran three times. At-least-once is a promise about the minimum, so a writing task needs an idempotency key.
Next: Timers And Events, where a workflow sleeps for eight hours without holding a coroutine open.