Published on

OpenAI Responses API 504 Timeout 재현·해결

Authors

서버에서 OpenAI Responses API를 호출하는데 간헐적으로 504 Gateway Timeout이 떨어지면, 대부분 “OpenAI가 504를 반환했다”기보다 당신의 경로 중간에 있는 게이트웨이(Cloudflare, NGINX, ALB, API Gateway, 사내 프록시, K8s Ingress 등)가 업스트림 응답을 제때 못 받아서 끊어버린 경우가 많습니다. 특히 LLM 응답은 (1) 생성 시간이 길어질 수 있고, (2) 스트리밍을 쓰면 연결이 오래 유지되며, (3) 네트워크 중간 장비의 idle timeout/버퍼링 정책에 민감합니다.

이 글에서는 504를 의도적으로 재현해 원인을 분리하고, Responses API 호출을 504에 강하게 만드는 해결책(타임아웃 설계, 스트리밍, 재시도/폴백, 프록시 튜닝, 요청 축소)을 단계별로 정리합니다.

504의 의미: “업스트림이 늦어서 게이트웨이가 끊음”

HTTP 504는 보통 다음 중 하나입니다.

  • 리버스 프록시/로드밸런서 타임아웃: NGINX proxy_read_timeout, ALB idle timeout, Cloudflare 100초 제한 등
  • 클라이언트 타임아웃을 504로 매핑: 일부 게이트웨이는 업스트림 연결 실패/타임아웃을 504로 응답
  • 스트리밍 연결이 중간에서 버퍼링/압축/idle로 끊김: SSE가 프록시에서 버퍼링되면 “데이터가 안 오는 연결”로 간주되어 타임아웃

중요한 포인트는, 504는 **OpenAI API의 애플리케이션 에러(400/401/422 등)**와 성격이 다르다는 점입니다. 400/422는 요청이 잘못되면 즉시 재현되지만, 504는 **환경(네트워크 경로/부하/응답시간)**에 의해 좌우됩니다.

재현 전략: “OpenAI 호출이 느려지면 어디서 끊기는가”를 확인

재현은 두 가지 축으로 합니다.

  1. 업스트림 응답 지연을 만들기: 큰 출력 토큰, 복잡한 추론, 도구 호출, RAG로 컨텍스트 과다 등
  2. 게이트웨이 제한을 낮추기: NGINX proxy_read_timeout을 5~10초로 낮춰 쉽게 504 유발

재현 1) NGINX로 강제 504 만들기 (로컬/스테이징)

아래처럼 NGINX 리버스 프록시를 두고, 업스트림(FastAPI)이 OpenAI Responses API를 호출하도록 구성합니다.

nginx.conf 예시(의도적으로 timeout을 짧게):

http {
  server {
    listen 8080;

    location / {
      proxy_pass http://127.0.0.1:8000;
      proxy_connect_timeout 2s;
      proxy_send_timeout 2s;
      proxy_read_timeout 5s;   # 일부러 짧게
      send_timeout 5s;

      # 스트리밍(SSE) 테스트 시 버퍼링 끄기
      proxy_buffering off;
      gzip off;
    }
  }
}

이 상태에서 FastAPI가 OpenAI 호출을 5초 이상 잡고 있으면, NGINX가 504를 반환합니다. 이 재현이 되면 “OpenAI가 504를 주는 게 아니라 프록시가 끊는다”는 것을 확정할 수 있습니다.

재현 2) Responses API를 일부러 느리게 만들기

  • max_output_tokens를 크게
  • 긴 프롬프트/컨텍스트를 넣어 처리 시간을 늘리기
  • 스트리밍을 끄고(=서버가 완성된 결과를 한 번에 받음) 기다리게 만들기

Python(서버)에서 Responses API를 호출하는 최소 예시는 다음과 같습니다.

import os
from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

def slow_call():
    # 일부러 출력 토큰을 크게 잡아 지연을 유발
    resp = client.responses.create(
        model="gpt-4.1-mini",
        input="아주 길고 자세한 기술 블로그 글을 6000자 이상 작성해줘."
              "섹션을 많이 나누고 코드도 포함해줘.",
        max_output_tokens=4000,
        # stream=False (기본)로 두면 업스트림 응답이 늦을수록 프록시가 끊기 쉬움
    )
    return resp.output_text

이 호출을 NGINX 뒤에서 실행하면, NGINX의 proxy_read_timeout이 짧을수록 504가 쉽게 발생합니다.

