Published on

OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드

Authors

서버에서 OpenAI Responses API를 호출하다가 갑자기 408 Request Timeout을 만나면, 대부분의 팀은 “OpenAI가 느린가?” 정도로만 결론 내리고 재시도 횟수만 늘립니다. 하지만 408은 어떤 계층에서 타임아웃이 발생했는지(클라이언트, 프록시, 앱 서버, 업스트림) 구분하지 않으면 재현도 어렵고, 재시도는 오히려 장애를 증폭시킵니다.

이 글에서는 408을 의도적으로 재현하고, 원인별 체크리스트와 해결책(특히 스트리밍, 리버스 프록시, 서버 워커 타임아웃, 클라이언트 read timeout)을 현업 관점에서 정리합니다.


408이 의미하는 것부터 정확히 잡기

HTTP 408은 “서버가 요청을 기다리다 타임아웃이 났다”는 의미로 알려져 있지만, 실무에서는 다음 두 케이스가 섞여 나타납니다.

  1. 당신의 앱/프록시가 408을 반환
    • Nginx/ALB/Cloudflare, 혹은 FastAPI/Gunicorn이 특정 시간 동안 업스트림 응답을 못 받아 타임아웃 처리
  2. OpenAI 업스트림이 408을 반환
    • 드물지만, 네트워크 단절/중간 프록시/스트리밍 중단 등으로 “요청 유지”가 깨졌을 때 발생

따라서 첫 단계는 “408이 어디서 생성됐는지”를 로그로 증명하는 것입니다.

1) 408의 발신자 확인 포인트

  • 응답 헤더에 server, via, cf-ray(Cloudflare), x-request-id(프록시/게이트웨이) 등을 확인
  • Nginx라면 access log에 upstream_status, upstream_response_time를 반드시 남기기
  • OpenAI SDK 사용 시에는 예외 객체에 포함된 status_code, response.headers를 덤프

408 타임아웃 “재현”부터 해야 해결이 빨라진다

현업에서 408은 재현이 안 되면 끝까지 미궁입니다. 아래 3가지 재현 패턴을 만들어두면, 원인 분리가 급격히 쉬워집니다.

재현 A: 클라이언트 read timeout으로 “가짜 408” 만들기

가장 흔한 실수는 클라이언트의 read timeout이 너무 짧은데 서버/프록시가 이를 408/504로 바꿔 반환하는 경우입니다.

Python(httpx)로 재현

import httpx

url = "https://api.openai.com/v1/responses"
headers = {
    "Authorization": f"Bearer {"YOUR_KEY"}",
    "Content-Type": "application/json",
}

payload = {
    "model": "gpt-4.1-mini",
    "input": "아주 길고 복잡한 분석을 2분 이상 걸리게 작성해줘."
}

# read를 매우 짧게 줘서 의도적으로 타임아웃
timeout = httpx.Timeout(connect=10.0, read=2.0, write=10.0, pool=10.0)

try:
    with httpx.Client(timeout=timeout) as client:
        r = client.post(url, headers=headers, json=payload)
        print(r.status_code, r.text)
except httpx.ReadTimeout as e:
    print("ReadTimeout 재현:", e)

이 경우 실제로는 408이 아니라 ReadTimeout 예외가 나지만, 당신의 API 서버가 이를 잡아 408로 래핑해 반환하는 패턴이 흔합니다.

해결 포인트

  • 서버가 408로 바꾸지 말고, 최소한 내부적으로는 ReadTimeout/ConnectTimeout을 구분해 로깅
  • “응답이 길어질 수 있는 요청”은 스트리밍으로 전환하거나, 백그라운드 잡 + 폴링 구조로 변경

재현 B: 스트리밍 중간에 프록시 버퍼/idle timeout으로 끊기

