- Published on
OpenAI Responses API 503 멈춤 - 재시도·폴백 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI Responses API를 붙여두면, 평소엔 잘 되다가도 503(Service Unavailable) 구간에서 요청이 오래 걸리거나(체감상 “멈춤”), 연쇄적으로 타임아웃/스레드 고갈/큐 적체로 번지는 순간이 있습니다. 특히 스트리밍을 켜둔 상태에서 네트워크가 흔들리면 “응답이 끝나지 않는” 듯 보이기도 하죠.
이 글은 503 자체를 없애는 방법이 아니라, 503이 발생해도 서비스가 멈추지 않게 만드는 재시도·폴백 설계를 다룹니다. 핵심은 아래 4가지를 한 세트로 보는 것입니다.
- 요청 단 타임아웃(connect/read/overall) 명확히
- 재시도 정책(exponential backoff + jitter + retry budget)
- 서킷 브레이커/벌크헤드로 폭주 격리
- 폴백 경로(모델 다운그레이드, 캐시, 규칙 기반 응답, 지연 처리)
추가로 Responses API 요청 스키마 문제로 4xx가 나는 경우는 재시도하면 더 악화됩니다. 422는 별도로 분리해서 다루는 게 좋습니다: OpenAI Responses API 422 스키마 검증 에러 해결 가이드
503이 “멈춤”으로 보이는 전형적인 시나리오
1) 스트리밍 연결은 열렸는데 토큰이 안 옴
- TCP/HTTP 연결은 성공했지만, 첫 토큰이 늦어져 프론트는 로딩만 계속
- 서버는 스트리밍을 기다리느라 워커(스레드/이벤트루프)를 오래 점유
2) 무제한 재시도로 인한 자기 증폭
- 503이 나자마자 즉시 재시도 → 동시 요청 수 증가 → 더 많은 503
- “짧은 지연 + 동시 폭주” 패턴이 가장 위험
3) 서버 타임아웃/리버스 프록시 타임아웃이 먼저 터짐
- API 호출은 계속 진행 중인데, 앞단(ALB/Nginx/Cloudflare)이 먼저 끊음
- 그 뒤 백엔드에서 실패 처리가 중첩되어 에러율 급증
(리버스 프록시 타임아웃/502·504 연쇄는 ALB 기준으로도 자주 보입니다: AWS ALB 502·504 난사 - 원인별 해결 체크리스트)
재시도는 “언제/몇 번/어떤 에러에”만 해야 한다
재시도의 1원칙은 재시도해도 성공 확률이 올라가는 경우에만 한다는 것입니다.
재시도 대상(권장)
- 503, 502, 504
- 네트워크 단절(ECONNRESET, ETIMEDOUT)
- 429(레이트리밋): 단,
Retry-After를 존중하거나 백오프를 더 크게
재시도 금지(원칙)
- 400/401/403/404
- 422(스키마/유효성 문제): 코드/입력 문제라 재시도해도 동일 실패
재시도 횟수는 “고정”이 아니라 “예산”으로
- 예: 사용자 요청 1건당 최대 2회 재시도
- 또는 1분 동안 재시도 총량 제한(서비스 전체 retry budget)
타임아웃을 먼저 설계하라: overall deadline
503에서 “멈춤”을 막는 가장 빠른 방법은 전체 데드라인(예: 8초) 을 강제하는 것입니다.
- 연결(connect) 타임아웃: 1~2초
- 응답(read) 타임아웃: 스트리밍이면 “첫 토큰” 기준 별도 타임아웃 권장
- 전체(overall) 타임아웃: 사용자 UX 기준(예: 8~15초)
전체 데드라인이 없으면, 일부 요청이 길게 늘어지며 워커를 점유하고 결국 다른 정상 요청까지 같이 죽습니다. (Gunicorn/Uvicorn 환경이라면 워커 타임아웃도 같이 봐야 합니다: Gunicorn Uvicorn Worker timeout 재현과 해결)
실전 재시도 정책: exponential backoff + full jitter
권장 패턴:
- base delay: 200~500ms
- backoff: 2x
- cap: 3~5초
- jitter: full jitter(0 ~ backoff_delay)
이렇게 해야 동시에 실패한 요청들이 같은 타이밍에 다시 몰려 재폭주하는 것을 막습니다.
Node.js(서버) 예제: Responses API + 재시도 + 데드라인 + 폴백
아래 예시는 다음을 포함합니다.
- 전체 데드라인(
deadlineMs) - 503/429/네트워크 오류만 재시도
- backoff + jitter
- 최종 실패 시 폴백(모델 다운그레이드)
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function isRetryable(err) {
const status = err?.status;
// openai SDK/Fetch 계열에서 status가 없고 cause.code만 있는 경우도 있음
const code = err?.cause?.code;
if (status === 503 || status === 502 || status === 504) return true;
if (status === 429) return true;
if (code === "ETIMEDOUT" || code === "ECONNRESET" || code === "EAI_AGAIN") return true;
return false;
}
function computeDelay(attempt, baseMs = 250, capMs = 4000) {
const exp = Math.min(capMs, baseMs * (2 ** attempt));
// full jitter
return Math.floor(Math.random() * exp);
}
async function createResponseWithRetry({ input, modelPrimary, modelFallback, deadlineMs = 10000 }) {
const started = Date.now();
let attempt = 0;
let lastErr;
// 최대 2회 재시도(=총 3번 시도)
const maxAttempts = 3;
while (attempt < maxAttempts) {
const elapsed = Date.now() - started;
const remaining = deadlineMs - elapsed;
if (remaining <= 0) break;
try {
// SDK 자체 timeout 옵션이 환경에 따라 다를 수 있어, 여기서는 AbortController로 강제
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), remaining);
const res = await client.responses.create({
model: modelPrimary,
input,
// 필요 시: temperature, max_output_tokens 등
}, { signal: ac.signal });
clearTimeout(t);
return { ok: true, model: modelPrimary, output_text: res.output_text };
} catch (err) {
lastErr = err;
// 422 등은 즉시 중단(재시도 금지)
if (!isRetryable(err)) break;
attempt += 1;
if (attempt >= maxAttempts) break;
const delay = computeDelay(attempt);
await sleep(delay);
}
}
// 폴백: 더 저렴/가벼운 모델로 1회만 시도(또는 캐시/규칙응답)
try {
const res2 = await client.responses.create({
model: modelFallback,
input: "(간단히 요약해서) " + input,
});
return { ok: true, model: modelFallback, output_text: res2.output_text, degraded: true };
} catch (err2) {
return {
ok: false,
error: {
message: "Responses API failed after retry + fallback",
primary: String(lastErr?.message ?? lastErr),
fallback: String(err2?.message ?? err2),
},
};
}
}
// 사용 예
const result = await createResponseWithRetry({
input: "장바구니 쿠폰 적용 로직을 설명해줘",
modelPrimary: "gpt-4.1",
modelFallback: "gpt-4.1-mini",
deadlineMs: 12000,
});
console.log(result);
포인트
- 전체 데드라인을 먼저 소진하고, 그 안에서만 재시도합니다.
- 재시도 후에도 실패하면 폴백은 1회 정도로 제한합니다(폴백까지 재시도하면 오히려 지연만 증가).
- 폴백 입력을 “요약/간단히”로 바꿔 토큰/시간을 줄이는 것도 실전에서 효과가 큽니다.
폴백 전략 5가지: “모델 폴백”만이 답이 아니다
503에서 멈출 때 폴백은 크게 다섯 갈래로 나뉩니다.
1) 모델 다운그레이드
gpt-4.1→gpt-4.1-mini같은 경량 모델- 장점: 구현 간단, 성공 확률↑, 비용↓
- 단점: 품질 저하 가능
2) 캐시 폴백(가장 강력)
- 동일/유사 질문에 대해 최근 N분 결과 캐시
- “정확히 동일”뿐 아니라 임베딩 유사도 캐시도 고려
- 503 순간에 캐시 히트율이 높으면 체감 장애가 거의 사라집니다.
3) 규칙 기반/템플릿 응답
- 고객센터/FAQ/정책 안내는 LLM 없이도 처리 가능
- 예: “현재 응답 지연이 발생… 잠시 후 다시 시도” 같은 안내를 제품 톤에 맞게
4) 비동기 처리로 전환
- 실시간 응답 대신 “요청 접수 → 완료 시 알림/웹훅”
- 내부적으로 큐(SQS/RabbitMQ/Kafka)로 흘려보내면 프론트는 즉시 반환 가능
5) 기능 축소(Graceful degradation)
- RAG 검색/툴 호출/장문 생성 중 일부를 끄고 짧은 답만 반환
- 특히 툴 체인이 길수록 503/타임아웃에 취약합니다.
서킷 브레이커와 벌크헤드: 503을 ‘격리’하는 장치
재시도만 넣으면, 트래픽이 많을 때는 여전히 위험합니다. 장애 구간에서 중요한 건 “성공하는 트래픽”을 보호하는 것입니다.
서킷 브레이커(권장 동작)
- 최근 30초 에러율이 임계치(예: 50%)를 넘으면 Open
- Open 상태에서는 OpenAI 호출을 즉시 스킵하고 폴백으로
- 10초 후 Half-Open으로 소량만 탐색 호출
벌크헤드(동시성 제한)
- OpenAI 호출 전용 세마포어로 동시 호출 수 제한(예: 20)
- 제한을 넘으면 큐잉하거나 즉시 폴백
Node.js에서 간단한 세마포어는 p-limit 같은 라이브러리로도 구현할 수 있고, 인프라 레벨에서는 워커/스레드/커넥션 풀을 분리해 “LLM 때문에 전체 API가 같이 죽는” 상황을 막습니다.
관측(Observability): 503을 ‘원인’이 아니라 ‘패턴’으로 본다
503은 외부 요인(일시적 과부하, 네트워크, 라우팅)일 때가 많아, “원인 규명”보다 “패턴 관측”이 더 중요합니다.
필수로 남길 지표/로그:
request_id(가능하면 OpenAI 응답/헤더 기반)와 내부 trace id- 시도 횟수(attempt), backoff delay, 최종 모델(primary/fallback)
- TTFT(Time To First Token): 스트리밍일수록 핵심
- 에러 코드별 비율(503/429/timeout)
- 사용자 영향 지표: p95/p99 latency, degrade 비율
이 데이터를 기반으로 “재시도 2회가 최적인가?”, “폴백 모델 품질이 허용 가능한가?”를 튜닝합니다.
체크리스트: 503에서 멈출 때 가장 먼저 바꿀 10가지
- 전체 데드라인(예: 10~12초) 설정
- 503/429/네트워크 오류만 재시도, 422는 즉시 실패
- exponential backoff + full jitter 적용
- 재시도 횟수 고정이 아니라 retry budget 도입
- 스트리밍이면 TTFT 타임아웃 별도 설정
- OpenAI 호출 동시성 제한(벌크헤드)
- 서킷 브레이커로 장애 구간에서 즉시 폴백
- 폴백은 1회만(모델 다운그레이드/캐시/규칙응답)
- “긴 작업”은 비동기 큐로 전환할 옵션 마련
- attempt/latency/TTFT/degrade 비율을 대시보드화
마무리: 재시도는 ‘회복력’이고 폴백은 ‘제품 설계’다
OpenAI Responses API의 503은 완전히 피하기 어렵습니다. 대신 멈추지 않는 시스템을 만드는 게 현실적인 목표입니다. 재시도는 회복력을 주지만, 무분별하면 폭주를 키웁니다. 그래서 데드라인·지터·서킷 브레이커·벌크헤드로 “재시도 가능한 범위”를 먼저 제한하고, 그 다음에 모델/캐시/규칙 기반 폴백으로 사용자 경험을 지키는 것이 정석입니다.
만약 503이 아니라 입력 스키마 문제(422)로 막히는 상황이라면, 재시도보다 먼저 요청 페이로드를 교정해야 합니다: OpenAI Responses API 422 스키마 검증 에러 해결 가이드