원인 분리 체크리스트: 504가 어디서 생성되었는지 찾기

504를 해결하려면 “누가 504를 만들었는지”를 먼저 확정해야 합니다.

1) 응답 헤더/바디로 프록시 식별

  • Cloudflare면 server: cloudflare 또는 CF 관련 헤더
  • NGINX면 server: nginx
  • AWS ALB면 server: awselb/2.0

클라이언트에서 curl -v로 확인합니다.

curl -v http://localhost:8080/your-endpoint

2) 서버 로그에서 OpenAI 호출이 끝났는지 확인

  • 서버 로그에 “OpenAI 응답 수신” 로그가 없다면: 업스트림 응답이 오기 전에 프록시가 끊었거나, 서버/클라이언트 타임아웃
  • 서버는 응답을 받았는데 클라이언트가 504라면: 서버→클라이언트 경로의 프록시/로드밸런서 문제

3) 스트리밍 여부 확인

  • 스트리밍을 쓰면 “조금이라도 데이터가 흘러가면” 중간 장비의 idle timeout을 피할 수 있습니다.
  • 하지만 프록시가 버퍼링하면 스트리밍이 무력화됩니다(클라이언트는 데이터가 안 오는 것으로 보임).

스트리밍 끊김/타임아웃 복구는 아래 글의 체크리스트가 직접적으로 도움이 됩니다.

해결 1) 서버/클라이언트 타임아웃을 “LLM 친화적으로” 재설계

가장 흔한 실패는 기본 타임아웃이 짧은 HTTP 클라이언트(예: 5~10초)로 LLM 호출을 하는 것입니다.

httpx 타임아웃/재시도 설계 (서버에서 직접 호출 시)

Responses API를 OpenAI SDK가 아니라 httpx로 감싸거나, 내부적으로 httpx 계열 타임아웃을 제어해야 하는 경우가 많습니다. 핵심은 다음입니다.

  • connect는 짧게(예: 5s)
  • read는 길게(예: 60~180s) 또는 스트리밍이면 더 길게
  • 재시도는 아이템포턴트한 요청(같은 입력에 같은 결과를 기대하거나, 중복 생성이 허용되는 경우)에만 적용

구체적인 설계 패턴은 아래 글을 참고하면 좋습니다.

해결 2) 스트리밍(SSE)로 “게이트웨이 idle timeout” 회피

504의 많은 케이스는 “응답이 늦다”가 아니라 “중간 장비가 일정 시간 동안 바이트가 흐르지 않는 연결을 끊는다”입니다. 스트리밍을 켜면 토큰이 생성되는 즉시 바이트가 흘러가므로, idle timeout에 강해집니다.

Responses API 스트리밍 예시 (Python)

import os
from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

def stream_call():
    events = client.responses.stream(
        model="gpt-4.1-mini",
        input="NGINX 뒤에서 SSE로 스트리밍하는 예제를 만들어줘.",
        max_output_tokens=1200,
    )

    text_parts = []
    with events as stream:
        for event in stream:
            # SDK 이벤트 타입은 버전에 따라 다를 수 있어, 텍스트 델타만 모으는 패턴을 사용
            if getattr(event, "type", "") in ("response.output_text.delta", "response.output_text"):
                delta = getattr(event, "delta", None) or getattr(event, "text", None)
                if delta:
                    text_parts.append(delta)
                    # 여기서 즉시 클라이언트로 flush하면 프록시 idle timeout을 피하기 좋음

    return "".join(text_parts)

서버가 이 스트림을 다시 브라우저로 중계하는 구조라면, 프록시 버퍼링 해제가 필수입니다(예: NGINX proxy_buffering off, gzip off). 프록시 뒤 스트리밍이 끊기는 문제는 아래 글이 인프라별로 잘 정리되어 있습니다.

해결 3) NGINX/Ingress/ALB 타임아웃을 “요청 경로 전체” 기준으로 올리기

LLM 호출 경로는 보통 다음 체인입니다.

브라우저/앱 → CDN/WAF → LB → Ingress/NGINX → App(Uvicorn/Gunicorn) → OpenAI

여기서 타임아웃은 가장 짧은 구간이 전체 상한이 됩니다. 예를 들어:

  • ALB idle timeout 60s
  • NGINX proxy_read_timeout 30s
  • Uvicorn keep-alive 5s

이면 30~60초 사이에서 끊길 수 있습니다.

