요약(155자)
Claude API 재시도는 오류 코드를 보고 반복하는 기능이 아니다. SDK 기본값, Retry-After, 지수 백오프와 jitter, 시간 예산, 중복 처리 방지, request-id 로깅을 함께 설계해야 한다. 이 글은 자동 테스트 가능한 Node.js 구현을 제시한다.
API 자동화를 운영하다 보면 429나 529는 피할 수 없다. 문제는 오류 자체보다 재시도 방식이다. 실패 즉시 같은 요청을 반복하면 제한이 풀리기 전에 다시 막히고, 여러 작업이 동시에 재시도되면 순간 트래픽이 더 커진다. 반대로 모든 오류를 포기하면 잠깐의 네트워크 흔들림에도 작업이 유실된다.
재시도 정책에는 다음 질문의 답이 들어 있어야 한다.
- 어떤 실패를 다시 시도할 것인가?
- 얼마나 기다리고, 최대 몇 번 시도할 것인가?
- SDK의 기본 재시도와 애플리케이션 재시도가 겹치지 않는가?
- 첫 요청이 이미 처리됐다면 중복 실행을 어떻게 막을 것인가?
- 실패한 한 건을 나중에 추적할 정보가 남는가?
이 글의 기준은 2026년 7월 19일에 확인한 Anthropic 공식 문서와 공식 TypeScript SDK 문서다. 특정 설정이 처리량이나 성공률을 몇 퍼센트 높인다는 식의 수치는 제시하지 않는다. 적정값은 작업 시간, 호출량, 모델, 사용 등급에 따라 달라진다.
먼저 SDK 기본 동작을 확인한다
Anthropic TypeScript SDK는 다음 실패를 기본으로 재시도한다.
- 네트워크 연결 오류
- HTTP 408 Request Timeout
- HTTP 409 Conflict
- HTTP 429 Rate Limit
- HTTP 500 이상 서버 오류
기본값은 maxRetries: 2다. 최초 요청 뒤 최대 두 번 더 시도하므로 한 번의 SDK 호출이 최대 세 번의 HTTP 시도가 될 수 있다. SDK는 짧은 지수 백오프를 적용하고, 응답에 retry-after 헤더가 있으면 이를 따른다.
기본 동작만으로 충분한 서비스라면 직접 재시도 코드를 추가하지 않는 편이 낫다.
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic({
maxRetries: 2, // 기본값이므로 생략 가능
});
작업 큐의 전체 제한 시간, 자체 로그 형식, 중복 방지 절차처럼 애플리케이션 차원의 정책이 필요하다면 SDK 재시도를 끄고 한 계층에서만 통제한다.
const client = new Anthropic({
maxRetries: 0,
timeout: 20_000,
});
예를 들어 바깥쪽 코드가 최대 네 번 호출하고 SDK 기본 재시도도 켜 두면, 최악에는 HTTP 시도가 최대 12번까지 늘어날 수 있다. 어느 계층이 재시도를 소유하는지 코드와 운영 문서에 명시해야 한다.
다시 시도할 오류와 멈춰야 할 오류
공식 오류 문서와 TypeScript SDK 정책을 운영 관점에서 정리하면 다음과 같다.
| 상태 | 기본 판단 | 운영 시 주의점 |
|---|---|---|
| 연결 오류 | 재시도 | 서버가 요청을 받았는지 알 수 없는 경우가 있어 중복 위험이 있다. |
| 408 | 재시도 | 요청 단위 타임아웃과 전체 시간 예산을 함께 둔다. |
| 409 | 조건부 재시도 | SDK는 자동 재시도하지만, 리소스 상태 충돌은 원인을 해소하지 않으면 반복해도 실패한다. 쓰기 작업은 현재 상태를 다시 읽고 판단한다. |
429 rate_limit_error |
재시도 | retry-after를 우선하고 동시성을 낮춘다. 갑작스러운 사용량 증가로 acceleration limit에 걸릴 수도 있다. |
500 api_error |
재시도 | 지수 백오프를 적용한다. 계속 실패하면 request ID와 함께 지원에 문의한다. |
504 timeout_error |
재시도 | 긴 비스트리밍 요청이라면 스트리밍 전환도 검토한다. |
529 overloaded_error |
재시도 | 전체 사용자 트래픽으로 API가 일시 과부하된 상태다. |
400 invalid_request_error |
재시도하지 않음 | 입력 형식이나 내용을 수정해야 한다. 같은 요청 반복은 의미가 없다. |
401 authentication_error |
재시도하지 않음 | API 키 또는 자격 증명을 고친다. |
402 billing_error |
재시도하지 않음 | 결제 정보를 확인한다. |
403 permission_error |
재시도하지 않음 | 권한과 워크스페이스 설정을 고친다. |
404 not_found_error |
재시도하지 않음 | 경로, 모델 또는 리소스 ID를 확인한다. |
413 request_too_large |
재시도하지 않음 | 요청 크기를 줄인다. |
| 그 밖의 4xx | 원인 수정 전 재시도하지 않음 | 메시지 문자열이 아니라 상태 코드와 SDK의 타입을 기준으로 분기한다. |
429가 발생했다고 무조건 호출 간격만 늘리면 충분한 것은 아니다. Anthropic의 rate limit은 RPM, 입력 토큰/분(ITPM), 출력 토큰/분(OTPM)처럼 여러 축으로 적용된다. 공식 문서는 짧은 구간의 burst도 제한에 걸릴 수 있으며, token bucket 방식으로 용량이 계속 보충된다고 설명한다. 요청 수뿐 아니라 동시 실행 수와 토큰 사용량도 함께 봐야 한다.
지수 백오프에 jitter를 섞는 이유
기본 지수 백오프는 시도할 때마다 대기 상한을 키운다.
상한 = min(최대 대기시간, 기본 대기시간 × 2^(시도 번호 - 1))
실제 대기 = 0 이상 상한 미만의 무작위 값
여러 작업이 같은 순간 실패했을 때 모두 1초, 2초, 4초 뒤에 정확히 다시 요청하면 트래픽 봉우리가 반복된다. full jitter는 각 작업의 재시도 시점을 흩어 놓는다.
Retry-After가 있으면 서버가 알려 준 시간을 최소 대기시간으로 취급한다. 애플리케이션의 백오프 상한이 30초여도 Retry-After: 60을 30초로 잘라서는 안 된다. 다만 60초가 작업의 전체 시간 예산을 넘는다면 억지로 기다리지 말고 현재 작업을 실패 또는 지연 큐 상태로 넘긴다.
최대 시도와 타임아웃은 별개다
maxAttempts만 두면 한 번의 요청이 오래 멈췄을 때 전체 작업 시간이 통제되지 않는다. 최소 두 종류의 제한이 필요하다.
- 요청 단위 타임아웃: HTTP 시도 한 번이 기다릴 수 있는 시간
- 전체 시간 예산: 백오프 대기까지 포함해 작업 하나가 쓸 수 있는 총시간
현재 TypeScript SDK의 기본 요청 타임아웃은 10분이며, 비스트리밍 요청의 max_tokens 값에 따라 동적으로 계산될 수 있다. 자동화 작업에는 이 기본값이 너무 길 수 있다. 반대로 장문 생성에 무조건 20초를 적용하면 정상 요청을 스스로 끊게 된다. 아래 예제의 20초/60초/4회는 동작을 보여 주는 시작값일 뿐 권장 보장값이 아니다. 실제 지연 분포와 작업 마감 시간을 보고 조정해야 한다.
Anthropic 문서는 오래 걸리는 요청에 스트리밍을 권한다. 단, SSE 스트림은 HTTP 200을 받은 뒤에도 오류가 날 수 있다. 이 오류는 일반 HTTP 오류 처리와 같지 않다. 중간까지 받은 출력을 버리고 새 요청을 시작하면 생성 비용과 출력이 중복될 수 있으므로, 스트림 재시도 정책은 별도로 설계한다.
Node.js 구현: 재시도 정책을 한 계층에 둔다
다음 예제는 Node.js 20 이상과 @anthropic-ai/sdk를 사용한다.
npm install @anthropic-ai/sdk
retry.mjs
import Anthropic from '@anthropic-ai/sdk';
export function retryAfterMs(value, nowMs = Date.now()) {
if (!value) return null;
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.ceil(seconds * 1_000);
}
const dateMs = Date.parse(value);
if (Number.isNaN(dateMs)) return null;
return Math.max(0, dateMs - nowMs);
}
export function requestIdOf(error) {
return (
error?.requestID ??
error?.headers?.get?.('request-id') ??
error?.error?.request_id ??
null
);
}
export function isRetryable(error) {
if (error instanceof Anthropic.APIConnectionError) return true;
const status = error?.status;
return status === 408 || status === 409 || status === 429 || status >= 500;
}
export function nextDelayMs({
attempt,
retryAfter,
baseMs = 500,
capMs = 30_000,
random = Math.random,
nowMs = Date.now(),
}) {
const exponentialCap = Math.min(capMs, baseMs * 2 ** (attempt - 1));
const fullJitter = Math.floor(random() * exponentialCap);
const serverMinimum = retryAfterMs(retryAfter, nowMs) ?? 0;
return Math.max(serverMinimum, fullJitter);
}
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function withRetry(
operation,
{
maxAttempts = 4,
totalTimeoutMs = 60_000,
baseMs = 500,
capMs = 30_000,
random = Math.random,
sleep = defaultSleep,
now = Date.now,
logger = console,
} = {},
) {
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
throw new RangeError('maxAttempts must be a positive integer');
}
const deadline = now() + totalTimeoutMs;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const remainingMs = Math.max(1, deadline - now());
return await operation({ attempt, remainingMs });
} catch (error) {
const retryable = isRetryable(error);
const requestId = requestIdOf(error);
logger.warn?.('claude_request_failed', {
attempt,
maxAttempts,
status: error?.status ?? null,
type: error?.type ?? error?.name ?? 'unknown_error',
requestId,
retryable,
});
if (!retryable || attempt === maxAttempts) throw error;
const retryAfter = error?.headers?.get?.('retry-after') ?? null;
const delayMs = nextDelayMs({
attempt,
retryAfter,
baseMs,
capMs,
random,
nowMs: now(),
});
const remainingMs = deadline - now();
if (delayMs >= remainingMs) {
throw new Error('Retry budget exhausted before the next attempt', {
cause: error,
});
}
logger.info?.('claude_retry_scheduled', {
attempt,
nextAttempt: attempt + 1,
delayMs,
requestId,
});
await sleep(delayMs);
}
}
throw new Error('unreachable');
}
example.mjs
import Anthropic from '@anthropic-ai/sdk';
import { withRetry } from './retry.mjs';
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
maxRetries: 0, // 애플리케이션 재시도 계층 하나만 사용한다.
timeout: 20_000,
});
const message = await withRetry(
({ remainingMs }) =>
client.messages.create(
{
model: process.env.CLAUDE_MODEL ?? 'claude-sonnet-4-6',
max_tokens: 256,
messages: [
{ role: 'user', content: '재시도 설계를 한 문장으로 설명해줘.' },
],
},
{
maxRetries: 0,
timeout: Math.min(20_000, remainingMs),
},
),
{
maxAttempts: 4,
totalTimeoutMs: 60_000,
},
);
console.info('claude_request_succeeded', {
requestId: message._request_id,
messageId: message.id,
});
console.log(message.content);
SDK의 정상 객체 응답에는 request-id 헤더에서 가져온 _request_id가 붙는다. 실패 시 Anthropic.APIError 계열에는 requestID, status, headers, type이 있으므로 구조화 로그에 남길 수 있다. 지원 문의에는 문제가 된 시도의 request ID를 포함한다. 재시도마다 새 요청이므로 request ID도 시도별로 기록해야 한다.
자동 테스트: 실제 API를 호출하지 않고 정책을 검증한다
재시도 코드는 실패 경로가 핵심이다. 운영 장애를 기다려 확인하지 말고 가짜 429와 400을 주입해 테스트한다.
retry.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import {
isRetryable,
nextDelayMs,
retryAfterMs,
withRetry,
} from './retry.mjs';
const silentLogger = { warn() {}, info() {} };
test('Retry-After 초 단위를 밀리초로 바꾼다', () => {
assert.equal(retryAfterMs('3'), 3_000);
});
test('Retry-After HTTP-date를 해석한다', () => {
const now = Date.parse('2026-07-19T00:00:00Z');
assert.equal(
retryAfterMs('Sun, 19 Jul 2026 00:00:05 GMT', now),
5_000,
);
});
test('서버 대기시간을 jitter보다 우선한다', () => {
assert.equal(
nextDelayMs({ attempt: 2, retryAfter: '3', random: () => 0.5 }),
3_000,
);
});
test('429 뒤에 재시도하고 성공한다', async () => {
let calls = 0;
const sleeps = [];
const result = await withRetry(
async () => {
calls += 1;
if (calls < 3) {
throw {
status: 429,
type: 'rate_limit_error',
requestID: `req_test_${calls}`,
headers: new Headers({ 'retry-after': '0.01' }),
};
}
return 'ok';
},
{
maxAttempts: 4,
totalTimeoutMs: 1_000,
random: () => 0,
sleep: async (ms) => sleeps.push(ms),
logger: silentLogger,
},
);
assert.equal(result, 'ok');
assert.equal(calls, 3);
assert.deepEqual(sleeps, [10, 10]);
});
test('400은 재시도하지 않는다', async () => {
let calls = 0;
await assert.rejects(
withRetry(
async () => {
calls += 1;
throw { status: 400, type: 'invalid_request_error' };
},
{ maxAttempts: 4, logger: silentLogger },
),
);
assert.equal(calls, 1);
});
test('상태 코드를 분류한다', () => {
for (const status of [408, 409, 429, 500, 504, 529]) {
assert.equal(isRetryable({ status }), true);
}
for (const status of [400, 401, 402, 403, 404, 413, 422]) {
assert.equal(isRetryable({ status }), false);
}
});
package.json에 다음 스크립트를 추가한 뒤 실행한다.
{
"type": "module",
"scripts": {
"test": "node --test retry.test.mjs"
}
}
npm test
이 글을 작성하며 별도 예제 디렉터리에서 위 정책을 실제로 실행했다. Retry-After 초 단위와 HTTP-date 해석, 429 후 성공, 400 즉시 중단, 상태 분류까지 6개 테스트가 통과했다. 이는 로컬 코드의 동작 확인 결과이며 Claude API의 성능이나 가용성 수치가 아니다.
재시도의 가장 어려운 부분은 중복 요청이다
타임아웃이나 연결 종료는 모호하다. 클라이언트가 응답을 못 받았을 뿐 서버는 첫 요청을 처리했을 수 있다. 같은 Messages 요청을 다시 보내면 새 생성 요청이 된다. 같은 프롬프트라도 출력이 같다는 보장이 없고 호출 비용도 다시 발생할 수 있다.
더 위험한 경우는 생성 뒤의 부수 효과다.
// 피해야 할 구조: 전체 콜백을 재시도하면 이메일도 중복 발송될 수 있다.
await withRetry(async () => {
const message = await client.messages.create(input);
await sendEmail(message); // 부수 효과
});
Claude 호출 재시도와 업무 반영을 분리한다.
const message = await withRetry(() => client.messages.create(input));
await commitOnce({ operationId, message });
commitOnce는 애플리케이션이 만든 operationId를 데이터베이스의 고유 키로 저장하고, 같은 키가 이미 완료됐다면 이메일 발송·결제·CMS 게시 같은 작업을 다시 실행하지 않아야 한다. 작업 상태도 pending, generated, committed, failed처럼 저장하면 프로세스가 재시작돼도 이어서 판단할 수 있다.
이 방식은 Claude API 호출 자체의 중복 비용까지 없애지는 못한다. 대신 외부 시스템의 중복 변경을 막는다. Messages API에 임의의 멱등성 키가 있다고 가정해 헤더를 붙이지 말고, 사용 중인 엔드포인트의 공식 문서에 멱등성 지원이 명시돼 있는지 확인해야 한다.
도구 사용(tool use)이 포함된 자동화라면 도구 실행 결과에도 같은 원칙을 적용한다. Claude 요청을 재시도하면서 이미 실행한 결제, 메일 발송, 파일 삭제 도구를 다시 실행해서는 안 된다. 도구 호출 ID만 믿기보다 업무용 operationId와 실행 결과를 별도 저장하는 편이 안전하다.
운영 로그에 남길 항목
성공과 실패를 모두 구조화 로그로 남긴다.
- 내부
operationId와 작업 종류 - 시도 번호와 최대 시도 수
- HTTP 상태와 Anthropic 오류
type request-id- 적용한 대기시간과
Retry-After - 요청 단위 타임아웃과 남은 전체 시간
- 사용 모델
- 최종 결과: 성공, 재시도 소진, 재시도 불가, 전체 시간 소진
프롬프트 전문과 API 키는 로그에 넣지 않는다. 개인정보나 고객 데이터가 포함될 수 있다. request ID는 Anthropic 요청을 추적하는 값이고, 내부 operation ID는 여러 시도와 후속 업무를 한 건으로 묶는 값이다. 둘은 역할이 다르므로 함께 보관한다.
소규모 운영 환경의 적용 순서
- SDK 기본 재시도만 쓸지, 애플리케이션이 재시도를 소유할지 먼저 정한다.
- 사용자 수정이 필요한 4xx는 즉시 실패 처리한다.
- 408, 429, 5xx와 연결 오류에는 지수 백오프와 full jitter를 적용한다.
Retry-After를 최소 대기시간으로 지킨다.- 최대 시도 수와 요청/전체 타임아웃을 따로 둔다.
- Claude 호출과 이메일·결제·게시 같은 부수 효과를 분리한다.
- 내부 operation ID에 고유 제약을 걸어 업무 반영을 한 번만 수행한다.
- 각 시도의 request ID를 기록하고 429 비율, 대기시간, 최종 실패를 모니터링한다.
- 트래픽이 갑자기 늘지 않도록 큐 동시성을 제한하고 서서히 올린다.
- 가짜 오류를 주입한 단위 테스트와 스테이징 장애 테스트를 배포 전에 실행한다.
재시도는 성공할 때까지 반복하는 루프가 아니다. 일시 장애에는 시간을 벌어 주고, 수정이 필요한 오류는 빨리 드러내며, 결과를 확신할 수 없는 요청은 중복 피해를 막는 제어 장치다.
출처
확인일: 2026-07-19
- Anthropic, Claude API errors — HTTP 오류 유형, 529, SDK 기본 재시도,
retry-after, 오류 본문의request_id, 응답의request-id. - Anthropic, Rate limits — RPM·ITPM·OTPM, 429와
retry-after, token bucket, burst 및 acceleration limit. - Anthropic, TypeScript SDK —
maxRetries, 자동 재시도 대상, 타임아웃, SDK 오류 타입,_request_id. - Anthropic, anthropic-sdk-typescript — 공식 SDK 소스와 패키지 사용법.
함께 읽을 가이드
공식 문서 확인 기준일: 2026년 7월 19일. 가격·모델·할당량은 변경될 수 있으므로 실행 전 연결된 공식 문서를 다시 확인하세요.
'🤖 1인 에이전트 구축기' 카테고리의 다른 글
| n8n 워크플로우 무중단 운영을 위한 예외 처리 및 대체 모델 필수 체크리스트 (0) | 2026.06.12 |
|---|---|
| Gemini·Claude 교차검증 가이드: LLM 합의보다 근거를 비교하는 방법 (0) | 2026.05.27 |
| API Rate Limit 대응: 429 재시도·백오프·동시성 제한 설계 (0) | 2026.05.23 |
| Gemini API 시작 가이드: API 키 발급부터 Python 첫 호출까지 (0) | 2026.05.13 |
| Claude API 키 발급과 안전한 환경변수 설정: .gitignore·유출 대응·Secret Manager 가이드 (2026) (0) | 2026.05.12 |