- Published on
OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 바쁘거나(503), 내부 오류가 터지거나(500) — LLM을 호출하는 서비스에서 이 두 상태 코드는 생각보다 자주 마주칩니다. 더 문제는 사용자 요청이 몰리는 순간에 함께 발생한다는 점입니다. 단순히 retry=3 같은 설정만으로는 장애가 커지고, 잘못된 재시도는 오히려 API와 우리 시스템을 동시에 압박합니다.
이 글에서는 OpenAI Responses API를 기준으로 500/503을 **재시도(Backoff) + 폴백(Fallback) + 서킷브레이커(Circuit Breaker)**로 다층 방어하는 방법을, 현업에서 바로 붙일 수 있는 형태로 정리합니다. 스트리밍/타임아웃 이슈는 별도 글(OpenAI Responses API 스트리밍 끊김 타임아웃 완전 복구 가이드)를 참고하세요.
500/503의 본질: 재시도해도 되는 실패 vs 안 되는 실패
500 Internal Server Error
- 공급자 내부 오류/일시적 장애
- 대부분 재시도 가치가 있음
- 단, 동일 요청을 무한 재시도하면 중복 비용과 지연 폭발이 발생
503 Service Unavailable
- 과부하, 일시적 셧다운, 롤링 배포 등
Retry-After헤더가 올 수도 있음(항상 오지는 않음)- 짧은 재시도 + 빠른 폴백이 핵심
“재시도하면 안 되는 실패”도 같이 분리해야 한다
500/503만 다룬다고 해도, 실전에서는 400/401/403/429가 섞입니다.
- 400 계열은 요청 자체 문제가 많아 재시도해봤자 실패 반복
- 429는 별도의 레이트리밋 전략이 필요
관련해서는 다음 글을 같이 보면 설계가 깔끔해집니다.
- 429 대응: OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기
- 400 원인: OpenAI Responses API 400 invalid_request_error 원인과 해결
목표 아키텍처: 3단 방어선
실전에서 추천하는 우선순위는 아래입니다.
- 짧고 똑똑한 재시도: 지수 백오프 + 지터 + 상한 + 전체 타임버짓
- 폴백: (a) 더 저렴/가벼운 모델 (b) 캐시/요약본 (c) 규칙 기반 응답
- 서킷브레이커: 장애가 지속되면 일정 시간 호출 자체를 차단하여 폭주를 막음
핵심은 “성공률을 올리는 것”이 아니라, 장애 중에도 시스템 전체가 살아남도록 만드는 것입니다.
재시도 설계: 지수 백오프 + 지터 + 타임버짓
Best Practice 체크리스트
- 최대 재시도 횟수만 두지 말고, 반드시 전체 타임버짓을 둔다 (예: 2.5초)
- 백오프는
base * 2^n+ 랜덤 지터 - 503은 첫 재시도를 빠르게(예: 100
200ms) 시작하되, 23회 안에 결론 - 500은 503보다 약간 더 길게 가져가도 됨(단, 사용자 UX와 SLA 내에서)
- 스트리밍 요청은 “중간까지 받은 토큰”이 있어 재시도가 더 복잡하니, 스트리밍/비스트리밍을 분리 운영하는 게 안전
Python 예제: httpx 기반 재시도 래퍼
아래 코드는 Responses API 호출을 감싸는 재시도 유틸입니다. (공식 SDK를 쓰더라도, 네트워크/상태코드 기준으로 감싸는 계층은 유효합니다.)
import random
import time
from dataclasses import dataclass
import httpx
RETRYABLE_STATUS = {500, 502, 503, 504}
@dataclass
class RetryPolicy:
max_attempts: int = 4
base_delay: float = 0.15 # seconds
max_delay: float = 1.2 # seconds
total_budget: float = 2.5 # seconds
def backoff(self, attempt: int) -> float:
exp = self.base_delay * (2 ** (attempt - 1))
jitter = random.uniform(0, exp * 0.25)
return min(self.max_delay, exp + jitter)
def call_responses_with_retry(
api_key: str,
payload: dict,
policy: RetryPolicy = RetryPolicy(),
timeout: float = 10.0,
) -> dict:
url = "https://api.openai.com/v1/responses"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
start = time.monotonic()
last_exc = None
with httpx.Client(timeout=timeout) as client:
for attempt in range(1, policy.max_attempts + 1):
elapsed = time.monotonic() - start
if elapsed > policy.total_budget:
break
try:
r = client.post(url, headers=headers, json=payload)
# 성공
if 200 <= r.status_code < 300:
return r.json()
# 재시도 가능한 서버 오류
if r.status_code in RETRYABLE_STATUS:
retry_after = r.headers.get("retry-after")
if retry_after:
try:
delay = min(policy.max_delay, float(retry_after))
except ValueError:
delay = policy.backoff(attempt)
else:
delay = policy.backoff(attempt)
# 마지막 시도면 종료
if attempt == policy.max_attempts:
r.raise_for_status()
time.sleep(delay)
continue
# 그 외는 즉시 실패(400/401/403/429 등)
r.raise_for_status()
except (httpx.TimeoutException, httpx.NetworkError) as e:
last_exc = e
if attempt == policy.max_attempts:
raise
time.sleep(policy.backoff(attempt))
# 타임버짓 초과 또는 반복 실패
if last_exc:
raise last_exc
raise RuntimeError("Responses API retry budget exceeded")
포인트
- **
total_budget**이 없으면 장애 시 사용자 요청이 길게 늘어져 워커/스레드가 잠깁니다. Retry-After가 있으면 우선 존중하되, 상한을 둡니다.- 500/503만 재시도하고, 나머지는 빠르게 실패 처리하여 문제를 분리합니다.
폴백 전략: “품질 하락”이 아니라 “서비스 지속”이 목적
폴백은 단순히 모델을 바꾸는 게 아니라, 사용자 기대치를 관리하는 UX/제품 전략과 함께 가야 합니다.
폴백 1순위: 더 가벼운 모델로 다운시프트
- 예: 기본은 고성능 모델, 장애 시 더 저렴하고 빠른 모델로 전환
- 단, 프롬프트/출력 포맷이 깨지지 않도록 스키마/가드레일을 맞춰야 함
폴백 2순위: 캐시/최근 결과/요약본
- 동일 질문이 반복되는 도메인(FAQ, 사내 정책, 상품 문의)에서 효과적
- “실시간 생성”이 아니라 “최근 생성 결과”라도 사용자 만족도가 유지되는 경우가 많음
폴백 3순위: 규칙 기반 응답 + 티켓화
- “현재 답변 생성이 지연된다”는 사실을 숨기지 말고, 대체 경로(상담 연결/재시도 버튼/이메일 알림) 제공
Python 예제: 모델 폴백 + 캐시 폴백
import hashlib
import json
from typing import Optional
# 예시용 인메모리 캐시(실전은 Redis 권장)
CACHE = {}
def cache_key(payload: dict) -> str:
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def get_cached_response(key: str) -> Optional[dict]:
return CACHE.get(key)
def set_cached_response(key: str, value: dict) -> None:
CACHE[key] = value
def create_payload(model: str, user_text: str) -> dict:
return {
"model": model,
"input": user_text,
# 필요 시 response_format, tools 등 추가
}
def robust_generate(api_key: str, user_text: str) -> dict:
primary = create_payload("gpt-4.1", user_text)
fallback = create_payload("gpt-4.1-mini", user_text)
key = cache_key(primary)
# 1) 캐시 힌트(이전 성공 결과)
cached = get_cached_response(key)
if cached:
return {"source": "cache", "data": cached}
# 2) 1차 모델 시도
try:
data = call_responses_with_retry(api_key, primary)
set_cached_response(key, data)
return {"source": "primary", "data": data}
except Exception:
pass
# 3) 폴백 모델 시도(재시도 정책은 더 짧게)
try:
data = call_responses_with_retry(
api_key,
fallback,
policy=RetryPolicy(max_attempts=3, total_budget=1.5),
)
return {"source": "fallback_model", "data": data}
except Exception:
# 4) 최후 폴백
return {
"source": "degraded",
"data": {
"message": "현재 생성 요청이 많아 답변이 지연되고 있습니다. 잠시 후 다시 시도해 주세요.",
},
}
포인트
- 폴백 모델은 재시도 예산을 더 짧게 잡아야 “끝까지 붙잡고 있다가 실패”를 피합니다.
- 캐시는 “정답 캐시”가 아니라도 됩니다. 최근 10분 내 응답만으로도 장애 완충 효과가 큽니다.
서킷브레이커: 장애를 ‘격리’해서 전파를 막는다
재시도와 폴백만으로도 부족한 상황이 있습니다.
- 특정 리전/네트워크가 불안정
- 우리 서비스의 트래픽이 폭증해 동시 요청이 너무 많음
- 다운스트림이 계속 503을 뱉는데도 워커가 계속 호출 → 큐 적체 → 전체 장애
이때 서킷브레이커는 **“잠깐 호출을 멈추는 용기”**를 시스템에 부여합니다.
상태 모델
- CLOSED: 정상 호출
- OPEN: 일정 시간 호출 차단(즉시 폴백)
- HALF_OPEN: 제한적으로 몇 건만 시도해 회복 여부 판단
Python 예제: 간단 서킷브레이커(프로세스 단위)
멀티 인스턴스 환경이면 Redis 같은 공유 저장소로 상태를 공유하는 게 좋지만, 우선 개념과 구현을 잡는 데 충분한 예제입니다.
import time
from dataclasses import dataclass
@dataclass
class CircuitBreaker:
failure_threshold: int = 5
recovery_time: float = 15.0 # seconds
state: str = "CLOSED" # CLOSED, OPEN, HALF_OPEN
failures: int = 0
opened_at: float = 0.0
def allow(self) -> bool:
if self.state == "CLOSED":
return True
if self.state == "OPEN":
if time.monotonic() - self.opened_at >= self.recovery_time:
self.state = "HALF_OPEN"
return True
return False
# HALF_OPEN
return True
def on_success(self):
self.failures = 0
self.state = "CLOSED"
def on_failure(self):
self.failures += 1
if self.failures >= self.failure_threshold:
self.state = "OPEN"
self.opened_at = time.monotonic()
cb = CircuitBreaker()
def generate_with_cb(api_key: str, payload: dict) -> dict:
if not cb.allow():
return {"source": "circuit_open", "data": {"message": "일시적으로 요청이 많습니다. 잠시 후 재시도해 주세요."}}
try:
data = call_responses_with_retry(api_key, payload)
cb.on_success()
return {"source": "primary", "data": data}
except Exception:
cb.on_failure()
raise
운영 팁
- 서킷이 OPEN일 때는 “바로 폴백”으로 보내야 합니다. OPEN인데도 재시도하면 의미가 없습니다.
- HALF_OPEN에서는 샘플링(예: 10개 중 1개만 원 호출)을 넣으면 더 안정적입니다.
- 인스턴스가 여러 개면, 인스턴스마다 다르게 열려 효과가 반감됩니다. Redis/DB로 공유하거나, API Gateway 레벨에서 차단을 고려하세요.
트러블슈팅: 500/503이 ‘우리 문제’일 때가 더 많다
1) 타임아웃/프록시가 500으로 포장되는 경우
- ALB/Nginx/Cloudflare가 upstream timeout을 500/502/503처럼 보이게 만들 수 있습니다.
- 특히 스트리밍은 중간 버퍼링/압축 설정 때문에 끊기기 쉬움
- 스트리밍 장애는 원인 분리가 중요하므로 위에서 언급한 스트리밍 가이드를 먼저 점검하세요.
2) 동시성 폭주로 우리 서버가 먼저 죽는다
- “OpenAI가 503”인 줄 알았는데, 사실은 우리 API 서버가 커넥션/워커 고갈로 503을 뱉는 경우
- 증상: p95 지연이 먼저 튄 뒤 5xx가 증가, 워커 수/DB 커넥션이 바닥
3) 큐/백그라운드 작업에서 무한 재시도 루프
- 백그라운드 작업이 500/503을 만나면 영원히 재시도하며 큐가 썩습니다.
- Celery 같은 시스템은 acks/visibility 설정 충돌로 “유령 작업”이 생기기도 합니다.
이 경우는 아래 체크리스트가 바로 도움됩니다.
관측(Observability)과 운영 기준: 이게 없으면 튜닝이 불가능
꼭 남겨야 하는 로그/메트릭
status_code,error_type,attempt,backoff_delay,total_elapsed- 모델명, 입력 토큰/출력 토큰(가능하면)
- 폴백 발생률(%)과 폴백으로 인한 품질 이슈 리포트(예: CS 태그)
알림 기준 예시
- 5분 이동창에서 503 비율 > 2%: 경고
- 5분 이동창에서 5xx 비율 > 5%: 장애
- 서킷브레이커 OPEN 상태가 1분 이상 지속: 즉시 알림
사용자 경험 가이드
- “오류가 났습니다”보다 재시도 버튼/대체 답변을 제공
- 폴백이 발생했으면 결과에
source=fallback_model같은 태그를 남겨 A/B로 품질을 추적
결론: 500/503은 피하는 게 아니라 ‘흡수’하는 것
- 500/503은 LLM 연동에서 자연스러운 운영 이벤트입니다.
- 지수 백오프 + 지터 + 타임버짓으로 재시도를 통제하고,
- 모델/캐시/규칙 기반 폴백으로 사용자를 지키며,
- 서킷브레이커로 장애 전파를 차단해야 전체 시스템이 살아남습니다.
오늘 할 일은 간단합니다.
- 현재 코드에
total_budget이 있는지 확인하고, - 500/503 재시도 정책을 분리한 뒤,
- 폴백 1단계(다운시프트 모델)와 서킷브레이커를 붙여보세요.
이 3가지만 해도 “가끔 터지는” 장애가 “서비스를 멈추는” 장애로 번지는 것을 대부분 막을 수 있습니다.