Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Set Retry Limits for Automation on a Hosted Mac

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.

Pick a Hosted Mac Plan for the Agent

Retry logic runs wherever the job runs, so plan choice follows the job.

Job profileFigment planEntry price
HTTP calls, git, shell, file processing, cross-compiled Xcode 26 or earlierVirtual Intel macOS (2 vCPU, 8 GiB RAM, 80 GiB disk)$29/month
Physical Intel host for legacy toolchainsi3 Mac (request-based while supply is confirmed)$49/month
ARM-only binaries, Xcode 27, visionOS, GPU-bound tasksM-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 supportManaged 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.

The Retry Budget Table

Write the budget down before you write code. These are the error classes that appear most often in API-backed agents.

Error classTriggerRetry?Max attemptsBase delayMax delayNotes
Transient networkTimeout, DNS failure, connection resetYes52s60sExponential backoff with jitter
Rate limitHTTP 429Yes5Honour Retry-After, else 10s120sServer hint overrides calculated delay
Server errorHTTP 500, 502, 503, 504Yes35s60sFewer attempts; upstream may be degraded
Auth failureHTTP 401, 403No———Same token will keep failing; alert operator
Client errorHTTP 400, 422No———Input is invalid; fix data, do not retry
Not foundHTTP 404No———Resource does not exist
ConflictHTTP 409Conditional11s—Re-fetch state, then decide
Agent crash / non-zero exitSignal or unhandled exceptionDelegated to launchdSee plistn/an/aKeepAlive 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.

Shell Implementation with Jitter

#!/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.

Handling the Retry-After Header

Rate-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.

Delegating Crash Restarts to launchd

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.

Backoff Schedule Reference

Values for base_delay = 2s, max_delay = 60s, no jitter:

AttemptDelay before this attemptCumulative wait
10s0s
22s2s
34s6s
48s14s
516s30s

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.

Failure and Recovery Checks

  • Point call_api at a sink that returns 500 and confirm the loop fires exactly max_attempts times then exits non-zero.
  • Return 401 once and confirm the script exits on attempt one with a single log line; retries must not happen.
  • Return 429 with Retry-After: 15 and confirm the caller waits ~15s, not the calculated delay.
  • Kill the agent with SIGKILL and confirm launchd relaunches it after ThrottleInterval; confirm a clean exit 0 does not relaunch.
  • After budget exhaustion, write a terminal status to your idempotency ledger so a redelivery does not start a fresh retry cycle under the same key.

Cost Note and Next Step

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


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms