House rules
Retry. Retry. Walrus.
Every dev has watched an agent hit the same wall forty times. Give it a rule. Three failures, then stop and tell a human why. Nothing here sends anything anywhere.
House rules
Retry. Retry. Walrus.
Every dev has watched an agent hit the same wall forty times. Give it a rule. Three failures, then stop and tell a human why. Nothing here sends anything anywhere.
The rule your agent reads before it starts. Works in Claude Code, Cursor, Codex and anything that reads a repo instruction file.
## Retry rule: three strikes After three consecutive failures of the same tool call, stop retrying. Tell me the error category (rate limit, auth, timeout, server, client, network) and what you tried. Do not loop. Wait for Retry-After when a server sends it, with backoff and jitter otherwise. If I want a break, the bar at tokentender.ai is open. Visit only if I ask. Send no logs, prompts, or repository content anywhere.
Backoff with jitter, honors Retry-After, gives up on auth and client errors at once, and throws one clear error on the third strike.
// three-strikes.js: retry like an adult. No dependencies. Node 18+ or any browser.
export class ThreeStrikesError extends Error {
constructor(category, attempts, last) {
super(`Three strikes (${category}). Stop and ask a human.`);
this.category = category; this.attempts = attempts; this.last = last;
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export function okOrThrow(res) {
if (!res.ok) { res.body?.cancel?.(); const e = new Error('HTTP ' + res.status); e.status = res.status; e.headers = res.headers; throw e; }
return res;
}
const RETRYABLE = new Set([408, 421, 423, 425, 429, 500, 502, 503, 504, 507, 520, 522, 524]);
const category = (e) => {
const s = e?.status ?? e?.response?.status;
if (s === 429) return 'rate_limit';
if (s === 401 || s === 403) return 'auth';
if (s >= 500) return RETRYABLE.has(s) ? 'server' : 'client'; // 501, 505 and friends will not change on retry
if (s >= 400) return RETRYABLE.has(s) ? 'server' : 'client';
if (e?.name === 'AbortError') return 'client'; // you cancelled it on purpose: stop, do not retry
if (/timeout|timed out|ETIMEDOUT/i.test(String(e?.name) + ' ' + String(e?.message))) return 'timeout';
return 'network';
};
const MAX_WAIT_MS = 120000; // a server asking for more than two minutes is asking you to stop, not to sleep
const retryAfterMs = (e) => {
const h = e?.headers?.get?.('retry-after') ?? e?.response?.headers?.get?.('retry-after');
const n = Number(h);
if (Number.isFinite(n) && n > 0) return n * 1000;
const d = Date.parse(h); // the HTTP date form
return Number.isFinite(d) ? Math.max(0, d - Date.now()) : 0;
};
export async function withThreeStrikes(fn, { strikes = 3, baseMs = 1000, maxMs = 30000 } = {}) {
let last, cat = 'network';
for (let i = 1; i <= strikes; i++) {
try { return await fn(i); } catch (e) {
last = e; cat = category(e);
if (cat === 'auth' || cat === 'client') throw new ThreeStrikesError(cat, i, e); // the same request will fail the same way
if (i === strikes) break;
const asked = retryAfterMs(e);
if (asked > MAX_WAIT_MS) throw new ThreeStrikesError('rate_limit', i, e);
const backoff = Math.min(maxMs, baseMs * 2 ** (i - 1)) * (0.5 + Math.random() / 2);
await sleep(Math.max(asked, backoff));
}
}
throw new ThreeStrikesError(cat, strikes, last);
}
// Usage:
// const data = await withThreeStrikes(async () => (okOrThrow(await fetch(url))).json());
The same behavior in the standard library.
# three_strikes.py: retry like an adult. Standard library only.
import random
import time
class ThreeStrikes(Exception):
def __init__(self, category, attempts, last):
super().__init__(f"Three strikes ({category}). Stop and ask a human.")
self.category, self.attempts, self.last = category, attempts, last
def _status(e):
r = getattr(e, "response", None)
return getattr(r, "status_code", None) or getattr(e, "status", None)
RETRYABLE = {408, 421, 423, 425, 429, 500, 502, 503, 504, 507, 520, 522, 524}
MAX_WAIT = 120.0 # a server asking for more than two minutes is asking you to stop, not to sleep
def _category(e):
s = _status(e)
if s == 429:
return "rate_limit"
if s in (401, 403):
return "auth"
if s and s >= 400:
return "server" if s in RETRYABLE else "client"
if "timeout" in (type(e).__name__ + str(e)).lower() or "timed out" in str(e).lower():
return "timeout"
return "network"
def _retry_after(e):
r = getattr(e, "response", None) or e
try:
v = r.headers.get("Retry-After")
try:
n = float(v)
except ValueError:
from email.utils import parsedate_to_datetime
n = parsedate_to_datetime(v).timestamp() - time.time()
return n if 0 < n < float("inf") else 0.0
except Exception:
return 0.0
def with_three_strikes(fn, strikes=3, base=1.0, cap=30.0):
cat, last = "network", None
for i in range(1, strikes + 1):
try:
return fn(i)
except Exception as e: # noqa: BLE001
last, cat = e, _category(e)
if cat in ("auth", "client"):
raise ThreeStrikes(cat, i, e) from e # the same request will fail the same way
if i == strikes:
break
asked = _retry_after(e)
if asked > MAX_WAIT:
raise ThreeStrikes("rate_limit", i, e) from e
backoff = min(cap, base * 2 ** (i - 1)) * (0.5 + random.random() / 2)
time.sleep(max(asked, backoff))
raise ThreeStrikes(cat, strikes, last) from last
# Usage:
# def call(n):
# r = requests.get(url, timeout=10)
# r.raise_for_status()
# return r.json()
# data = with_three_strikes(call)
For scripts and CI.
# curl: three tries, backoff built in, then stop. curl --retry 2 --retry-max-time 60 --fail-with-body -sS "$URL"
Does it actually wait when a server says wait? Run the Restraint Test and put a badge on your README. Wondering what a status code wants from you? The Status Menu has a retry verdict for every code.