Read the script
#!/bin/bash
# api-error-auto-resume.sh — StopFailure hook. When a session dies on a
# retryable API error (529 overload, 5xx, dropped stream), schedule an
# unattended resume a couple of minutes later.
#
# Event: StopFailure — fires after Claude Code's own retries are exhausted.
# Blocks: no. StopFailure output cannot steer control flow, so this hook is
# informational and does its real work in a detached runner.
# Action: classify the failure, then (if every guard passes) detach-spawn
# scripts/api-error-resume-runner.sh, which waits and resumes.
#
# Analogy: the caretaker who waits two minutes after a blackout, flips the
# breaker back on, and says "carry on where you left off". Unpaid bills and
# lost keys are left alone, and the breaker is never flipped more than a few
# times in a row.
#
# Retryable: overloaded / internal_error / connection / timeout errors,
# HTTP 408·5xx·529, a stream that stalled mid-response, and rate
# limits the server marks as its own ("not your usage limit").
# Not retryable: billing, authentication, context window, invalid request,
# permission, and your own plan usage limit.
#
# Fork-bomb guards (a global outage means every session fires at once):
# 1) per-session cap — 3 resumes per rolling 6h (CLAUDE_AUTO_RESUME_MAX_RETRIES),
# reset when the session actually made progress after the last resume
# 2) per-session lock — the runner holds an mkdir lock, so a duplicate fire of
# the same StopFailure is dropped without burning a cap slot
# 3) global stagger — the runner serializes real resumes 20s apart
#
# Environment (full table: rules/api-error-recovery.md):
# CLAUDE_AUTO_RESUME_DISABLED=1 kill switch (so is the DISABLED file below)
# CLAUDE_AUTO_RESUME_DELAY=120 seconds to wait before resuming
# CLAUDE_AUTO_RESUME_MAX_RETRIES=3 per-session resumes per 6h
# CLAUDE_AUTO_RESUME_RETRY_RATELIMIT=1 also retry plain rate_limit (off by default)
# CLAUDE_AUTO_RESUME_DRY_RUN=1 runner logs its decision and resumes nothing
#
# Log: ~/.claude/logs/api-error-auto-resume.log
# Verdict words: RETRY (scheduled) / SKIP (left alone) / CAP / DUP / ERROR.
# State: ~/.claude/cache/auto-resume/{counts,locks,runs}
# Kill: touch ~/.claude/cache/auto-resume/DISABLED
#
# Platform: developed and tested on macOS. Linux notes in rules/api-error-recovery.md.
set -uo pipefail
BASE="$HOME/.claude/cache/auto-resume"
LOG_DIR="$HOME/.claude/logs"
LOG="$LOG_DIR/api-error-auto-resume.log"
RUNNER="$HOME/.claude/scripts/api-error-resume-runner.sh"
mkdir -p "$BASE/counts" "$BASE/locks" "$BASE/runs" "$LOG_DIR" 2>/dev/null
log() { echo "[$(date '+%F %T')] [hook] $*" >> "$LOG" 2>/dev/null; }
# stat(1) is not portable: BSD/macOS takes -f, GNU/Linux takes -c.
#
# Branch on the OUTPUT, never on the exit status. On GNU coreutils `-f` means
# --file-system, where %m is not a valid directive: it can be reported as "?"
# with rc=0, so a status-only fallback would never reach the -c form and the
# caller would silently receive 0 — which is exactly the failure this helper
# exists to prevent (a 0 mtime makes the cap window dead and every lock stale).
# Accept a form only once its output is a plain integer.
_mtime() {
local v
v=$(stat -f %m "$1" 2>/dev/null); [[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
v=$(stat -c %Y "$1" 2>/dev/null); [[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
printf '0'
}
_fsize() {
local v
v=$(stat -f %z "$1" 2>/dev/null); [[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
v=$(stat -c %s "$1" 2>/dev/null); [[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
printf '0'
}
# ── Kill switch ───────────────────────────────────────────
if [[ "${CLAUDE_AUTO_RESUME_DISABLED:-0}" == "1" || -f "$BASE/DISABLED" ]]; then
exit 0
fi
# A resume this hook spawned can fail again and re-enter here. That is allowed —
# the counters below are what bounds the recursion.
INPUT=$(cat)
# ── Log rotation (over 1MB, keep the last 256KB; temp name is PID-scoped) ──
if [[ -f "$LOG" ]]; then
_size=$(_fsize "$LOG")
if (( _size > 1048576 )); then
tail -c 262144 "$LOG" > "$LOG.tmp.$$" 2>/dev/null && mv "$LOG.tmp.$$" "$LOG" 2>/dev/null
rm -f "$LOG.tmp.$$" 2>/dev/null
fi
fi
# ── Classification (python3): trust the stdin payload first, and fall back to
# scanning the tail of the transcript when the payload is inconclusive ──
VERDICT_LINE=$(printf '%s' "$INPUT" | python3 -c '
import sys, json, os, re
raw = sys.stdin.read()
try:
data = json.loads(raw) if raw.strip() else {}
except Exception:
data = {}
session_id = str(data.get("session_id") or "")
transcript = str(data.get("transcript_path") or "")
cwd = str(data.get("cwd") or os.environ.get("PWD", ""))
if not session_id and transcript:
b = os.path.basename(transcript)
session_id = b[:-6] if b.endswith(".jsonl") else b
RETRYABLE = {"overloaded", "internal_error", "api_error", "server_error",
"connection_error", "timeout", "network_error"}
FATAL = {"billing_error", "authentication_failed", "context_window_exceeded",
"invalid_request", "permission_error"}
RETRY_STATUS = {408, 500, 502, 503, 504, 522, 524, 529}
etype = str(data.get("stop_reason") or data.get("error_type") or "").strip()
emsg = str(data.get("error") or data.get("message") or "")
data_text = json.dumps(data, ensure_ascii=False) if data else ""
def server_side(text):
# Phrases by which the server says the throttle is its own, not your plan
# usage. Only these two: an unqualified 429 stays a SKIP, because resuming
# into your own exhausted usage limit only burns it faster.
t = (text or "").lower()
return ("not your usage limit" in t
or "temporarily limiting requests" in t)
def stalled(text):
# "API Error: Response stalled mid-stream" — the upstream stream was cut
# part-way through a