블로그로 돌아가기

문제 해결가이드

HTTP 200 이후 스트림 실패: Python에서 스트림 중간 오류 잡기

200 상태 이후 스트림이 깨질 수 있는 이유, event: error를 발견하는 방법, 답변을 중복하지 않고 부분 텍스트를 유지하는 Python 재시도.

작성자: CheapTokens Team 게시됨읽는 시간 7분

message_stop 대신 event: error와 overloaded_error로 끝나는 HTTP 200 OK 스트림
이 페이지의 내용
  1. 200이 실제로 보장하는 것
  2. Python에서 잡아내기
  3. 답변을 두 번 출력하지 않고 재시도하기
  4. 모든 조기 종료가 오류는 아닙니다
  5. 배포 전에

채팅 UI가 답변을 절반만 스트리밍하다 멈췄고, 로그에는 200 OK가 찍혀 있습니다. 이는 모순이 아닙니다: 200은 스트림이 시작됐다는 것만 알려줄 뿐, 끝났다는 것은 아닙니다. 나중에 무언가 깨지면 서버는 오류를 스트림 안에서 보낼 수밖에 없으므로, 상태 코드만 확인해서는 절대 볼 수 없습니다. 해결책은 스트림이 종료 신호로 끝날 때만 성공으로 간주하고, 그렇지 않으면 깨끗한 버퍼에서 재시도하는 것입니다.

이 페이지의 나머지 부분에서는 깨진 스트림이 어떻게 보이는지, 두 개의 Python 핸들러(Anthropic Messages와 OpenAI SDK), 그리고 답변의 시작 부분을 두 번 출력하지 않는 재시도를 보여줍니다. 두 핸들러 모두 CheapTokens를 API 엔드포인트로 사용합니다: 문서에 base URL이 있고, 샘플 모델은 DeepSeek-V4-Flash입니다(id deepseek-ai/DeepSeek-V4-Flash-0731, 380K 컨텍스트, 답변당 최대 32,768 출력 토큰, 시간대와 무관한 단일 가격 — 모델 페이지 참고).

200이 실제로 보장하는 것#

문 앞에서 택배를 수령 서명하는 기사를 생각해 보세요: 서명은 그가 도착했다는 것을 증명할 뿐, 택배가 온전하다는 것은 아닙니다. HTTP도 같은 방식입니다. 서버는 상태 줄과 헤더를 먼저 보내고, 그다음 본문이 조각조각 도착합니다. 모델 백엔드가 중간에 과부하되면 상태 줄은 이미 나가버린 뒤입니다.

Anthropic의 API 오류 페이지(2026년 11월 1일 확인)는 이를 직접 명시합니다: SSE로 스트리밍 응답을 받을 때 API가 200을 반환한 후에도 오류가 발생할 수 있으며, 그때는 표준 오류 처리가 적용되지 않습니다. 스트리밍 문서에는 해당 이벤트의 형태가 나와 있습니다:

text
event: error
data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}

overloaded_error는 일반적으로 비스트리밍 호출에서 HTTP 529에 해당합니다(높은 트래픽으로 인한 일시적 과부하). 같은 페이지에는 500 api_error(지수 백오프로 재시도)과 504 timeout_error(긴 요청에는 스트리밍 사용)이 나열되어 있습니다. 실제 전송 중에 깨진 스트림은 다음과 같습니다:

200 OK로 시작해 message_stop 대신 event: error로 끝나는 스트림
스트림 중간 오류의 구조: 콘텐츠 이벤트가 멈추고 message_stop이 있어야 할 자리에 event: error가 도착함 (Anthropic 스트리밍 문서의 형태)

문서는 또한 새로운 이벤트 유형이 추가될 수 있다고 경고하므로, 코드는 알 수 없는 이벤트를 우아하게 처리해야 합니다. 그래서 아래 핸들러들은 모든 오류 바이트를 인식하려 하지 않습니다. 대신 한 가지 질문만 합니다: 스트림이 종료 신호로 끝났는가?

  • Anthropic Messages: 마지막 이벤트는 message_stop이고, message_delta의 stop_reason이 왜 끝났는지 알려줍니다(end_turn, max_tokens, stop_sequence).
  • OpenAI Chat Completions: 마지막 청크에 비어 있지 않은 finish_reason가 있습니다(stop, length, …).

