stream_error_event_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. package handler
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. // Regression for the production incident on 2026-05-24 around 9:13 CST:
  16. // user 16 sent /v1/responses with stream:true via Codex CLI; the user-concurrency
  17. // slot wait sent SSE ping comments (flushing HTTP 200 + headers), then the 30s
  18. // timeout fired and the handler emitted `event: error\ndata: {...}`. Codex CLI
  19. // does not recognize that as a Responses terminal event and reports
  20. // "stream closed before response.completed". The fix is to emit a synthetic
  21. // response.failed event when the inbound endpoint is /v1/responses.
  22. func newGinContextForEndpoint(t *testing.T, endpoint string) (*gin.Context, *httptest.ResponseRecorder) {
  23. t.Helper()
  24. gin.SetMode(gin.TestMode)
  25. w := httptest.NewRecorder()
  26. c, _ := gin.CreateTestContext(w)
  27. c.Request = httptest.NewRequest(http.MethodPost, endpoint, nil)
  28. return c, w
  29. }
  30. // parseResponsesFailedSSE 抽出 SSE 中 data 行的 JSON,返回 (response 对象, error 对象)。
  31. func parseResponsesFailedSSE(t *testing.T, body string) (map[string]any, map[string]any) {
  32. t.Helper()
  33. require.True(t, strings.HasPrefix(body, "event: response.failed\n"),
  34. "expect event: response.failed prefix, got: %q", body)
  35. require.True(t, strings.HasSuffix(body, "\n\n"))
  36. lines := strings.SplitN(strings.TrimSuffix(body, "\n\n"), "\n", 2)
  37. require.Len(t, lines, 2)
  38. require.True(t, strings.HasPrefix(lines[1], "data: "))
  39. jsonStr := strings.TrimPrefix(lines[1], "data: ")
  40. var parsed map[string]any
  41. require.NoError(t, json.Unmarshal([]byte(jsonStr), &parsed), "data must be valid JSON: %s", jsonStr)
  42. assert.Equal(t, "response.failed", parsed["type"])
  43. // 故意不发 sequence_number,避免与后续真实事件的序号冲突。
  44. _, hasSeq := parsed["sequence_number"]
  45. assert.False(t, hasSeq, "synthetic event must not emit sequence_number")
  46. resp, ok := parsed["response"].(map[string]any)
  47. require.True(t, ok, "response object missing")
  48. assert.Equal(t, "response", resp["object"])
  49. assert.Equal(t, "failed", resp["status"])
  50. errObj, ok := resp["error"].(map[string]any)
  51. require.True(t, ok, "error object missing")
  52. return resp, errObj
  53. }
  54. // OpenAI handler: /v1/responses streaming, after stream started, must emit response.failed.
  55. func TestOpenAIHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(t *testing.T) {
  56. c, w := newGinContextForEndpoint(t, EndpointResponses)
  57. h := &OpenAIGatewayHandler{}
  58. h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error",
  59. "Concurrency limit exceeded for user, please retry later", true)
  60. resp, errObj := parseResponsesFailedSSE(t, w.Body.String())
  61. id, _ := resp["id"].(string)
  62. assert.True(t, strings.HasPrefix(id, "resp_"), "id should start with resp_, got %q", id)
  63. assert.Equal(t, "rate_limit_exceeded", errObj["code"])
  64. assert.Equal(t, "Concurrency limit exceeded for user, please retry later", errObj["message"])
  65. }
  66. func TestOpenAIAdmissionError_SynchronousNonResponsesIncludesGatewayCode(t *testing.T) {
  67. c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
  68. h := &OpenAIGatewayHandler{}
  69. h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
  70. gatewayQueueFullCode, "Too many pending requests, please retry later", false, false)
  71. assert.Equal(t, http.StatusTooManyRequests, w.Code)
  72. assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
  73. assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
  74. }
  75. func TestOpenAIAdmissionError_SynchronousResponsesIncludesGatewayCode(t *testing.T) {
  76. c, w := newGinContextForEndpoint(t, EndpointResponses)
  77. h := &OpenAIGatewayHandler{}
  78. h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
  79. gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", false, false)
  80. assert.Equal(t, http.StatusTooManyRequests, w.Code)
  81. assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
  82. assert.Equal(t, gatewayConcurrencyLimitCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
  83. }
  84. func TestOpenAIAdmissionError_ResponsesFailedIncludesGatewayCode(t *testing.T) {
  85. c, w := newGinContextForEndpoint(t, EndpointResponses)
  86. h := &OpenAIGatewayHandler{}
  87. h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
  88. gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true, false)
  89. _, errObj := parseResponsesFailedSSE(t, w.Body.String())
  90. assert.Equal(t, gatewayConcurrencyLimitCode, errObj["code"])
  91. }
  92. func TestOpenAIAdmissionError_StreamingNonResponsesIncludesGatewayCode(t *testing.T) {
  93. c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
  94. h := &OpenAIGatewayHandler{}
  95. h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
  96. gatewayQueueFullCode, "Too many pending requests, please retry later", true, false)
  97. body := w.Body.String()
  98. assert.True(t, strings.HasPrefix(body, "event: error\n"))
  99. payload := body[strings.Index(body, "{"):]
  100. assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String())
  101. assert.Equal(t, gatewayQueueFullCode, gjson.Get(payload, "error.code").String())
  102. }
  103. func TestOpenAIUpstreamRateLimitWithoutLocalCodeRemainsUnchanged(t *testing.T) {
  104. c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
  105. h := &OpenAIGatewayHandler{}
  106. h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "upstream limited", false)
  107. assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
  108. assert.False(t, gjson.GetBytes(w.Body.Bytes(), "error.code").Exists())
  109. }
  110. // 当 setOpsRequestContext 写过 model,合成事件应回填该字段(与 codebase 已有 makeResponsesCompletedEvent 对齐)。
  111. func TestOpenAIHandleStreamingAwareError_ResponsesStreamingIncludesModel(t *testing.T) {
  112. c, w := newGinContextForEndpoint(t, EndpointResponses)
  113. setOpsRequestContext(c, "gpt-5.5", true)
  114. h := &OpenAIGatewayHandler{}
  115. h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "boom", true)
  116. resp, _ := parseResponsesFailedSSE(t, w.Body.String())
  117. assert.Equal(t, "gpt-5.5", resp["model"])
  118. }
  119. // 没有 model 时 model 字段不应出现(避免发空字符串污染下游解析)。
  120. func TestOpenAIHandleStreamingAwareError_ResponsesStreamingOmitsEmptyModel(t *testing.T) {
  121. c, w := newGinContextForEndpoint(t, EndpointResponses)
  122. h := &OpenAIGatewayHandler{}
  123. h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "boom", true)
  124. resp, _ := parseResponsesFailedSSE(t, w.Body.String())
  125. _, hasModel := resp["model"]
  126. assert.False(t, hasModel, "model field must be omitted when unknown")
  127. }
  128. // 当 request.Context 携带 ctxkey.RequestID 时,合成 id 应与之关联,便于和 server log 串起来。
  129. func TestOpenAIHandleStreamingAwareError_ResponsesStreamingReusesRequestID(t *testing.T) {
  130. c, w := newGinContextForEndpoint(t, EndpointResponses)
  131. c.Request = c.Request.WithContext(
  132. context.WithValue(c.Request.Context(), ctxkey.RequestID, "fd277bc5-ff7e-45d1-8aa9-f54e1df318f1"),
  133. )
  134. h := &OpenAIGatewayHandler{}
  135. h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "x", true)
  136. resp, _ := parseResponsesFailedSSE(t, w.Body.String())
  137. assert.Equal(t, "resp_fd277bc5ff7e45d18aa9f54e1df318f1", resp["id"])
  138. }
  139. // 与旧分支的 TestOpenAIHandleStreamingAwareError_JSONEscaping 对齐:
  140. // 新的 response.failed payload 也必须正确转义 message 里的特殊字符,
  141. // 否则下游 SDK 解析 JSON 时会失败。
  142. func TestOpenAIHandleStreamingAwareError_ResponsesStreamingJSONEscaping(t *testing.T) {
  143. cases := []struct {
  144. name string
  145. errType string
  146. message string
  147. }{
  148. {"双引号", "server_error", `upstream returned "invalid" response`},
  149. {"反斜杠", "server_error", `path C:\Users\test\file.txt not found`},
  150. {"双引号+反斜杠", "upstream_error", `error parsing "key\value": unexpected token`},
  151. {"换行与制表", "server_error", "line1\nline2\ttab"},
  152. {"普通", "upstream_error", "Upstream service temporarily unavailable"},
  153. }
  154. for _, tc := range cases {
  155. t.Run(tc.name, func(t *testing.T) {
  156. c, w := newGinContextForEndpoint(t, EndpointResponses)
  157. h := &OpenAIGatewayHandler{}
  158. h.handleStreamingAwareError(c, http.StatusBadGateway, tc.errType, tc.message, true)
  159. _, errObj := parseResponsesFailedSSE(t, w.Body.String())
  160. assert.Equal(t, tc.message, errObj["message"], "message 必须被原样还原")
  161. })
  162. }
  163. }
  164. // OpenAI handler: /v1/chat/completions streaming keeps the legacy event: error format
  165. // (out of scope for this fix; covered to prevent regression of unrelated paths).
  166. func TestOpenAIHandleStreamingAwareError_ChatCompletionsStreamingKeepsLegacy(t *testing.T) {
  167. c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
  168. h := &OpenAIGatewayHandler{}
  169. h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "boom", true)
  170. body := w.Body.String()
  171. assert.True(t, strings.HasPrefix(body, "event: error\n"), "got: %q", body)
  172. }
  173. // Gateway (Anthropic-backed) handler: /v1/responses path also must emit response.failed.
  174. func TestGatewayHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(t *testing.T) {
  175. c, w := newGinContextForEndpoint(t, EndpointResponses)
  176. h := &GatewayHandler{}
  177. h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "upstream gone", true)
  178. _, errObj := parseResponsesFailedSSE(t, w.Body.String())
  179. assert.Equal(t, "upstream_error", errObj["code"])
  180. assert.Equal(t, "upstream gone", errObj["message"])
  181. }
  182. func TestGatewayAdmissionError_SynchronousIncludesGatewayCode(t *testing.T) {
  183. c, w := newGinContextForEndpoint(t, EndpointMessages)
  184. h := &GatewayHandler{}
  185. h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
  186. gatewayQueueFullCode, "Too many pending requests, please retry later", false)
  187. assert.Equal(t, http.StatusTooManyRequests, w.Code)
  188. assert.Equal(t, "error", gjson.GetBytes(w.Body.Bytes(), "type").String())
  189. assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
  190. assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
  191. }
  192. func TestGatewayAdmissionError_MessagesStreamingIncludesGatewayCode(t *testing.T) {
  193. c, w := newGinContextForEndpoint(t, EndpointMessages)
  194. h := &GatewayHandler{}
  195. h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
  196. gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true)
  197. body := w.Body.String()
  198. assert.True(t, strings.HasPrefix(body, `data: {"type":"error"`))
  199. payload := body[strings.Index(body, "{"):]
  200. assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String())
  201. assert.Equal(t, gatewayConcurrencyLimitCode, gjson.Get(payload, "error.code").String())
  202. }
  203. func TestGatewayAdmissionError_BareResponsesFailedIncludesGatewayCode(t *testing.T) {
  204. c, w := newGinContextForEndpoint(t, "/responses")
  205. h := &GatewayHandler{}
  206. h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
  207. gatewayQueueFullCode, "Too many pending requests, please retry later", true)
  208. _, errObj := parseResponsesFailedSSE(t, w.Body.String())
  209. assert.Equal(t, gatewayQueueFullCode, errObj["code"])
  210. }
  211. // Gateway handler: /v1/messages preserves the legacy data:{type:error,...} format
  212. // (Anthropic spec accepts a type:"error" stream event).
  213. func TestGatewayHandleStreamingAwareError_MessagesStreamingKeepsLegacy(t *testing.T) {
  214. c, w := newGinContextForEndpoint(t, EndpointMessages)
  215. h := &GatewayHandler{}
  216. h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "boom", true)
  217. body := w.Body.String()
  218. assert.True(t, strings.HasPrefix(body, `data: {"type":"error"`), "got: %q", body)
  219. }
  220. // 项目里 /responses 注册在多组路由:/v1/responses(gateway)、裸 /responses(top-level)、
  221. // /backend-api/codex/responses(codex direct)。我们 fix 必须覆盖全部,
  222. // 否则一些客户端走的路径就不会发 response.failed,照样报 stream closed。
  223. // 这是生产 2026-05-24 ~11:05 UTC user 16 实际命中的 bug。
  224. func TestInboundIsResponses_CoversAllRoutes(t *testing.T) {
  225. cases := []struct {
  226. route string
  227. want bool
  228. }{
  229. {"/v1/responses", true},
  230. {"/v1/responses/compact", true},
  231. {"/responses", true}, // <-- 用户 16 实际走这条
  232. {"/responses/compact", true},
  233. {"/backend-api/codex/responses", true},
  234. {"/backend-api/codex/responses/compact", true},
  235. {"/v1/chat/completions", false},
  236. {"/v1/messages", false},
  237. {"/", false},
  238. {"/responses-fake", false},
  239. }
  240. for _, tc := range cases {
  241. t.Run(tc.route, func(t *testing.T) {
  242. c, _ := newGinContextForEndpoint(t, tc.route)
  243. assert.Equal(t, tc.want, inboundIsResponses(c), "route=%q", tc.route)
  244. })
  245. }
  246. }
  247. // 用 c.Request.URL.Path 作为 fallback(当 c.FullPath() 为空时,例如某些测试 fixture)。
  248. func TestInboundIsResponses_FallsBackToURLPath(t *testing.T) {
  249. gin.SetMode(gin.TestMode)
  250. w := httptest.NewRecorder()
  251. c, _ := gin.CreateTestContext(w)
  252. c.Request = httptest.NewRequest(http.MethodPost, "/responses", nil)
  253. // 这种情况下 c.FullPath() 是 "",必须 fallback 到 URL.Path
  254. assert.True(t, inboundIsResponses(c), "URL.Path fallback must work when FullPath is empty")
  255. }
  256. // 回归生产事故:用户 16 走 /responses 路径,必须发 response.failed。
  257. func TestOpenAIHandleStreamingAwareError_BareResponsesRouteEmitsResponseFailed(t *testing.T) {
  258. c, w := newGinContextForEndpoint(t, "/responses")
  259. h := &OpenAIGatewayHandler{}
  260. h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error",
  261. "Concurrency limit exceeded for user, please retry later", true)
  262. resp, errObj := parseResponsesFailedSSE(t, w.Body.String())
  263. id, _ := resp["id"].(string)
  264. assert.True(t, strings.HasPrefix(id, "resp_"))
  265. assert.Equal(t, "rate_limit_exceeded", errObj["code"])
  266. }
  267. // Synthesized response.failed id falls back to uuid when no request_id is present.
  268. // issue #5601:严格的 Responses 客户端把 created_at 当必填字段,缺失即
  269. // `missing field 'created_at'`。合成的终止事件若解析不了,本文件存在的意义
  270. // (给客户端一个可识别的终止事件而不是盲重连)就落空了。
  271. func TestOpenAIHandleStreamingAwareError_ResponsesStreamingCarriesCreatedAt(t *testing.T) {
  272. c, w := newGinContextForEndpoint(t, EndpointResponses)
  273. h := &OpenAIGatewayHandler{}
  274. h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "boom", true)
  275. resp, _ := parseResponsesFailedSSE(t, w.Body.String())
  276. raw, ok := resp["created_at"]
  277. assert.True(t, ok, "response.failed 必须带 created_at")
  278. createdAt, ok := raw.(float64)
  279. assert.True(t, ok, "created_at 必须是数字,得到 %T", raw)
  280. assert.Greater(t, int64(createdAt), int64(0), "created_at 必须是有效的 unix 时间戳")
  281. }
  282. func TestSynthesizeResponseID_FallbackUUID(t *testing.T) {
  283. c, _ := newGinContextForEndpoint(t, EndpointResponses)
  284. id := synthesizeResponseID(c)
  285. assert.True(t, strings.HasPrefix(id, "resp_"))
  286. // uuid 去掉短横线后 32 hex 字符;前缀 "resp_" 共 37。
  287. assert.Len(t, id, 37)
  288. }
  289. func TestMapResponsesErrorCode(t *testing.T) {
  290. cases := []struct{ in, out string }{
  291. {"rate_limit_error", "rate_limit_exceeded"},
  292. {"invalid_request_error", "invalid_request"},
  293. {"permission_error", "permission_denied"},
  294. {"authentication_error", "authentication_failed"},
  295. {"upstream_error", "upstream_error"},
  296. {"server_error", "server_error"},
  297. {"api_error", "server_error"},
  298. {"", "server_error"},
  299. {"custom_thing", "custom_thing"},
  300. }
  301. for _, tc := range cases {
  302. assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in, ""), "in=%q", tc.in)
  303. }
  304. assert.Equal(t, gatewayQueueFullCode, mapResponsesErrorCode("rate_limit_error", gatewayQueueFullCode))
  305. }