Responses API 스트리밍(SSE)은 “연결을 오래 유지”합니다. 이때 프록시(Cloudflare/Nginx/ALB)가

  • 버퍼링을 켜두었거나
  • 일정 시간 데이터가 안 오면 idle timeout
  • gzip/압축으로 flush가 지연 되면 중간에서 끊기고, 상위 계층에서 408/499/504 등으로 보일 수 있습니다.

이 케이스는 아래 글의 체크리스트가 그대로 적용됩니다.

재현 C: 앱 서버 워커 타임아웃으로 408/504 유발

FastAPI를 Gunicorn(UvicornWorker)로 띄운 경우, 워커 타임아웃이 짧으면 요청 처리 중 워커가 죽고 클라이언트는 408/502/504로 관측합니다.


원인별 해결 전략 1: “타임아웃 예산”을 계층별로 재설계

408을 줄이는 핵심은 타임아웃을 무작정 늘리는 게 아니라, 계층별로 “예산”을 맞추는 것입니다.

권장 예산(예시)

  • 클라이언트(당신의 백엔드 → OpenAI)
    • connect: 5~10s
    • read: 스트리밍이면 길게(예: 60300s), 비스트리밍이면 모델/프롬프트에 따라 30120s
  • 앱 서버(외부 사용자 → 당신의 백엔드)
    • 사용자 요청 타임아웃은 짧게 유지(예: 30s)
    • 대신 스트리밍/SSE 또는 비동기 작업 큐로 UX를 유지
  • 프록시(Nginx/ALB/Cloudflare)
    • proxy_read_timeout/idle timeout을 스트리밍 요구사항에 맞춰 상향
    • 버퍼링/압축으로 flush가 지연되지 않게 조정

중요: “사용자 요청 타임아웃”을 5분으로 늘리면, 장애 시 동시 연결이 쌓여 서버가 먼저 죽습니다.


원인별 해결 전략 2: 스트리밍(SSE)로 408을 구조적으로 회피

긴 응답은 비스트리밍으로 받으면 read timeout과 프록시 idle timeout의 표적이 됩니다. Responses API는 스트리밍을 지원하므로, 긴 작업은 스트리밍으로 전환하는 것이 정공법입니다.

Python SDK 스트리밍 예시(개념 코드)

from openai import OpenAI

client = OpenAI()

stream = client.responses.stream(
    model="gpt-4.1-mini",
    input="긴 답변을 단계적으로 생성해줘."
)

for event in stream:
    # event.type에 따라 delta 텍스트를 이어붙이거나,
    # 특정 이벤트에서 flush하여 클라이언트로 전달
    if event.type == "response.output_text.delta":
        print(event.delta, end="", flush=True)

stream.close()

스트리밍에서 408이 계속 난다면 체크할 것

  • 프록시가 SSE를 버퍼링하고 있지 않은가?
  • Content-Type: text/event-stream이 끝까지 유지되는가?
  • 중간에 gzip으로 chunk flush가 지연되지 않는가?
  • 로드밸런서 idle timeout이 짧지 않은가?

프록시/버퍼/타임아웃을 한 번에 점검하려면 아래 글의 체크리스트가 빠릅니다.


원인별 해결 전략 3: 재시도는 “무조건”이 아니라 “조건부”로

408이 발생했을 때 무작정 재시도하면, 지연이 길어지는 시간대에 트래픽이 더 몰려 동시 요청 폭발이 납니다. 특히 스트리밍 요청을 그대로 재시도하면 비용도 2배가 됩니다.

조건부 재시도 규칙(권장)

  • connect timeout / DNS / TCP reset: 짧은 지수 백오프로 재시도 가치가 큼
  • read timeout(서버가 처리 중일 가능성): 동일 요청 재시도는 위험
    • 가능하면 idempotency key(지원 시) 또는 내부 작업 ID로 중복 실행 방지
    • 스트리밍이면 체크포인팅(부분 결과 저장) 후 이어받기 설계
  • 프록시 408/504: 업스트림이 느린지, 프록시 설정 문제인지 먼저 분리