종료 신호가 없거나 도중에 예외가 발생하면 — 상태 코드가 무엇을 말했든 답변은 불완전합니다.

Python에서 잡아내기#

브라우저로 답변을 스트리밍하는 지원 봇을 예로 들어 보겠습니다. 루프는 텍스트를 버퍼에 모으고 세 가지를 보고합니다: 지금까지의 텍스트, 스트림이 끝났는지 여부, 그리고 오류가 있었다면 그 오류입니다. 사용하는 SDK에 맞는 탭을 선택하세요.

하나의 핸들러, 두 개의 SDK

Anthropic SDK

python
import os
import anthropic

client = anthropic.Anthropic(
    base_url='https://api.cheaptokens.dev',  # no /v1 for the Anthropic SDK
    api_key=os.environ['CHEAPTOKENS_API_KEY'],
)
MODEL = 'deepseek-ai/DeepSeek-V4-Flash-0731'

def ask_once(prompt, on_text=lambda t: None):
    parts, finished = [], False
    try:
        with client.messages.stream(
            model=MODEL,
            max_tokens=1024,
            messages=[{'role': 'user', 'content': prompt}],
        ) as stream:
            for event in stream:
                if event.type == 'content_block_delta' and event.delta.type == 'text_delta':
                    parts.append(event.delta.text)
                    on_text(event.delta.text)
                elif event.type == 'message_stop':
                    finished = True
    except anthropic.APIError as err:
        return ''.join(parts), False, err
    return ''.join(parts), finished, None

OpenAI SDK

python
import os
import openai

client = openai.OpenAI(
    base_url='https://api.cheaptokens.dev/v1',
    api_key=os.environ['CHEAPTOKENS_API_KEY'],
)
MODEL = 'deepseek-ai/DeepSeek-V4-Flash-0731'

def ask_once(prompt, on_text=lambda t: None):
    parts, finish = [], None
    try:
        stream = client.chat.completions.create(
            model=MODEL,
            max_tokens=1024,
            stream=True,
            messages=[{'role': 'user', 'content': prompt}],
        )
        for chunk in stream:
            if not chunk.choices:
                continue
            choice = chunk.choices[0]
            if choice.delta.content:
                parts.append(choice.delta.content)
                on_text(choice.delta.content)
            if choice.finish_reason:
                finish = choice.finish_reason
    except openai.APIError as err:
        return ''.join(parts), False, err
    return ''.join(parts), finish is not None, None

두 가지 세부 사항을 다시 살펴볼 만합니다. 첫째, 두 핸들러 모두 SDK의 기본 오류 클래스(anthropic.APIError, openai.APIError)를 잡으므로, 특정 실패에 대해 SDK가 어떤 서브클래스를 던지든 상관없습니다. 둘째, finished 플래그는 예외와 별개입니다: 스트림은 예외 없이 그냥 일찍 끝날 수도 있으며, 그 경우도 실패로 간주해야 합니다.

openai-python README는 OpenAI 측에서 예상할 수 있는 것을 추가로 설명합니다(2026년 11월 1일 확인): 스트림의 읽기 타임아웃은 APITimeoutError를 발생시키고, 다른 HTTPX 실패는 원래 예외를 __cause__에 담아 APIConnectionError를 발생시킵니다. 스트림이 실패하는 대신 멈춘다면 OpenAI Python SDK의 타임아웃과 재시도를 참고하세요.

답변을 두 번 출력하지 않고 재시도하기#

배포하기 쉬운 버그가 있습니다. 첫 시도가 "환불 정책은…"을 출력하다 죽고, 재시도가 처음부터 시작해 "환불 정책은…"을 다시 출력합니다 — 이제 사용자는 시작 부분을 두 번 보거나, 문장이 자기 재시작에 붙어 있는 것을 봅니다. 흔한 원인은 하나의 공유 버퍼입니다: answer += text가 시도 밖에 있어서 두 번째 시도가 첫 번째에 덧붙입니다.

해결책은 지루합니다. 한 시도는 하나의 버퍼를 소유하고, 실패한 시도의 텍스트는 버려지며(또는 로그용으로만 보관), 재시도는 처음부터 시작합니다:

python
import time

