LLM API 429 재시도 전략: 지수 백오프와 실패 격리 실전 구현
새벽 2시 7분, 자동 발행 파이프라인이 초안 3편 중 2편을 빈 파일로 남기고 끝나 있었습니다. 로그를 열어 보니 LLM API 응답이 전부 429였고, 재시도 코드는 3번 모두 0.5초 간격으로 두드리다 포기했더군요. 레이트리밋은 서버가 잠깐 쉬라고 말하는 신호인데, 제 코드는 그 말을 알아듣지 못했습니다.
이 글을 끝까지 따라오면 429 응답에서 대기 시간을 뽑아내고, 지수 백오프에 지터를 얹고, 배치 열 건 중 한 건이 죽어도 나머지 아홉 건을 살려 내는 재시도 계층을 직접 만들 수 있습니다. 코드는 Node.js 기준이지만 파이썬으로 옮겨도 구조는 같아요.
준비물과 사전 점검
Node.js 20 이상, fetch 를 쓸 수 있는 런타임이면 충분합니다. 공식 SDK 를 쓴다면 자체 재시도가 이미 들어 있으니 겹치지 않게 최대 재시도 횟수를 먼저 확인하세요. anthropic-sdk-python 은 2026년 8월 20일에 1.0.0 이 나왔고, Anthropic 공식 문서도 SDK 의 자동 재시도를 전제로 설명합니다. 티어별 한도는 계정 콘솔에서 직접 보는 게 정확해요. 문서 표는 기준값일 뿐, 조직 이력에 따라 달라집니다.
429가 났을 때 가장 먼저 봐야 할 헤더는 무엇인가요?
429 는 "지금은 안 된다"가 아니라 "언제 다시 오라"는 응답입니다. OpenAI 공식 문서는 Retry-After 값을 최소 대기 초로 보고, 그만큼 기다린 뒤 약간의 무작위 지연을 더해 재시도하라고 안내합니다. 여러 클라이언트가 같은 초에 몰려 다시 두드리는 사태를 막으려는 장치죠. 같은 문서가 알려 주는 x-ratelimit-remaining-requests 와 x-ratelimit-remaining-tokens 는 남은 여유를 미리 보여 주니, 저는 이 두 값을 로그에 남겨 매일 아침 훑습니다.
주의할 함정이 하나 있어요. Anthropic 공식 문서를 보면 조직 지출 상한에 도달했을 때도 HTTP 429 가 돌아오지만 이때는 retry-after 헤더가 없고, Messages API 응답의 error.details.error_code 가 enforced_spend_limit_reached 로 찍힙니다. 이 경우 재시도는 다음 달 1일까지 계속 실패합니다. 헤더가 없는 429 를 만나면 기다리지 말고 사람을 부르세요.
지수 백오프에 지터는 어떻게 얹나요?
백오프 공식 자체는 단순합니다. 재시도 n 번째의 대기 시간을 base * 2^n 으로 늘리고, 여기에 무작위 값을 섞어 요청이 한 지점에 겹치지 않게 흩뜨립니다. 저는 base 를 1초, 상한을 30초, 최대 시도를 5회로 두고 씁니다. 이 조합이면 최악의 경우에도 총 대기가 1분을 넘지 않아 크론 잡의 타임아웃 안에 들어와요.
const sleep = ms => new Promise(r => setTimeout(r, ms));
function backoffMs(attempt, retryAfterSec) {
const base = Math.min(1000 * 2 ** attempt, 30_000);
const floor = retryAfterSec ? retryAfterSec * 1000 : 0;
const wait = Math.max(base, floor); // 헤더 값은 최소 대기
return wait / 2 + Math.random() * (wait / 2); // 절반형 지터
}
핵심은 retryAfterSec 을 바닥값으로 쓰는 줄입니다. 서버가 56초를 기다리라고 했는데 계산식이 4초를 뱉으면 그 재시도는 다시 429 를 받습니다. 지터를 절반만 준 건 대기가 너무 짧게 떨어지는 걸 막으려는 선택입니다. 2026년 7월 파이프라인에서 지터 없이 돌렸을 때 동시 3개 워커가 같은 초에 몰려 429 를 연달아 세 번 받은 적이 있어요.
429와 529, 5xx는 같은 사고가 아닙니다
재시도 코드를 처음 짤 때 저는 4xx 와 5xx 를 뭉뚱그려 "실패면 다시"로 처리했습니다. 그 결과 고칠 수 없는 요청을 다섯 번씩 되쏘며 토큰만 태웠어요. 지금은 응답을 네 갈래로 나눕니다.
- 429 +
retry-after있음: 헤더가 준 초만큼 쉬고 재시도 - 429 + 헤더 없음: 지출 상한일 가능성이 크니 즉시 중단하고 알림
- 529 overloaded: 서버 혼잡 신호, 백오프를 두 배로 늘려 재시도
- 500·502·503: 일시 장애로 보고 3회까지만 재시도
529 는 제 운영 기록 기준 8월 한 달에 14건 나왔고, 전부 20~40초 뒤 정상 응답으로 돌아왔습니다. OpenAI 문서도 Retry-After 가 붙었다고 해서 결제·할당량 문제까지 재시도로 풀리지는 않는다고 못 박아 두었어요. 요청 형식이 틀려서 오는 400 invalidrequesterror 는 아예 재시도 대상에서 빼세요.
배치 요청에서 한 건의 실패를 어떻게 격리하나요?
하루 세 편 초안을 만드는 파이프라인에서 Promise.all 을 쓰다가 크게 데었습니다. 세 번째 호출이 429 로 죽으면 이미 완성된 앞의 두 편까지 통째로 버려졌거든요. Promise.allSettled 로 바꾸고 항목마다 재시도를 감싸니 부분 성공이 남기 시작했습니다.
async function withRetry(fn, max = 5) {
for (let i = 0; i < max; i++) {
try { return { ok: true, value: await fn() }; }
catch (e) {
if (e.status === 400) return { ok: false, error: e };
if (e.status !== 429 && e.status !== 529 && e.status < 500) throw e;
const ra = Number(e.headers?.['retry-after']) || null;
if (e.status === 429 && !ra) return { ok: false, error: e };
await sleep(backoffMs(i, ra));
}
}
return { ok: false, error: new Error('retry exhausted') };
}
const done = await Promise.allSettled(items.map(it => withRetry(() => call(it))));
동시성도 같이 조여야 합니다. Anthropic 공식 문서는 레이트리밋을 토큰 버킷으로 관리해 용량이 계속 채워진다고 설명하는데, 분당 60건이 초당 1건으로 집행될 수 있다는 단서도 함께 붙어 있어요. 그래서 저는 동시 실행을 3으로 묶고 항목 사이에 200밀리초 간격을 넣었습니다. 그 뒤 2주 동안 배치 전체가 통째로 날아간 사고는 0건입니다.
제가 날린 시간들 — 흔한 실수 세 가지
첫 번째는 재시도 로그를 남기지 않은 것입니다. 429 가 몇 번, 어느 모델에서 났는지 몰라 원인을 찾는 데 3시간을 썼어요. 지금은 시도 횟수·대기 시간·상태 코드를 한 줄 JSON 으로 남깁니다.
두 번째 실수는 스트리밍 중단을 재시도로 감싸지 않은 대목이었습니다. 스트리밍 응답은 앞부분을 이미 받은 상태에서 끊길 수 있는데, 그때 처음부터 다시 호출하면 앞서 소비한 입력 토큰이 그대로 날아갑니다. 저는 30초 무응답이면 끊고 재시도하되, 받은 텍스트를 임시 파일에 남겨 둡니다.
세 번째는 캐시를 놓친 쪽이에요. Anthropic 공식 문서에 따르면 대부분의 Claude 모델에서 캐시로 읽은 입력 토큰은 분당 입력 토큰 레이트리밋에 잡히지 않습니다. 시스템 지시문과 긴 문서를 캐시로 돌리자 같은 작업이 한도에 덜 걸렸습니다.
오늘 바로 손댈 한 가지
재시도 코드를 열어 retry-after 를 읽는 줄이 있는지부터 확인하세요. 없다면 그 한 줄이 오늘 추가할 전부입니다. 서버가 알려 준 시간을 무시하는 재시도는 레이트리밋을 푸는 게 아니라 키우는 쪽에 가깝습니다.
자주 묻는 질문
Q. 재시도 최대 횟수는 몇 번이 적당한가요?
A. 저는 5회를 쓰지만 사용자가 화면 앞에서 기다리는 경로라면 2회로 줄입니다. 배치 잡처럼 사람이 안 보는 경로만 5회까지 갑니다.
Q. 공식 SDK 재시도와 제 코드가 겹치면 어떻게 되나요?
A. 곱해집니다. SDK 기본 2회에 제 코드 5회를 얹으면 최대 10번 호출돼요. 한쪽을 0으로 끄고 다른 쪽만 남기세요.
Q. 모델을 바꿔 우회해도 되나요?
A. Anthropic 문서 기준 한도는 모델별로 따로 적용되니 다른 모델로 폴백하면 통과합니다. 다만 출력 품질이 달라지므로 폴백 사실을 결과에 표시해 두세요.
Q. 429가 계속 나는데 코드 문제인지 한도 문제인지 어떻게 구분하죠?
A. 남은 요청 수와 남은 토큰 수를 같이 찍어 보세요. 토큰이 넉넉한데 요청 수만 0이면 호출 빈도가 문제고, 반대면 프롬프트를 줄여야 합니다.