
An agent that retries forever wastes resources and can flood a downstream API. An agent that never retries drops work on the first transient network blip. The fix is a retry budget: a fixed maximum attempt count, an explicit backoff schedule, and a published list of which errors are terminal versus transient. This page gives you the worksheet, a safe shell implementation, and the launchd restart rule to wire it to.
Retry logic runs wherever the job runs, so plan choice follows the job.
| Job profile | Figment plan | Entry price |
|---|---|---|
| HTTP calls, git, shell, file processing, cross-compiled Xcode 26 or earlier | Virtual Intel macOS (2 vCPU, 8 GiB RAM, 80 GiB disk) | $29/month |
| Physical Intel host for legacy toolchains | i3 Mac (request-based while supply is confirmed) | $49/month |
| ARM-only binaries, Xcode 27, visionOS, GPU-bound tasks | M-series Mac (chip, RAM and disk confirmed in the quote) | from $95/month |
| One managed M-series Mac with CI setup, backups, monitoring and 30 minutes monthly support | Managed Build Mac | $249/month |
For a self-managed retry loop driving HTTP APIs or shell work, the $29 virtual Intel macOS instance is enough. It runs macOS, so launchd, bash, curl, plutil and compatible Intel Xcode toolchains work the same way they do on any other Mac; access is through the browser console. Move up to M-series only when the agent must execute ARM-only binaries or the newer Xcode. Physical Intel and M-series plans currently collect requests until supply is confirmed, so configuration and lead time come back in the quote.
Write the budget down before you write code. These are the error classes that appear most often in API-backed agents.
| Error class | Trigger | Retry? | Max attempts | Base delay | Max delay | Notes |
|---|---|---|---|---|---|---|
| Transient network | Timeout, DNS failure, connection reset | Yes | 5 | 2s | 60s | Exponential backoff with jitter |
| Rate limit | HTTP 429 | Yes | 5 | Honour Retry-After, else 10s | 120s | Server hint overrides calculated delay |
| Server error | HTTP 500, 502, 503, 504 | Yes | 3 | 5s | 60s | Fewer attempts; upstream may be degraded |
| Auth failure | HTTP 401, 403 | No | — | — | — | Same token will keep failing; alert operator |
| Client error | HTTP 400, 422 | No | — | — | — | Input is invalid; fix data, do not retry |
| Not found | HTTP 404 | No | — | — | — | Resource does not exist |
| Conflict | HTTP 409 | Conditional | 1 | 1s | — | Re-fetch state, then decide |
| Agent crash / non-zero exit | Signal or unhandled exception | Delegated to launchd | See plist | n/a | n/a | KeepAlive with SuccessfulExit = false restarts only on non-zero exit |
Terminal errors must stop the job on the first occurrence. Retrying a 401 with the same credentials only burns budget and risks a lockout. Retrying a 400 means the payload is wrong; more attempts will not fix it.
#!/usr/bin/env bash
# retry.sh — reusable retry with exponential backoff + jitter.
# Usage: retry <max_attempts> <base_delay_s> <max_delay_s> <command> [args...]
set -euo pipefail
retry() {
local max_attempts="$1" base_delay="$2" max_delay="$3"
shift 3
local cmd=("$@") attempt=1 delay exit_code jitter sleep_time
while (( attempt <= max_attempts )); do
printf '[%s] attempt %d/%d: %s\n' \
"$(date -u +%FT%TZ)" "$attempt" "$max_attempts" "${cmd[*]}" >&2
if "${cmd[@]}"; then
return 0
fi
exit_code=$?
if (( attempt == max_attempts )); then
printf '[%s] exhausted after %d attempts, last exit %d\n' \
"$(date -u +%FT%TZ)" "$max_attempts" "$exit_code" >&2
return "$exit_code"
fi
delay=$(( base_delay * (1 << (attempt - 1)) ))
(( delay > max_delay )) && delay=$max_delay
jitter=$(( RANDOM % (delay / 4 + 1) ))
sleep_time=$(( delay + jitter ))
printf '[%s] sleeping %ds before retry\n' \
"$(date -u +%FT%TZ)" "$sleep_time" >&2
sleep "$sleep_time"
(( attempt++ ))
done
}
This is an unexecuted reference example; run it against your own command with the parameters above and keep the measured timings in your own log. set -euo pipefail ensures a failed command inside the retried function actually surfaces its exit code instead of being counted as success.
Retry-After HeaderRate-limited and overloaded services often return a Retry-After value; prefer it over the calculated delay because the server knows better than the client when it will accept work again. Per the MDN Retry-After reference, the value is either a non-negative integer number of seconds or an HTTP-date, and it is defined for 429, 503 and some redirect responses.
call_api() {
local body_file headers_file http_code retry_after
body_file=$(mktemp -t agent_body.XXXXXX)
headers_file=$(mktemp -t agent_hdr.XXXXXX)
trap 'rm -f "$body_file" "$headers_file"' RETURN
http_code=$(curl --silent --max-time 30 \
--output "$body_file" --dump-header "$headers_file" \
--write-out '%{http_code}' \
-H "Authorization: Bearer ${API_TOKEN}" \
-H "Content-Type: application/json" \
-X POST "${API_ENDPOINT}" --data "$1")
case "$http_code" in
2*) cat "$body_file"; return 0 ;;
429|503)
retry_after=$(awk 'tolower($1)=="retry-after:"{print $2}' "$headers_file" | tr -d '\r')
printf 'RATE_LIMITED:%s\n' "${retry_after:-}" >&2
return 2 ;;
*) printf 'HTTP_ERROR:%s\n' "$http_code" >&2; return 1 ;;
esac
}
The caller inspects exit code 2 and, if Retry-After is a plain integer, sleeps that many seconds before the next attempt; if it is an HTTP-date, parse with date -j -f on macOS and compute the delta.
Process-level crashes belong to launchd, not the retry function. From Apple's own Creating Launchd Jobs guide: a job that exits within 10 seconds of launch may be considered misbehaving and throttled, so your agent must stay up for at least that long before exiting, even on failure.
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>30</integer>
With SuccessfulExit = false, launchd restarts the job only when it exits non-zero, so a clean exit after a drained queue does not relaunch forever. ThrottleInterval enforces a minimum spacing between relaunches. Apple does not publish a hard maximum restart count; measure in your own logs.
Values for base_delay = 2s, max_delay = 60s, no jitter:
| Attempt | Delay before this attempt | Cumulative wait |
|---|---|---|
| 1 | 0s | 0s |
| 2 | 2s | 2s |
| 3 | 4s | 6s |
| 4 | 8s | 14s |
| 5 | 16s | 30s |
Five attempts drain the budget in about 30 seconds of real time, which is usually enough to ride out a brief upstream hiccup without extending a run long enough to miss a cron window.
call_api at a sink that returns 500 and confirm the loop fires exactly max_attempts times then exits non-zero.Retry-After: 15 and confirm the caller waits ~15s, not the calculated delay.SIGKILL and confirm launchd relaunches it after ThrottleInterval; confirm a clean exit 0 does not relaunch.The retry loop itself adds no meaningful cost on the $29 virtual Intel macOS instance — the agent is sleeping between attempts. If the job later needs ARM-only execution or Xcode 27, request an M-series plan from $95/month and confirm the chip, RAM and disk in the quote; do not assume a specific configuration before Figment returns it.
Start your hosted Mac request · All Mac hosting plans
Related: Run Mac Shortcuts from the command line · Batch rename files in Finder on a hosted Mac · GitHub Actions self-hosted runner on a Mac