Published on

OpenAI Responses API 스트리밍 끊김 타임아웃 완전 복구 가이드

Authors

서버에서 OpenAI Responses API를 스트리밍으로 붙여두면, 데모에서는 잘 되는데 운영에서만 간헐적으로 응답이 ‘툭’ 끊기는 순간이 옵니다. 로그에는 httpx.ReadTimeout, httpx.RemoteProtocolError: Server disconnected 같은 메시지가 남고, 사용자는 답변이 중간에서 멈춘 채 새로고침을 누릅니다.

이 문제의 핵심은 모델이 멈춘 게 아니라, 네트워크 경로(프록시/로드밸런서/HTTP 스택)가 스트리밍 연결을 “정상 종료 없이” 끊는 경우가 많다는 점입니다. 그래서 해결도 “타임아웃 늘리기” 같은 단일 설정이 아니라,

  • 원인을 좁히는 관측(Observability)
  • HTTP/2·프록시·keep-alive 튜닝
  • 끊겨도 사용자에게는 끊기지 않은 것처럼 보이게 하는 재시도 + 체크포인팅(Checkpointing)

이 3가지를 함께 가져가야 합니다.

아래는 현업에서 바로 적용 가능한 형태로 정리한 실전 가이드입니다.


장애 증상 패턴: ReadTimeout vs RemoteProtocolError

1) httpx.ReadTimeout

  • 의미: 읽을 바이트가 일정 시간 동안 도착하지 않음
  • 흔한 원인
    • 중간 프록시가 버퍼링하다가 flush를 늦게 함(SSE/Chunked에 치명적)
    • HTTP/2에서 윈도우/플로우 컨트롤 문제로 데이터가 밀림
    • 서버(또는 모델)가 아주 긴 “생각 구간”을 갖고 첫 토큰이 늦게 옴

2) httpx.RemoteProtocolError: Server disconnected / Server closed connection

  • 의미: 상대가 TCP 연결을 닫았거나, 중간에서 RST/FIN이 들어옴
  • 흔한 원인
    • 로드밸런서/프록시 idle timeout
    • keep-alive 재사용 커넥션이 중간에서 만료(half-open)
    • HTTP/2 연결 공유 중 특정 스트림이 리셋

둘 다 “OpenAI가 항상 문제”라기보다, 당신의 앱 ↔ 프록시 ↔ 인터넷 ↔ OpenAI 경로 어딘가에서 스트리밍에 부적합한 설정이 섞여 나타나는 경우가 많습니다.


원인 1: 프록시/로드밸런서가 스트리밍을 ‘스트리밍’으로 취급하지 않는다

운영에서만 끊기는 이유 1순위는 프록시입니다.

  • NGINX가 proxy_buffering on인 상태
  • Cloudflare 같은 CDN이 응답을 버퍼링/압축
  • ALB/Ingress의 idle timeout이 60초/120초로 짧음

이 경우 OpenAI에서 토큰을 잘 보내도, 중간에서 모아서 한 번에 보내거나(사용자는 “멈춤”으로 인지), idle로 판단해 연결을 끊습니다.

프록시 뒤 SSE/웹소켓 스트리밍이 끊기는 이슈는 아래 글의 체크리스트와 거의 동일한 원리로 재현/해결됩니다.

프록시가 있는지 확인하는 빠른 방법

  • 클라이언트(브라우저)에서가 아니라, 서버(백엔드)에서 OpenAI로 가는 경로에 프록시가 있는지 확인
    • egress proxy
    • 서비스 메시(Envoy)
    • 회사 네트워크 프록시

그리고 다음을 체크합니다.

  • 응답이 chunked로 바로 내려오는가?
  • 중간 hop에서 gzip/브로틀리 같은 압축이 켜져 버퍼링이 생기지 않는가?
  • idle timeout이 스트리밍 최대 길이를 커버하는가?

