Published on

OpenAI Responses API 503 멈춤 - 재시도·폴백 설계

Authors

서버에서 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.1gpt-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가지

  1. 전체 데드라인(예: 10~12초) 설정
  2. 503/429/네트워크 오류만 재시도, 422는 즉시 실패
  3. exponential backoff + full jitter 적용
  4. 재시도 횟수 고정이 아니라 retry budget 도입
  5. 스트리밍이면 TTFT 타임아웃 별도 설정
  6. OpenAI 호출 동시성 제한(벌크헤드)
  7. 서킷 브레이커로 장애 구간에서 즉시 폴백
  8. 폴백은 1회만(모델 다운그레이드/캐시/규칙응답)
  9. “긴 작업”은 비동기 큐로 전환할 옵션 마련
  10. attempt/latency/TTFT/degrade 비율을 대시보드화

마무리: 재시도는 ‘회복력’이고 폴백은 ‘제품 설계’다

OpenAI Responses API의 503은 완전히 피하기 어렵습니다. 대신 멈추지 않는 시스템을 만드는 게 현실적인 목표입니다. 재시도는 회복력을 주지만, 무분별하면 폭주를 키웁니다. 그래서 데드라인·지터·서킷 브레이커·벌크헤드로 “재시도 가능한 범위”를 먼저 제한하고, 그 다음에 모델/캐시/규칙 기반 폴백으로 사용자 경험을 지키는 것이 정석입니다.

만약 503이 아니라 입력 스키마 문제(422)로 막히는 상황이라면, 재시도보다 먼저 요청 페이로드를 교정해야 합니다: OpenAI Responses API 422 스키마 검증 에러 해결 가이드