AI API 재시도 코드,
가짜 장애 시험을 모두 통과했다
LLM API 장애 대응은 ‘오류가 나면 세 번 더 호출’로 끝나지 않는다. 같은 429라도 잠깐 기다리면 풀리는 속도 제한과 크레딧·할당량 문제가 다르다. 서버가 60초 뒤에 오라고 했는데 서비스가 3초만 기다릴 수 있다면, 3초 뒤에 다시 보내는 대신 현재 작업을 보류해야 한다.
2026년 9월 24일 로컬 HTTP 서버에 11가지 응답 조건을 넣어 각각 세 번 실행했다. 제한된 재시도, 즉시 중단, 작업 보류와 응답 본문 타임아웃을 포함한 33회 모두 사전에 정한 판정과 일치했다. 코딩하는 상인에서 실행한 교육용 실험이며, OpenAI·Claude·Gemini의 실제 장애나 응답 품질·청구 비용을 측정한 결과는 아니다.
결론
잠깐 기다리면 풀리는 오류는 다시 보내고, 크레딧 소진이나 잘못된 키에서는 멈추는지 봤어요. 실제 AI API 대신 오류를 흉내 내는 로컬 시험 서버로 장애 11가지를 3번씩 돌려 결과와 요청 횟수를 기대값과 비교했어요. 시험 중 날짜 해석을 고친 뒤의 결과예요.
주요 수치
- 33/33번
- 11개
- 11개
목차
LLM API 장애 대응 실험에서 확인한 결과
Node.js v22.19.0, macOS arm64에서 내장 fetch와 HTTP 서버를 썼다. 요청은 127.0.0.1로만 보냈다. API 키, 외부 서비스, 고객 데이터, 유료 호출은 사용하지 않았다. SDK 자동 재시도도 없는 조건이다.
최초 요청을 포함해 최대 3회, 전체 제한 시간 3,000ms, 허용할 최대 재시도 대기 2,000ms를 정했다. 헤더가 없거나 유효하지 않으면 100ms, 200ms 순서로 기다렸다. 재현 시험에서는 지터를 0으로 고정했다. 배포용 함수의 기본 지터는 0~99ms다.
| 로컬 서버 조건 | 실제 전송 횟수, 매 반복 동일 | 관측한 처리 |
|---|---|---|
429 → 200, Retry-After: 1 | 2 | 최소 1초 기다린 뒤 성공 |
| 503 → 200, 미래 HTTP-date | 2 | 지정 시각 이후 재요청해 성공 |
| 500 → 500 → 200 | 3 | 백오프 후 성공 |
| 계속 500 | 3 | 시도 상한에서 종료 |
| 크레딧 소진 코드가 붙은 429 | 1 | 설정·결제 조치 필요로 종료 |
| 잘못된 키를 뜻하는 401 | 1 | 재시도 없이 종료 |
| 원인을 모르는 코드의 429 | 1 | 재시도 없이 확인 대상으로 종료 |
429, Retry-After: 60 | 1 | 대기 상한을 넘으므로 보류 |
| 429, 1초 대기 지시·전체 제한 200ms | 1 | 전체 제한 시간을 넘으므로 보류 |
503 → 200, 헤더 값 tomorrow | 2 | 잘못된 헤더 대신 100ms 백오프 |
| 200 헤더 뒤 본문이 늦음, 전체 제한 80ms | 1 | 결과 미확정으로 종료 |
각 조건은 세 번 반복했다. 첫 번째 조건은 서버가 받은 두 요청의 간격이 1,000ms 이상인지도 검사했다. HTTP-date 조건에서는 서버가 발행한 시각 전 재요청이 없었는지 확인했다. 이 표의 100% 통과는 이 코드와 조건에 대한 검증이다. 실제 공급사의 복구율을 뜻하지 않는다.
실험 원본 결과 JSON에는 매 실행의 전송 횟수, 요청 간격, 경과 시간과 판정 로그가 들어 있다. 클라이언트 코드와 실험 실행 파일을 같은 폴더에 저장하면 다시 실행할 수 있다.
node run-experiment.mjs
추가 npm 패키지가 필요하지 않다. 실행 파일은 로컬 서버를 만들고 시험이 끝나면 닫는다. 성공하면 실행 폴더의 results.json을 새 측정값으로 덮어쓴다. 원본 기록을 보존하려면 복사본에서 실행한다.
Retry-After를 줄여서 기다리면 안 된다
RFC 9110의 Retry-After 정의는 값을 초 단위 정수 또는 HTTP-date로 구분한다. parseInt()만 쓰면 날짜를 처리할 수 없고, 일부 잘못된 문자열도 정수로 받아들인다.
실험 코드는 음수·소수·임의 문자열을 유효한 초 값으로 취급하지 않는다. 0초, 미래·과거 날짜, 과거 HTTP-date 표기, 매우 큰 정수까지 11가지 파서 입력도 검사했다. 과거 날짜는 추가 대기 0ms로 처리하고, 표현 범위를 넘는 큰 정수는 즉시 재시도할 값으로 바꾸지 않는다.
대기시간을 정하는 부분은 다음과 같다. 전체 코드는 다운로드 파일에 있다.
const hint = retryAfterMs(response.headers.get('retry-after'));
const delay = (hint ?? baseDelayMs * 2 ** (attempt - 1)) + jitter();
const timeLeft = deadlineMs - (performance.now() - started);
if (delay > maxRetryDelayMs || delay >= timeLeft) {
return finish('deferred', attempt);
}
await sleep(delay);
여기서 Math.min(serverDelay, maxDelay)로 서버의 대기를 잘라내지 않는다. 대기할 수 없으면 deferred를 반환한다. 호출하는 쪽은 이를 대기열, 캐시 또는 사용자 안내로 연결해야 한다. 예제 자체는 작업을 대기열에 저장하지 않는다.
OpenAI 속도 제한 문서도 유효한 서버 대기시간이 지원 상한보다 길면 더 일찍 재시도하지 말고 요청을 미루라고 설명한다. 전체 제한 시간과 각 시도의 타임아웃도 같은 설정이 아니다. 이 예제는 매 시도에 남은 전체 시간을 전달하며 응답 본문을 읽는 동안에도 같은 취소 신호를 유지한다.
HTTP 상태 뒤에 공급사의 세부 코드를 읽는다
실험의 오류 응답은 실제 공급사의 형식을 단순화한 fixture다. 운영 코드에서는 사용하는 엔드포인트의 정확한 오류 구조를 변환하는 단계가 필요하다.
| 공식 문서에서 확인한 경우 | 처리 기준 |
|---|---|
OpenAI 429 slow_down | 급격한 요청 증가를 줄이고 서버 대기 지시 확인 |
OpenAI 429 credit_balance_exhausted 또는 비용·사용량 상한 코드 | 크레딧·한도·초기화 조건 확인, 즉시 반복 전송 금지 |
Claude 429 rate_limit_error | retry-after와 error.details.error_code를 함께 확인 |
Claude enforced_spend_limit_reached | 강제 비용 상한 상태, 일반 속도 제한과 분리 |
Gemini Interactions API rate_limit_exceeded | 지수 백오프 대상 |
Gemini Interactions API quota_exceeded | 할당량 초기화 또는 상향 요청 확인 |
근거는 OpenAI 오류 코드, Claude 한도 문서, Gemini API 오류 표다. Gemini의 Interactions 오류 이름을 Generate Content 응답에 그대로 기대해서는 안 된다. 프로젝트 한도, 키 권한, 결제 상태는 실제 응답과 계정 화면으로 확인한다.
예제는 원인을 모르는 429를 자동 재시도하지 않는다. 반복 전송으로 풀리는 오류인지 확인하지 못했기 때문이다. 운영 환경에서 처리할 오류를 추가할 때는 상태 코드만 넓히지 말고 실제 오류 응답과 기대 동작을 테스트에 넣는다.
SDK와 앱의 재시도가 겹치면 호출 수가 늘어난다
이번 실험에는 SDK가 없다. 실제 서비스에서 같은 반복문으로 SDK 메서드를 감싸면 호출 횟수가 달라진다. Claude 오류 문서는 공식 SDK가 일부 일시 오류를 기본 두 번 재시도한다고 안내한다. OpenAI도 SDK와 버전에 따라 재시도 및 긴 대기 처리 방식이 달라질 수 있다고 설명한다.
앱이 최대 3회 시도하고 SDK가 매번 최초 전송 뒤 2회 더 보내면, 한 공급사에 최대 9회 전송한다. 같은 정책으로 공급사 두 곳을 순차 폴백하면 최대 18회다. 성공 시 조기 종료하므로 실제 평균이나 청구 횟수를 뜻하는 숫자는 아니다.
재시도를 앱이 관리할지 SDK가 관리할지 먼저 정한다. 앱에서 관리한다면 SDK 재시도를 끄는 설정을 확인한다. SDK에 맡긴다면 내부 전송 횟수까지 포함해 전체 제한 시간과 호출 상한을 시험해야 한다.
타임아웃 뒤에는 결과를 모를 수 있다
실험의 마지막 조건은 HTTP 200 헤더를 먼저 보내고 본문을 늦췄다. 상태만 보고 성공으로 기록하는 클라이언트라면 잘못 끝내지만, 이 코드는 본문 소비 중 취소를 감지해 result_unknown을 남겼다.
클라이언트의 연결 종료가 서버 작업의 취소를 증명하지는 않는다. 특히 메일 발송·결제·DB 변경처럼 부수 효과가 있는 요청은 결과를 조회하지 않고 재전송하면 중복 실행될 수 있다. 이 예제는 그런 작업을 실행하거나 멱등성을 구현하지 않는다.
스트리밍도 별도 설계가 필요하다. Claude는 HTTP 200 이후 SSE 오류 이벤트가 생길 수 있다고 문서에 명시한다. 이미 사용자에게 보낸 문장이 있다면 전체 요청을 다시 실행했을 때 중복 출력이 생긴다. 세 API의 TypeScript 예제에서 스트리밍·완료 상태를 함께 확인해야 한다.
폴백은 HTTP 200 외에 출력 계약도 통과해야 한다
대체 모델이 성공 응답을 돌려줘도 필요한 JSON 필드가 빠지거나 도구 인수가 달라질 수 있다. 폴백 후보는 평소에 같은 입력으로 스키마, 업무 규칙, 거절 처리, 데이터 보존 조건과 지연을 검증해 둬야 한다.
모델 전환만이 대체 경로는 아니다. 마지막 승인 결과 캐시, 사람이 검토한 템플릿, 나중에 처리하는 작업과 명확한 실패 안내도 후보가 된다. 결제나 권한 변경은 검증되지 않은 대체 모델로 계속 실행하는 것보다 승인된 실패 경로로 닫는 편이 낫다.
이 실험은 공급사 간 폴백 품질과 실제 토큰 사용량을 측정하지 않았다. 전송 횟수가 제한돼도 각 호출의 출력이 길면 비용은 커진다. 운영에서는 최대 출력 토큰, 사용자·작업별 예산, 최초 호출과 폴백의 사용량 합계를 별도로 둔다. API 비용 계산기는 토큰 가격을 비교할 때 쓰고 실제 청구액은 공급사 사용량 기록과 대조한다.
로그에는 재시도한 이유와 멈춘 이유를 남긴다
원본 로그에는 시도 번호, HTTP 상태, 오류 코드, 요청 ID, 대기시간과 판정만 넣었다. 응답 본문이 로그에 섞이지 않는지도 검사했다. 이 fixture에서 원문이 빠졌다는 결과가 운영 로그 전체의 개인정보 보호를 보증하지는 않는다.
실제 서비스에서는 논리 작업 ID와 배포 버전을 더해 최초 요청·재시도·폴백을 연결한다. 성공한 응답만 저장하면 장애 뒤 호출 수가 왜 늘었는지 알 수 없다. 토큰 정보가 없으면 0으로 채우지 말고 미확인으로 남긴다.
작업을 보류한 뒤 누가 다시 실행할지, 결과 미확정 상태를 어떻게 조회할지, 상한에 닿은 사용자에게 무엇을 보여줄지가 정해져야 장애 대응이 완성된다. 이 코드를 출발점으로 쓴다면 먼저 실제 서비스의 실패 응답을 익명 fixture로 추가하고, 그 조건에서도 호출 수와 전체 제한 시간이 지켜지는지 확인한다.
2026년 9월 24일 수정: 공식 문서 설명에 로컬 HTTP 장애 주입 실험 33회와 파서 경계 11건을 추가했다. 실행 파일과 원본 결과를 공개하고 실제 공급사 장애·모델 품질·과금 검증과 구분했다.
역할 분담과 원본 파일
- AI가 한 일
- 실험을 돌려 측정하고 초안을 썼어요. 프로그램이 정해 둔 기준대로 채점했어요.
- 운영자가 한 일
- 시험할 주제를 고르고 공개 여부를 정했어요.