type
Post
status
Published
date
Nov 13, 2025
slug
gateway002
summary
tags
gateway
category
icon
password
摘要
这篇只回答一个很小的问题:
网关入口是不是已经能做最基本的接入判断:非法请求拦住,合法请求分别返回 JSON 和完整 SSE。
我一开始很容易被
non-stream 的 200 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
.png%3FspaceId%3Dd89f2367-b54a-81cd-9c59-00032f2f4f1e?table=block&id=358f2367-b54a-8054-b712-d2cd70dbc1ff&t=358f2367-b54a-8054-b712-d2cd70dbc1ff)
invalid api key -> 401
.png%3FspaceId%3Dd89f2367-b54a-81cd-9c59-00032f2f4f1e?table=block&id=358f2367-b54a-8037-aeba-c246739a2851&t=358f2367-b54a-8037-aeba-c246739a2851)
valid key, non-stream -> 200
.png%3FspaceId%3Dd89f2367-b54a-81cd-9c59-00032f2f4f1e?table=block&id=358f2367-b54a-8016-adcd-e8ce6bdc49fe&t=358f2367-b54a-8016-adcd-e8ce6bdc49fe)
stream=true -> text/event-stream + [DONE].png%3FspaceId%3Dd89f2367-b54a-81cd-9c59-00032f2f4f1e?table=block&id=358f2367-b54a-8012-bcf3-eadb09fa831a&t=358f2367-b54a-8012-bcf3-eadb09fa831a)
本地验证无误,我的网关可以稳稳接住请求了!
分享
