
If a polling agent keeps hitting 429 responses, the fix is not a slower manual sleep. It is reading the Retry-After header the server already sent and sizing your launchd interval against the published rate window. This page gives you a complete agent you can run on a hosted Mac, plus a worksheet to pick the interval.
For pure API polling with curl, requests, or a Node client, the $29/month virtual Intel macOS environment (2 vCPU, 8 GiB RAM, 80 GiB disk) is the right starting point. It runs a real macOS userland on Linux/KVM, so launchd, security, and Homebrew-installed clients all work. There is no GPU and no audio, which a network-bound polling agent does not need. Access is through the browser console; commands run in the guest's desktop terminal.
Upgrade when the workload needs more than network I/O:
The $29 price is a monthly charge; it is not pro-rated to shorter windows, so a two-week project still pays for the month. For the physical tiers, ask us to confirm current availability in your quote rather than assuming instant provisioning.
Document the agent's contract before writing poll code. Missing inputs fail silently inside a launchd job.
| Input | Where it lives |
|---|---|
| API base URL | EnvironmentVariables in the launchd plist |
| Auth token | macOS Keychain via security add-generic-password |
| Poll interval | StartInterval (seconds) |
| Output path | Local path or mounted volume |
| Rate-limit budget | From your API provider's docs |
Keep the token out of the plist and out of shell history. Add it once from the browser console terminal, then read it at runtime:
TOKEN="$(security find-generic-password -a poll-agent -s api-token -w)"
If the API uses OAuth, run the one-time browser authorization from the hosted Mac, write the refresh token to Keychain, and let the agent refresh headlessly from then on.
RFC 6585 defines 429 Too Many Requests and notes the response MAY include a Retry-After header. Retry-After can be an integer number of seconds or an HTTP-date (see RFC 7231 §7.1.3). The script below parses the integer form and falls back to exponential backoff when the header is missing or date-formatted; extend it with date -j -f if your provider uses HTTP-dates.
#!/usr/bin/env bash
set -euo pipefail
API_URL="${API_BASE_URL:?API_BASE_URL not set}"
TOKEN="$(security find-generic-password -a poll-agent -s api-token -w)"
LOG="$HOME/Library/Logs/poll-agent.log"
BODY="$(mktemp -t poll-body)"
HEADERS="$(mktemp -t poll-headers)"
trap 'rm -f "$BODY" "$HEADERS"' EXIT
MAX_RETRIES=5
BACKOFF=10
attempt=0
while (( attempt < MAX_RETRIES )); do
HTTP_CODE=$(curl -sS \
-o "$BODY" -D "$HEADERS" -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN" \
"$API_URL")
case "$HTTP_CODE" in
200)
printf '%s OK\n' "$(date -u +%FT%TZ)" >> "$LOG"
# Hand "$BODY" to your own processor here.
exit 0
;;
429)
RA=$(awk 'BEGIN{IGNORECASE=1} /^Retry-After:/ {gsub(/\r/,""); print $2; exit}' "$HEADERS")
if [[ "$RA" =~ ^[0-9]+$ ]]; then
printf '%s 429 Retry-After=%ss\n' "$(date -u +%FT%TZ)" "$RA" >> "$LOG"
sleep "$RA"
else
printf '%s 429 backoff=%ss\n' "$(date -u +%FT%TZ)" "$BACKOFF" >> "$LOG"
sleep "$BACKOFF"
BACKOFF=$(( BACKOFF * 2 ))
fi
;;
*)
printf '%s HTTP %s abort\n' "$(date -u +%FT%TZ)" "$HTTP_CODE" >> "$LOG"
exit 1
;;
esac
attempt=$(( attempt + 1 ))
done
printf '%s max retries exceeded\n' "$(date -u +%FT%TZ)" >> "$LOG"
exit 1
This is an unexecuted example, not a benchmark. Save it as ~/bin/poll-agent.sh, chmod +x, then test it once by hand against your real endpoint before letting launchd run it.
Fill the rightmost column, then copy it into the plist's StartInterval. The rule is interval >= ceil(window / limit) * 1.1 so clock drift does not push you over.
| API documented limit | Window | Safe StartInterval (s) | Retry-After behaviour |
|---|---|---|---|
| 60 req/min | 60 s | 66 | Use header; fall back to 60 s |
| 1,000 req/day | 86,400 s | 95 | Use header; fall back to 300 s |
| 100 req/hour | 3,600 s | 40 | Use header; fall back to 120 s |
| 5 req/sec burst | 1 s | 5 | Header is mandatory |
| Undocumented | — | 300 | Header is mandatory |
At StartInterval=66 the agent makes about 1,309 calls per day, inside a 60/min quota with headroom for the occasional retry.
Save this to ~/Library/LaunchAgents/com.example.poll-agent.plist. Key semantics are defined in Apple's Creating launchd Jobs reference.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.example.poll-agent</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/agent/bin/poll-agent.sh</string>
</array>
<key>StartInterval</key><integer>66</integer>
<key>StandardOutPath</key><string>/Users/agent/Library/Logs/poll-agent.out</string>
<key>StandardErrorPath</key><string>/Users/agent/Library/Logs/poll-agent.err</string>
<key>EnvironmentVariables</key>
<dict>
<key>API_BASE_URL</key><string>https://api.example.com/v1/items</string>
</dict>
</dict>
</plist>
Load with launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.example.poll-agent.plist. Omit KeepAlive: the job should exit each run and let launchd relaunch on the interval, so a bad call never trips the "respawning too fast" throttle.
Review after the first 24 hours:
tail -50 ~/Library/Logs/poll-agent.log should be dominated by OK. More than 10% 429 rows means the window is too tight.HTTP 401 means refresh failed. Re-run the Keychain read by hand to isolate token versus network.pmset or your hosting console.poll-agent.log is append-only. Add a newsyslog.d rule or truncate in-script above a size threshold.Open Mac Hosting, pick the $29 virtual Intel macOS plan for a network-only agent or request a quote for i3 or M-series if you need a physical host. Reach the guest through the browser console, create ~/bin, save the script, store the token in Keychain, drop the plist in ~/Library/LaunchAgents, and bootstrap it.
Migrating from another host? Copy the script and plist files, re-add secrets directly on the new machine (do not copy a Keychain database between hosts), bootstrap the agent, and confirm one clean OK in the log before you decommission the old runner.
Related guides