원인 2: HTTP/2 + 커넥션 재사용(keep-alive)이 만드는 “간헐 끊김”

httpx는 기본적으로 HTTP/2를 쓸 수 있고(설정에 따라), 커넥션 풀로 keep-alive 재사용을 합니다. 여기서 간헐적으로 아래가 터집니다.

  • 오래된 커넥션을 재사용했는데 중간 장비가 이미 세션을 만료 → 첫 read에서 RemoteProtocolError
  • HTTP/2 단일 커넥션에 여러 요청이 multiplexing → 특정 스트림만 RST(스트림 리셋)

실전 대응 전략

  • 스트리밍 요청만큼은 HTTP/2를 끄거나, 커넥션 재사용을 보수적으로
  • keep-alive 만료/idle을 짧게 잡아 “죽은 커넥션 재사용”을 줄이기

아래 코드에서 “스트리밍 전용 클라이언트”를 별도로 두는 게 효과적입니다.


httpx 스트리밍 안정화 기본 설정(권장 템플릿)

아래는 Responses API 스트리밍을 안정적으로 읽기 위한 httpx 설정의 출발점입니다.

  • read 타임아웃은 스트리밍 특성상 길게(혹은 None)
  • 커넥션 풀/keepalive를 보수적으로
  • HTTP/2는 환경에 따라 off(특히 프록시/Envoy/사내망 섞이면)
import httpx

TIMEOUT = httpx.Timeout(
    connect=10.0,
    read=None,      # 스트리밍은 read timeout을 짧게 두면 끊김으로 오인
    write=30.0,
    pool=30.0,
)

LIMITS = httpx.Limits(
    max_connections=50,
    max_keepalive_connections=10,
    keepalive_expiry=30.0,  # half-open 재사용 감소
)

client = httpx.Client(
    timeout=TIMEOUT,
    limits=LIMITS,
    http2=False,  # 환경에 따라 True/False A/B 테스트 권장
    headers={
        "Authorization": f"Bearer {YOUR_OPENAI_API_KEY}",
        "Content-Type": "application/json",
    },
)

read=None이 불안하다면?

  • “무한 대기”가 싫다면 read=300.0처럼 길게 두되,
  • 대신 애플리케이션 레벨에서 하트비트/체크포인트로 사용자 경험을 보장하세요(아래 섹션).

100% 복구의 핵심: 재시도 + 체크포인팅(클라이언트에 끊김을 숨긴다)

스트리밍은 특성상 “한 번 끊기면 끝”처럼 보이지만, 제품 관점에서는 끊김을 사용자가 몰라도 되게 만들 수 있습니다.

핵심은 두 가지입니다.

  1. 체크포인트: 지금까지 사용자에게 전달한 텍스트/토큰을 안전하게 저장
  2. 재시도: 끊기면 같은 입력으로 다시 요청하되, 이미 보낸 부분을 프롬프트에 포함해 “이어서” 생성하게 만들기

> 주의: Responses API는 “같은 request를 재전송하면 같은 지점부터 정확히 이어서”를 보장하는 프로토콜은 아닙니다. 따라서 실전에서는 “이어서 생성”을 프롬프트 제약으로 유도하고, 중복을 제거하는 디듀프 로직을 함께 둡니다.

체크포인트에 저장할 것(최소)

  • conversation_id(당신의 시스템 내부 대화 키)
  • messages(혹은 input) 원본
  • streamed_text_so_far (이미 사용자에게 보낸 누적 텍스트)
  • last_flush_at (마지막으로 토큰을 받은 시각)
  • attempt (재시도 횟수)

재시도 시 프롬프트 패턴

  • “아래는 이미 사용자에게 전달한 답변의 앞부분이다. 그 다음 문장부터 중복 없이 이어서 출력하라.”
  • 모델이 중복을 내면, 서버에서 가장 긴 공통 접두/접미를 찾아 잘라내고 이어붙임

