상황
모델 제공자의 스트리밍 엔드포인트를 부르는 클라이언트가 있다. 정상일 때는 이벤트 스트림이 오고, 클라이언트는 이벤트를 읽어 답을 조립한다. 그런데 어떤 사용자는 답이 몇 글자 만에 끝나거나 아예 비어서 돌아온다고 보고한다. 로그에는 오류가 없고, 상태 코드는 200이다.
흔한 착각
2xx면 성공이고, 성공이면 본문은 스트림이라고 가정한다. 그래서 스트림 파서가 아무 이벤트도 찾지 못하면 "모델이 짧게 답했다"거나 "연결이 중간에 끊겼다"로 처리하고, 끊김으로 분류된 요청은 조용히 재시도된다. 진짜 원인은 어디에도 기록되지 않는다.
실제로 일어난 일
엔드포인트는 성공 코드와 함께 스트림이 아닌 본문, 즉 오류 설명이 담긴 일반 문서를 돌려주고 있었다. 이 불일치를 오류로 올리는 수정을 리뷰하는 동안 자동 리뷰어가 경계 사례를 하나씩 더 찾아냈다. 미디어 타입의 대소문자가 섞인 경우, 본문이 아예 없는 2xx, 본문은 없는데 헤더만 스트림이라고 적힌 2xx, 그리고 오류 메시지에 "HTTP 503" 같은 문구를 넣었더니 다른 계층이 그 문구를 상태 코드로 다시 파싱해 재시도 가능한 전송 오류로 바꿔 버린 경우까지. 수정 하나가 여러 번의 왕복을 거친 이유가 전부 이 경계들이었다.
무엇을 확인해야 하는가
스트림을 파싱하기 전에 두 가지를 먼저 본다. 본문이 실제로 있는가, 그리고 미디어 타입의 본체(파라미터를 뗀 부분)가 대소문자 무관하게 기대한 스트림 타입과 같은가. 둘 중 하나라도 아니면 그 응답은 성공이 아니다. 그리고 그 판단이 이후 계층을 지나면서 바뀌지 않는지, 재시도 분류기가 그 오류를 어떻게 읽는지도 따라가 본다.
고치는 방향
불일치는 전용 오류 타입이나 오류 코드로 올리고, 재시도 대상이 아닌 종단 오류로 분류한다. 진단을 위해 본문 앞부분을 읽되 크기에 상한을 두고, 읽은 뒤 스트림을 취소하며, 인증 토큰 같은 값은 지운다. 상태 코드는 구조화된 필드로 전달하고, 사람이 읽는 메시지 문구에는 다른 코드가 파싱할 만한 패턴을 넣지 않는다.
확인 방법
경계마다 테스트를 하나씩 둔다. 스트림이 아닌 본문의 2xx, 본문 없는 2xx, 헤더만 스트림인 빈 2xx, 대소문자가 섞인 미디어 타입, 상태 코드처럼 보이는 문구가 든 본문. 각 테스트가 수정 전 코드에서는 실패하고 수정 후에는 통과하는지 확인하고, 마지막으로 그 오류가 재시도되지 않고 한 번에 사용자에게 보이는지 본다.
The setup
A client calls a model provider's streaming endpoint. Normally an event stream comes back and the client assembles the answer from its events. Then users report answers that stop after a few characters or come back empty. The logs show no error, and the status code is 200.
The usual mistake
Assume that 2xx means success and success means the body is a stream. So when the stream parser finds no events, the client files it as "the model answered briefly" or "the connection dropped," and anything classified as a drop gets retried quietly. The real cause is never written down anywhere.
What actually happened
The endpoint was returning a success code with a body that was not a stream at all: a plain document describing an error. While a fix that raised this mismatch as an error was under review, an automated reviewer kept finding one more edge: a media type in mixed case; a 2xx with no body; a 2xx with no body whose header still claimed to be a stream; and an error message that included text like "HTTP 503," which another layer parsed back into a status code and turned into a retryable transport failure. Every extra round trip on that one fix came from those edges.
What to check
Before parsing a stream, check two things. Is there actually a body? Does the media type's essence, with parameters stripped and compared case-insensitively, equal the stream type you expect? If either answer is no, the response is not a success. Then follow that verdict through the later layers and see how the retry classifier reads the error, so nothing downstream quietly reverses it.
Which way to fix it
Raise the mismatch as a dedicated error type or error code and classify it as terminal, not retryable. For diagnostics, read the start of the body with a hard size cap, cancel the stream afterwards, and scrub values such as auth tokens. Carry the status code in a structured field, and keep human-readable message text free of patterns that other code might parse.
How to check
Write one test per edge: a 2xx with a non-stream body, a 2xx with no body, an empty 2xx whose header still says stream, a mixed-case media type, and a body containing text that looks like a status code. Confirm each test fails on the old code and passes on the new code. Finally, confirm the error reaches the user once, without a retry.
场景
有一个客户端调用模型提供方的流式接口。正常情况下返回的是事件流,客户端从事件里拼出回答。可是有用户反映,回答只有几个字就结束了,或者干脆是空的。日志里没有报错,状态码是 200。
常见的误判
默认 2xx 就是成功,成功就意味着正文是流。于是流解析器一个事件都没找到时,客户端就把它记成“模型回答得很短”或者“连接中途断了”,被归为断连的请求还会被悄悄重试。真正的原因哪里都没有记下来。
实际发生了什么
那个接口返回的是成功状态码,正文却根本不是流,而是一份描述错误的普通文档。把这种不匹配作为错误抛出的修复在评审期间,自动评审工具一次又一次找出新的边界情况:大小写混用的媒体类型;没有正文的 2xx;没有正文、但响应头仍然声称是流的 2xx;还有错误消息里写了“HTTP 503”这样的文字,结果被另一层重新解析成状态码,变成了可重试的传输错误。这一个修复之所以来回了那么多轮,全都是因为这些边界。
该检查什么
解析流之前先看两件事:到底有没有正文;媒体类型的主体部分(去掉参数、不区分大小写)是否就是预期的流类型。只要有一项不满足,这个响应就不算成功。然后顺着后面的各层追下去,看重试分类器怎么解读这个错误,确保下游不会悄悄把结论改掉。
修复方向
把不匹配作为专用的错误类型或错误码抛出,并归为不可重试的终止性错误。为了诊断可以读取正文开头,但要设定大小上限,读完后取消流,并抹掉认证令牌之类的值。状态码放在结构化字段里传递,给人看的错误消息里不要写可能被其他代码解析的模式。
如何确认
每个边界写一个测试:正文不是流的 2xx、没有正文的 2xx、响应头声称是流但为空的 2xx、大小写混用的媒体类型、正文里有像状态码一样的文字。确认每个测试在修复前的代码上失败、修复后通过。最后确认这个错误只会一次性呈现给用户,不会被重试。
状況
モデル提供元のストリーミングエンドポイントを呼ぶクライアントがある。正常ならイベントストリームが返り、クライアントはイベントを読んで答えを組み立てる。ところが、答えが数文字で終わる、あるいは空で返ってくるという報告が来る。ログにエラーはなく、ステータスコードは 200 だ。
よくある思い込み
2xx なら成功で、成功なら本文はストリームだと決めつける。だからストリームパーサーがイベントを一つも見つけられないと、「モデルが短く答えた」か「接続が途中で切れた」として扱い、切断に分類されたリクエストは黙って再試行される。本当の原因はどこにも記録されない。
実際に起きていたこと
エンドポイントは成功コードとともに、ストリームではない本文、つまりエラーを説明する普通の文書を返していた。この不一致をエラーとして上げる修正をレビューしている間、自動レビュアーが境界のケースを一つずつ見つけ続けた。大文字と小文字が混ざったメディアタイプ。本文のない 2xx。本文はないのにヘッダーだけストリームを名乗る 2xx。そしてエラーメッセージに「HTTP 503」のような文言を入れたら、別の層がその文言をステータスコードとして読み直し、再試行可能な通信エラーに変えてしまったケース。一つの修正が何度も往復したのは、すべてこれらの境界のせいだった。
何を確かめるべきか
ストリームをパースする前に二つ確かめる。本文は本当にあるか。メディアタイプの本体(パラメータを除き、大文字小文字を区別しない)が期待するストリームの型と一致するか。どちらかが違えば、その応答は成功ではない。そのうえで、その判定が後ろの層を通るうちに覆らないか、再試行の分類器がそのエラーをどう読むかまで追いかける。
直す方向
不一致は専用のエラー型かエラーコードとして上げ、再試行しない終端エラーに分類する。診断のために本文の先頭を読むなら、サイズに上限を設け、読んだあとストリームをキャンセルし、認証トークンのような値は消しておく。ステータスコードは構造化されたフィールドで渡し、人が読むメッセージの文言には、ほかのコードがパースしそうなパターンを入れない。
確認方法
境界ごとにテストを一つ置く。ストリームではない本文の 2xx、本文のない 2xx、ヘッダーだけストリームの空の 2xx、大文字小文字が混ざったメディアタイプ、ステータスコードに見える文言を含む本文。それぞれのテストが修正前のコードでは失敗し、修正後は通ることを確かめ、最後にそのエラーが再試行されずに一度でユーザーに見えることを確認する。