Lazy loaded image
网关项目demo02|网关的 SSE 不能只看一个 200
Words 1569Read Time 4 min
2025-11-13
type
Post
status
Published
date
Nov 13, 2025
slug
gateway002
summary
tags
gateway
category
icon
password

摘要

这篇只回答一个很小的问题:
网关入口是不是已经能做最基本的接入判断:非法请求拦住,合法请求分别返回 JSON 和完整 SSE。
我一开始很容易被 non-stream200 OK 带偏,以为拿到响应就说明链路通了。再往下看才发现,stream=true 更容易误判。
200 OK 只说明响应头已经发出来了。它不能说明客户端已经按 SSE 方式收到内容,也不能说明这条流正常结束。
所以这篇我只看四条路径:
  • 没带 API Key,要被挡住
  • API Key 无效,要被挡住
  • API Key 有效,non-stream 要返回 OpenAI-compatible JSON
  • API Key 有效,stream=true 要返回 text/event-stream,终端里要看到多段 data:,最后还要看到 data: [DONE]
这里要确认的是:入口层已经能拒绝非法请求,也能把合法请求分别送到 JSON 和 SSE 两条返回路径。
如果这四条路径还没跑通,后面的 tenant governance、fallback、observability 都没有讨论价值。

只跑本地路径

这次用到的东西很少:
  • 本地 MySQL / Redis
  • go run ./cmd/devinit
  • go run ./cmd/gateway
  • curl
本地有效 key:
网关地址:
这篇先不讨论真实 provider。
002 只确认一件事:
请求进到网关以后,鉴权、JSON 返回、SSE 返回这三条路径能不能先分清楚。

最小路径图


四条路径一起跑通,入口层才算接住了请求

测试矩阵

用例
场景
请求条件
预期状态码
预期响应特征
我关注的点
TC-01
missing key
Authorization
401
{"error":"missing api key"}
没有身份的请求不能进 chat 链路
TC-02
invalid key
Authorization: Bearer invalid-key
401
{"error":"invalid api key"}
无效 key 不能变成 500,也不能被放行
TC-03
valid key, non-stream
Authorization: Bearer lag-local-dev-key
200
JSON,包含 "object":"chat.completion"
合法请求能走到 JSON 返回路径
TC-04
valid key, stream
有效 key,"stream": true
200
Content-Type: text/event-stream,多条 data:,最终 [DONE]
SSE 不能只看响应头,要看内容和结束标志

前三条不复杂,第四条最容易误判

1. missing api key -> 401

最小返回:
这一步先看入口能不能拦住没有身份的请求。
没有 Authorization,请求就不应该继续落到 chat、service、provider。

2. invalid api key -> 401

最小返回:
这一步看的是错误语义。
找不到 key 时,网关要稳定返回 401。它不能泄漏成 500,也不能把请求放进去。

3. valid key, non-stream -> 200

最小返回:
到这里,本地 non-stream 成功路径已经成立。
它说明入口鉴权之后,合法请求能拿到 OpenAI-compatible JSON。
真实 upstream 还没算进来,这里不提前写成更大的结论。

最容易误判的是 stream=true

这一步是这篇最想说明的地方。
最小返回至少要长这样:
这一步我不会只看 200
我会继续看三件事:
  • Content-Type 是不是 text/event-stream
  • 终端里有没有连续出现多段 data: ...
  • 最后有没有 data: [DONE]
这里用了 curl -i -N
  • N 会去掉客户端自己的输出缓冲,避免最后一次性打印出来,把我误导成“看起来像流式”。
所以这一步我只确认到:
终端侧已经看到多段 data:,而且最后看到了 [DONE]
我不会提前把它写成“服务端实时 flush 已经完全验证”。
但到这个程度,我才会把这条 SSE 算作最小路径成立。
少一项都不算:
  • 只有 200,不算
  • 只有 text/event-stream,不算
  • 只有几段 data:,但没有 [DONE],也不算
这些都只能说明它看起来有响应,还不能说明最小 SSE 链路已经完整结束。

回到代码里,只看和这条主线有关的几处

这里我不把代码位置当成 curl 输出的替代。
它们只是帮我把前面的四条结果落回到真实逻辑里。

internal/api/router.go

POST /v1/chat/completions 在这里挂到路由上。
requireAPIKey(...) 会在进入 chat handler 之前,先拦住 missing / invalid key。

internal/api/handlers/chat.go

streamCompletion(...) 是 SSE 路径的关键。
这里对应的是:
  • Content-Type: text/event-stream
  • fmt.Fprintf(w, "data: %s\\n\\n", payload)
  • flusher.Flush()
  • 最后的 data: [DONE]

internal/service/chat/service.go

StreamCompletion(...)handleProviderStreamEvent(...) 会把 provider event 转成 service event。
这一层能帮我看清 stream 路径怎么往上走,但它不能替代前面的 curl 输出。
这篇要收住的点不是“代码里有 SSE 逻辑”,而是:
终端侧已经看到了完整 SSE 返回形态。

总结

四条最小路径都已经跑通了:
  • 没 key,被拦住
  • key 不对,被拦住
  • 合法 non-stream,返回 JSON
  • 合法 stream=true,返回 SSE,并以 [DONE] 结束
这一步最有价值的地方,是我不再把下面两件事直接当成接入链路成立:
  • 服务能启动
  • HTTP 返回 200
尤其是 SSE,至少要同时看到:
  • 响应类型正确
  • 多段 data:
  • 最后的 [DONE]
到这里,才可以说:
入口层已经能把合法请求分成 JSON 和 SSE 两条返回路径。

附:本地验证示例

missing api key -> 401
notion image
invalid api key -> 401
notion image
valid key, non-stream -> 200
notion image
stream=true -> text/event-stream + [DONE]
notion image
本地验证无误,我的网关可以稳稳接住请求了!
 
回到首页