def ask_with_retry(prompt, attempts=3, on_text=lambda t: None, on_restart=lambda: None):
    for attempt in range(attempts):
        text, finished, err = ask_once(prompt, on_text)
        if finished:
            return text
        status = getattr(err, 'status_code', None)
        if status is not None and 400 <= status < 500 and status not in (408, 409, 429):
            raise err  # a 400/401/404 will not fix itself
        print(f'attempt {attempt + 1} failed after {len(text)} chars: {err!r}')
        if attempt == attempts - 1:
            break
        on_restart()  # clear the message box in your UI
        time.sleep(2 ** attempt)  # 1 s, 2 s
    raise RuntimeError('stream failed after all attempts')

on_restart가 중복을 막는 부분입니다. 웹 UI에서는 다음 시도가 스트리밍되기 전에 메시지 버블을 비웁니다. 일반 터미널에서는 출력을 되돌릴 수 없으므로, finished이 참이 된 후에만 출력하거나 눈에 보이는 --- retrying --- 줄을 출력하세요. 처음부터 재시도하면 전체 프롬프트를 다시 보내므로, 새 시도에 대해 입력이 다시 과금됩니다; 대부분의 채팅 답변에서는 깔끔한 답변을 위한 합당한 비용입니다.

순서대로 배열하기

재시도 단계를 코드가 실행해야 할 순서대로 배치하세요

  1. 깨진 스트림 읽기를 멈추고 부분 텍스트를 로그용으로 보관
  2. 오류 확인: 408, 409, 429가 아닌 4xx는 재시도가 아니라 중단을 의미
  3. 메시지 상자를 비우고 새 버퍼 시작
  4. 지수 백오프로 대기(1초, 그다음 2초)
  5. 전체 요청을 다시 보내고 새 답변만 표시

Anthropic의 SDK는 이미 일시적 실패 — 연결 오류, 속도 제한, 5xx — 를 지수 백오프로 재시도하며, 기본적으로 2회, retry-after을 준수하고, max_retries로 이를 변경합니다. 이는 요청을 커버합니다. 200 이후에 죽는 스트림은 다른 이야기입니다: 문서에 따르면 표준 오류 처리가 적용되지 않으므로, 위의 루프는 직접 유지해야 합니다. 429만 따로 다루려면 별도의 안내가 있습니다: 429 Too Many Requests: 백오프를 사용한 재시도.

모든 조기 종료가 오류는 아닙니다#

재시도하기 전에 답변이 왜 끝났는지 확인하세요. stop_reason: max_tokens(Anthropic) 또는 finish_reason: length(OpenAI)로 끝나는 스트림은 실제로 끝난 것입니다 — 설정한 한도에 도달했을 뿐입니다. 이를 재시도하면 같은 잘린 텍스트와 같은 청구서가 돌아옵니다. 대신 한도를 높이세요; 잘린 답변 가이드에서 자세히 다룹니다.

세 번째 그룹이 있습니다: 네트워크입니다. 유휴 연결이 도중에 끊길 수 있으며, 이는 연결 오류나 아무 이벤트 없이 멈추는 스트림으로 나타납니다. 스트림 중간 오류와 동일하게 처리하고, 계속 발생하면 일반적인 연결 오류를 참고하세요. 긴 생성의 경우 Anthropic은 거대한 비스트리밍 max_tokens보다 스트리밍을 권장하므로, 이미 올바른 쪽에 있습니다.

배포 전에#

실패한 모든 시도의 요청 id를 저장하세요. Anthropic 오류 본문에는 request_id 필드와 request-id 응답 헤더가 있고, Python SDK는 message._request_id을 노출합니다; 그 id가 있으면 실패는 일화가 아니라 추적 가능한 것이 됩니다.

체크리스트

스트리밍 오류 처리, 제대로 하기

핸들러가 실행되는 것을 보고 싶다면 자신의 키로 지정하세요: 문서에서 API 키 생성, CHEAPTOKENS_API_KEY 설정, 그리고 ask_with_retry('Explain SSE in two sentences') 호출. Python 스트리밍의 기본은 Python의 스트리밍 응답부터 시작하세요; Anthropic 측은 CheapTokens와 함께하는 Anthropic Python SDK에서 다룹니다.

재시도 흐름: 스트림 시작, 오류 도착, 부분 텍스트 버림, 요청 다시 전송
다섯 단계의 재시도 루프: 시작, 오류 이벤트, 부분 버퍼 버리기, 백오프, 다시 요청