- Published on
OpenAI Responses API 스트리밍 끊김 타임아웃 완전 복구 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 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% 복구의 핵심: 재시도 + 체크포인팅(클라이언트에 끊김을 숨긴다)
스트리밍은 특성상 “한 번 끊기면 끝”처럼 보이지만, 제품 관점에서는 끊김을 사용자가 몰라도 되게 만들 수 있습니다.
핵심은 두 가지입니다.
- 체크포인트: 지금까지 사용자에게 전달한 텍스트/토큰을 안전하게 저장
- 재시도: 끊기면 같은 입력으로 다시 요청하되, 이미 보낸 부분을 프롬프트에 포함해 “이어서” 생성하게 만들기
> 주의: 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 기본값
이 합작으로 발생합니다.
해결의 정답은 단일 설정이 아니라,
- 스트리밍 전용 httpx 설정으로 기본 끊김을 줄이고
- 재시도 + 체크포인팅 + 디듀프로 끊겨도 사용자에게는 “연속 응답”처럼 보이게 만드는 것입니다.
지금 운영 로그에서 ReadTimeout/RemoteProtocolError가 보인다면, 오늘 해야 할 액션은 하나입니다.
- 스트리밍 응답을 “복구 가능한 프로토콜”로 취급하고, 체크포인트를 넣어 재시도 경로를 제품 기능으로 승격하세요.