500/503 중심이긴 하지만, 재시도/폴백/서킷브레이커 설계는 아래 글의 패턴을 408에도 그대로 확장할 수 있습니다.


트러블슈팅 체크리스트: “408 한 번”을 끝까지 쪼개기

아래는 장애 대응 중 실제로 효과가 큰 순서대로 정리한 체크리스트입니다.

1) 로그에 남겨야 하는 6가지(없으면 원인 규명 불가)

  • 요청 시작/종료 시각(밀리초)
  • OpenAI 호출의 connect/read/write 각각의 타임아웃 설정값
  • OpenAI 응답/예외의 원문(가능한 범위에서)
  • 프록시의 upstream status/response time
  • 스트리밍 여부 및 첫 토큰까지 걸린 시간(TTFT)
  • 재시도 횟수와 백오프 시간

2) “첫 토큰까지”가 길어졌는지 확인

  • TTFT가 길어지면 read timeout이 쉽게 터집니다.
  • 프롬프트가 길거나, 툴 호출이 많거나, 출력이 과도하면 TTFT가 늘어납니다.

즉시 가능한 완화책

  • 출력 길이 제한(요약/목차/단계적 생성)
  • 툴 호출 최소화
  • 스트리밍으로 전환

3) 프록시/로드밸런서 idle timeout 확인

  • ALB/NLB, Cloudflare, Nginx, API Gateway 모두 기본 idle timeout이 존재합니다.
  • 스트리밍에서 “몇 초 동안 이벤트가 안 오면” 끊기는지 측정해보면 범인이 빨리 드러납니다.

4) 서버 워커 타임아웃/동시성 확인

  • Gunicorn timeout이 짧거나
  • 워커 수가 부족해 큐잉이 길어지면 클라이언트는 408로 보게 됩니다.

5) 네트워크 레벨(사내 프록시, NAT, 방화벽)

  • 사내 egress 프록시가 긴 연결을 끊는 경우가 있습니다.
  • 특정 리전에서만 발생하면 네트워크 경로 문제일 확률이 큽니다.

Best Practice: 408을 “없애는” 아키텍처 패턴 3가지

1) 사용자 요청은 짧게, 모델 작업은 비동기로

  • 사용자 요청(HTTP)은 10~30초 내에 응답
  • 모델 작업은 큐(예: Celery/RQ/SQS)로 넘기고
  • 결과는 폴링/웹훅/스트리밍으로 전달

2) 스트리밍 + 주기적 flush로 프록시 idle timeout 회피

  • SSE는 “계속 데이터가 흐르는 것”이 중요합니다.
  • 앱 레벨에서 일정 주기 keep-alive 이벤트를 보내 idle timeout을 피하는 방식도 고려(프록시 정책에 따라 다름)

3) 재시도는 서킷브레이커와 함께

  • 408이 급증하는 순간은 대개 “느려진 상태”입니다.
  • 이때 재시도는 눈덩이처럼 커지므로, 일정 임계치 이상이면
    • 큐잉
    • 더 작은 모델로 폴백
    • 응답 품질을 낮춘 빠른 모드 같은 완화가 필요합니다.

결론: 408은 “느림”이 아니라 “경계면 실패”다

OpenAI Responses API의 408은 단순히 모델이 느려서가 아니라, 클라이언트 타임아웃/프록시 idle timeout/서버 워커 타임아웃/스트리밍 버퍼링 같은 “경계면”에서 터지는 경우가 훨씬 많습니다.

오늘 바로 할 일은 3가지입니다.

  1. 408의 발신 계층을 로그로 특정(헤더 + 프록시 upstream 로그)
  2. 타임아웃 예산을 계층별로 재설계(사용자 요청은 짧게, 모델 작업은 스트리밍/비동기)
  3. 스트리밍 환경이라면 프록시 버퍼/idle timeout을 체크리스트로 점검

재현 코드를 만들어두면, 다음 408은 장애가 아니라 “설정 미스”로 끝낼 수 있습니다.