[LazyClaude 심층 6] Auto-Resume — rate-limit과 context-full을 분류하고 재시도하기
exit reason classifier · exponential backoff · snapshot-hash stall detection
LazyClaude 심층 6편. 긴 세션이 밤사이에 끊기면 보통 거기서 끝난다. Auto-Resume은 그 끊김에 분류 와 재시도 를 붙인다.
문제
Claude Code 세션은 다음 이유로 끊긴다:
| 이유 | 회복 가능성 |
|---|---|
| rate-limit | 시간이 지나면 됨 (정확한 reset time 알 수 있음) |
| context-full | 컨텍스트 압축 + 재개 필요 |
| auth-expired | 토큰 갱신 필요 |
| unknown | 분류 불가 — 사용자 개입 |
각각 대응 방법이 다르다. 한 가지 백오프 전략만 적용하면 어떤 경우에는 너무 빨리 깨고, 다른 경우에는 너무 늦게 시도한다.
분류 — 첫 번째 일
세션 종료 메시지·exit code·로그 마지막 라인을 보고 분류한다.
def classify_exit(stderr_tail: str, exit_code: int) -> str:
s = stderr_tail.lower()
if "rate limit" in s or "429" in s or "rate_limited" in s:
return "rate_limit"
if "context" in s and ("full" in s or "exceed" in s):
return "context_full"
if "401" in s or "unauthorized" in s or "auth" in s:
return "auth_expired"
return "unknown"
분류는 단순한 패턴 이어도 충분하다. 정확도보다 재현 가능성 이 중요. 잘못 분류하면 다음 시도에서 알게 된다.
reset time 파싱
rate_limit 으로 분류되었으면, 정확히 언제 다시 시도할 수 있는지를 stderr에서 뽑는다:
import re, datetime as dt
def parse_reset(s: str) -> dt.datetime | None:
m = re.search(r"retry after\s+([0-9T:\-Z\.]+)", s, re.I)
if not m: return None
try:
return dt.datetime.fromisoformat(m.group(1).replace("Z", "+00:00"))
except ValueError:
return None
서버가 알려 준 시각이 있으면 그 시각 까지 기다린다. 백오프 공식보다 훨씬 정확.
Exponential backoff — fallback
reset time 없이 분류만 가능한 경우는 지수 백오프:
import random
def backoff(attempt: int, base=2.0, cap=300.0) -> float:
expo = min(cap, base * (2 ** attempt))
jitter = random.uniform(0, expo * 0.2)
return expo + jitter
핵심:
- 지수 — 매 시도마다 2배
- 상한(cap) — 무한 증가 방지 (5분 정도)
- 지터(jitter) — 동시에 여러 클라이언트가 깨어나는 쿠팡 패턴 방지
Snapshot-hash stall detection
가끔 세션이 깨지지도, 진행하지도 않는 상태 로 멈춘다. 이건 분류기로 잡히지 않는다. 진행 자체 를 봐야 한다.
해법: 출력 스트림의 해시를 일정 간격으로 찍어서, N번 연속 같은 해시 면 stall로 판정.
import hashlib
def is_stalled(history: list[str], window=5) -> bool:
if len(history) < window: return False
return len(set(history[-window:])) == 1
def snapshot(s: str) -> str:
return hashlib.sha1(s.encode()).hexdigest()[:12]
stall이 감지되면 더 이상 기다리지 않고 즉시 재개 시도.
재개 — claude –resume
분류·대기·재개의 흐름:
def auto_resume_loop(session_id, max_attempts=8):
for attempt in range(max_attempts):
result = run_until_exit(["claude", "--resume", session_id])
if result.exit_code == 0:
return "ok"
reason = classify_exit(result.stderr_tail, result.exit_code)
if reason == "rate_limit":
wait_until = parse_reset(result.stderr_tail) or _backoff_clock(attempt)
sleep_until(wait_until)
elif reason == "context_full":
run_compaction(session_id)
elif reason == "auth_expired":
return "needs_user" # human in the loop
else:
time.sleep(backoff(attempt))
return "gave_up"
auth_expired는 사람이 풀어야 하는 일. 자동화하지 않는다.
SessionStart / Stop hook과의 연계
Claude Code의 hook 시스템은 세션 라이프사이클의 진입/이탈에 코드를 끼울 수 있다. Auto-Resume은 두 지점을 활용:
- Stop hook: 세션 종료 시점에 자체 종료 인지 이상 종료 인지 기록
- SessionStart hook: 재개된 세션이 시작할 때, 직전 컨텍스트의 핵심 을 다시 주입 (handoff 노트가 있다면 그것을 로드)
이걸로 재개된 세션 이 연속된 세션처럼 보이게 만든다.
UI — 패널과 배지
사용자가 이게 자동 재개되고 있는지 모르면 안 된다. LazyClaude는 두 위치에 표시:
- 세션 detail 패널: 시도 횟수, 다음 시도 시각, 분류 결과
- 세션 list의 🔄 AR 배지: Auto-Resume이 켜진 세션 표시
자동화는 보이지 않는 자동화 와 보이는 자동화 의 차이가 크다. 후자가 신뢰를 만든다.
일반화 — 재시도 시스템의 4가지 책임
이 패턴은 LazyClaude 바깥에서도 그대로 쓸 수 있다:
- Classifier: 왜 실패했는가
- Wait policy: 분류별로 다른 대기
- Stall detector: 진행 자체가 멈췄는지 별도로 감시
- Human-in-the-loop: 사람만 풀 수 있는 실패 를 명시적으로 분리
한 줄 요약
모든 실패에 같은 백오프를 쓰지 마라. 분류가 대기 전략 을 정한다.
다음 편: LazyClaude 심층 7 — Multi-provider 워크플로우 이전 편: LazyClaude 심층 5 — Hyper Agent