NGINX에서 자주 만지는 값

  • proxy_read_timeout: 업스트림에서 바이트가 안 오면 끊는 시간
  • proxy_send_timeout: 업스트림으로 보낼 때 지연
  • send_timeout: 클라이언트로 보낼 때 지연
  • 스트리밍이면 proxy_buffering off, gzip off

Kubernetes Ingress/NGINX/Gunicorn/Uvicorn 조합에서 502/504를 줄이는 튜닝은 아래 글이 실전적입니다.

해결 4) “느린 요청” 자체를 줄이기: 토큰/컨텍스트/도구 호출 최적화

타임아웃을 올리는 건 필요하지만, 근본적으로는 응답 시간을 예측 가능하게 만드는 게 더 강력합니다.

  • 입력 컨텍스트 축소: 불필요한 로그/HTML/중복 문단 제거
  • RAG라면 청킹/리랭킹으로 top-k를 줄여 품질을 유지하면서 토큰 절감
  • max_output_tokens를 업무 요구에 맞게 상한 설정
  • 도구 호출이 있다면, 도구 자체의 타임아웃/재시도/캐시 적용

RAG에서 컨텍스트가 과해져 응답이 느려지고 불안정해지는 경우는 아래 체크리스트가 도움이 됩니다.

해결 5) 504를 “정상적인 장애”로 취급: 재시도·폴백·서킷브레이커

게이트웨이 504는 네트워크/부하/일시적 지연의 산물이라, 일정 비율로는 발생할 수 있습니다. 따라서 다음을 갖추면 체감 장애가 크게 줄어듭니다.

  • 지수 백오프 + 지터 재시도(단, 중복 생성 비용/부작용 고려)
  • 폴백 모델/짧은 응답 모드로 전환
  • 서킷브레이커로 연쇄 장애 방지
  • 요청 단위 idempotency 키(가능한 범위에서)로 중복 처리 완화

500/503 중심이긴 하지만, 운영 패턴(재시도/폴백/서킷브레이커)은 504에도 그대로 적용됩니다.

간단한 재시도 래퍼 예시 (504/timeout 계열)

아래는 “서버 내부에서 OpenAI 호출이 실패하면 제한적으로 재시도”하는 예시입니다. 핵심은 재시도 횟수 제한백오프입니다.

import time
import random
from openai import OpenAI

client = OpenAI()

RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}

def call_with_retry(create_fn, max_attempts=3, base_delay=0.5):
    last_exc = None
    for attempt in range(1, max_attempts + 1):
        try:
            return create_fn()
        except Exception as e:
            last_exc = e
            # SDK 예외 타입/필드가 환경마다 다를 수 있어, 상태코드 추출은 방어적으로
            status = getattr(e, "status_code", None) or getattr(getattr(e, "response", None), "status_code", None)
            if status not in RETRYABLE_STATUS or attempt == max_attempts:
                raise

            sleep = base_delay * (2 ** (attempt - 1))
            sleep = sleep * (0.5 + random.random())  # jitter
            time.sleep(sleep)

    raise last_exc


def create_response():
    return client.responses.create(
        model="gpt-4.1-mini",
        input="요약 보고서를 작성해줘.",
        max_output_tokens=800,
    )

resp = call_with_retry(create_response)
print(resp.output_text)

운영에서는 여기서 한 단계 더 나아가:

  • 재시도 시 출력 토큰 상한을 낮춘 폴백
  • 스트리밍으로 전환
  • “지금은 간단 요약만 제공” 같은 기능 플래그 기반 degrade

를 함께 적용하면 504 체감이 크게 줄어듭니다.

실전 결론: 504를 없애는 가장 현실적인 조합

504를 재현해보면, 대부분은 “OpenAI 호출이 느려짐” + “중간 장비 timeout이 짧음/스트리밍이 버퍼링됨”의 결합입니다. 다음 조합이 가장 현실적인 해결책입니다.

  1. 스트리밍(SSE) 기본 적용 + 프록시 버퍼링 해제
  2. 경로 상의 가장 짧은 timeout(Ingress/NGINX/ALB/API Gateway)을 LLM에 맞게 상향
  3. 입력/출력 토큰 예산을 관리해 응답 시간을 예측 가능하게
  4. 504/timeout을 전제로 재시도 + 폴백 + 서킷브레이커를 적용

이 네 가지를 적용하면, “가끔 504가 뜬다”가 아니라 “504가 떠도 사용자 경험이 깨지지 않는다” 수준까지 끌어올릴 수 있습니다.