Published on

OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커

Authors

서버가 바쁘거나(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는 별도의 레이트리밋 전략이 필요

관련해서는 다음 글을 같이 보면 설계가 깔끔해집니다.

목표 아키텍처: 3단 방어선

실전에서 추천하는 우선순위는 아래입니다.

  1. 짧고 똑똑한 재시도: 지수 백오프 + 지터 + 상한 + 전체 타임버짓
  2. 폴백: (a) 더 저렴/가벼운 모델 (b) 캐시/요약본 (c) 규칙 기반 응답
  3. 서킷브레이커: 장애가 지속되면 일정 시간 호출 자체를 차단하여 폭주를 막음

핵심은 “성공률을 올리는 것”이 아니라, 장애 중에도 시스템 전체가 살아남도록 만드는 것입니다.

재시도 설계: 지수 백오프 + 지터 + 타임버짓

Best Practice 체크리스트

  • 최대 재시도 횟수만 두지 말고, 반드시 전체 타임버짓을 둔다 (예: 2.5초)
  • 백오프는 base * 2^n + 랜덤 지터
  • 503은 첫 재시도를 빠르게(예: 100200ms) 시작하되, 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 연동에서 자연스러운 운영 이벤트입니다.
  • 지수 백오프 + 지터 + 타임버짓으로 재시도를 통제하고,
  • 모델/캐시/규칙 기반 폴백으로 사용자를 지키며,
  • 서킷브레이커로 장애 전파를 차단해야 전체 시스템이 살아남습니다.

오늘 할 일은 간단합니다.

  1. 현재 코드에 total_budget이 있는지 확인하고,
  2. 500/503 재시도 정책을 분리한 뒤,
  3. 폴백 1단계(다운시프트 모델)와 서킷브레이커를 붙여보세요.

이 3가지만 해도 “가끔 터지는” 장애가 “서비스를 멈추는” 장애로 번지는 것을 대부분 막을 수 있습니다.