파이썬 예제: 스트리밍 끊김 자동 복구(디듀프 포함)

아래 예시는 개념을 보여주는 형태입니다.

  • 스트리밍을 읽다가 예외가 나면 재시도
  • 이미 보낸 텍스트를 prefix로 두고 “이어서” 생성
  • 중복 텍스트를 제거하여 사용자에게는 하나의 연속 스트림처럼 전달
import time
import httpx

OPENAI_URL = "https://api.openai.com/v1/responses"


def longest_overlap_suffix_prefix(a: str, b: str, max_len: int = 4000) -> int:
    """a의 suffix와 b의 prefix가 겹치는 최대 길이"""
    a = a[-max_len:]
    for k in range(min(len(a), len(b)), 0, -1):
        if a[-k:] == b[:k]:
            return k
    return 0


def stream_with_recovery(
    client: httpx.Client,
    model: str,
    user_input: str,
    max_attempts: int = 5,
):
    streamed = ""

    for attempt in range(1, max_attempts + 1):
        # 체크포인트 기반 “이어서 생성” 프롬프트
        if streamed:
            continuation_hint = (
                "\n\n"
                "[SYSTEM] 아래는 이미 사용자에게 전달한 답변의 앞부분이다. "
                "중복 없이 정확히 다음 내용부터 이어서 작성하라. "
                "이미 나온 문장을 다시 출력하지 마라.\n"
                f"[ALREADY_SENT]\n{streamed}\n"
            )
        else:
            continuation_hint = ""

        payload = {
            "model": model,
            "input": [
                {
                    "role": "user",
                    "content": user_input + continuation_hint,
                }
            ],
            "stream": True,
        }

        try:
            with client.stream("POST", OPENAI_URL, json=payload) as r:
                r.raise_for_status()

                buffer = ""
                last_token_at = time.time()

                for line in r.iter_lines():
                    if not line:
                        continue

                    # (예시) SSE 스타일: "data: {...}" 파싱이 필요할 수 있음
                    # 여기서는 단순화를 위해 line이 곧 텍스트 조각이라고 가정
                    chunk = line

                    # 앱 레벨 idle 감지(프록시가 조용히 끊기기 전에)
                    now = time.time()
                    if now - last_token_at > 90:
                        raise httpx.ReadTimeout("application-level idle")
                    last_token_at = now

                    buffer += chunk

                    # flush 정책: 너무 자주 보내지 말고 일정 단위로
                    if len(buffer) >= 50:
                        # 중복 제거
                        overlap = longest_overlap_suffix_prefix(streamed, buffer)
                        to_send = buffer[overlap:]
                        if to_send:
                            yield to_send
                            streamed += to_send
                        buffer = ""

                # 남은 버퍼 flush
                if buffer:
                    overlap = longest_overlap_suffix_prefix(streamed, buffer)
                    to_send = buffer[overlap:]
                    if to_send:
                        yield to_send
                        streamed += to_send

            return  # 성공적으로 스트림 완료

        except (httpx.ReadTimeout, httpx.RemoteProtocolError, httpx.ConnectError) as e:
            if attempt == max_attempts:
                raise
            # 지수 백오프 + 지터(429 대응 패턴과 동일한 설계가 안정적)
            backoff = min(8.0, 0.5 * (2 ** (attempt - 1)))
            time.sleep(backoff)
            continue

이 방식은 “정확히 같은 토큰에서 재개”는 아니지만, 대부분의 고객이 원하는 건 중간에 멈추지 않고 끝까지 답변이 오는 것입니다. 체크포인트 + 디듀프를 넣으면 체감상 100%에 가깝게 복구됩니다.

재시도 설계는 429(레이트리밋) 대응과도 구조가 같아서, 아래 글의 백오프/큐잉 설계를 함께 적용하면 운영 안정성이 크게 올라갑니다.


트러블슈팅 체크리스트: 어디서 끊기는지 30분 안에 좁히기

1) 끊김이 “항상 같은 시간(예: 60초, 120초)”에 발생하는가?

  • 그렇다면 90%는 프록시/로드밸런서 idle timeout입니다.
  • OpenAI 호출 경로의 모든 hop에서 timeout을 확인하세요.

2) 개발 환경에선 OK, 쿠버네티스/인그레스 뒤에서만 끊기는가?

  • 인그레스(특히 NGINX) 버퍼링/타임아웃/keepalive 설정이 원인일 확률이 높습니다.
  • 스트리밍은 “서버 튜닝” 문제가 아니라 “경로 튜닝” 문제로 나타납니다.

쿠버네티스에서 502/504 및 스트리밍 끊김을 다룰 때는 아래 가이드의 튜닝 포인트(ingress timeout, buffering, upstream keepalive 등)가 그대로 적용됩니다.

3) RemoteProtocolError가 증가하고, 재시도하면 대부분 성공하는가?

  • keep-alive 재사용 문제(half-open) 가능성이 큽니다.
  • keepalive_expiry를 줄이고, 스트리밍 요청은 별도 클라이언트로 분리해 보세요.

4) “첫 토큰”이 늦어 ReadTimeout이 나는가?

  • read 타임아웃을 늘리거나 None으로 두고,
  • 앱 레벨 idle 감지를 두어 “정말 멈춘 경우”만 재시도하세요.

Best Practice: 운영에서 스트리밍을 ‘제품 수준’으로 만들기

1) 스트리밍을 관측 가능하게 만들어라

  • 요청당
    • 첫 토큰까지 시간(TTFT)
    • 토큰 간 최대 간격(max inter-token gap)
    • 끊김 발생 시각
    • 재시도 횟수
  • 이 4개만 있어도 원인이 프록시/모델/네트워크인지 빨리 갈립니다.

2) “사용자에게 전달한 것”을 단일 진실(SoT)로 저장

  • DB든 Redis든 상관없지만,
  • 클라이언트로 이미 flush된 텍스트를 기준으로 체크포인트를 쌓아야 합니다.
  • 그래야 재시도에서 중복 제거가 쉬워집니다.

3) 재시도는 무조건 멱등(idempotent)하게

  • 같은 요청이 중복 실행돼도
    • 최종 출력이 하나만 남고
    • 과금/로그/이벤트가 중복 집계되지 않게
  • conversation_id + turn_id 같은 키로 결과를 합치세요.

4) 백엔드→프론트 스트리밍도 별도의 장애 도메인이다

  • OpenAI→백엔드는 복구했는데, 백엔드→브라우저 SSE가 프록시에서 끊기면 사용자 경험은 동일하게 “멈춤”입니다.
  • 따라서 내부적으로는 2개의 스트림을 각각 튜닝해야 합니다.

결론: 타임아웃을 늘리는 게 아니라, 끊겨도 끝까지 가게 만들어라

OpenAI Responses API 스트리밍 끊김은 대개

  • 프록시/로드밸런서의 버퍼링·idle timeout
  • HTTP/2/keep-alive 재사용에서 오는 간헐 세션 종료
  • 스트리밍에 부적합한 httpx timeout 기본값

이 합작으로 발생합니다.

해결의 정답은 단일 설정이 아니라,

  1. 스트리밍 전용 httpx 설정으로 기본 끊김을 줄이고
  2. 재시도 + 체크포인팅 + 디듀프로 끊겨도 사용자에게는 “연속 응답”처럼 보이게 만드는 것입니다.

지금 운영 로그에서 ReadTimeout/RemoteProtocolError가 보인다면, 오늘 해야 할 액션은 하나입니다.

  • 스트리밍 응답을 “복구 가능한 프로토콜”로 취급하고, 체크포인트를 넣어 재시도 경로를 제품 기능으로 승격하세요.