openai_capacity_shed_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. )
  16. // --- mock: 只记录临时不可调度写入,其余方法不应被调用 ---
  17. type capacityShedAccountRepoStub struct {
  18. AccountRepository // 嵌入接口,未实现的方法会 panic(不应被调用)
  19. tempUnschedCalls int
  20. }
  21. func (r *capacityShedAccountRepoStub) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, _ string) error {
  22. r.tempUnschedCalls++
  23. return nil
  24. }
  25. // 上游容量降载是请求级信号:故障因素(客户端身份、模型容量)与账号无关,
  26. // 同账号重试用尽后不得把账号临时摘掉——否则一个被降载的请求会顺着 failover
  27. // 把整池账号逐个封禁,而每个账号都会以同一个错误失败。
  28. func TestTempUnscheduleRetryableErrorSkipsRequestScopedTransient(t *testing.T) {
  29. t.Run("请求级瞬时故障不写账号状态", func(t *testing.T) {
  30. repo := &capacityShedAccountRepoStub{}
  31. svc := &GatewayService{accountRepo: repo}
  32. svc.TempUnscheduleRetryableError(context.Background(), 1, &UpstreamFailoverError{
  33. StatusCode: http.StatusBadGateway,
  34. RetryableOnSameAccount: true,
  35. RequestScopedTransient: true,
  36. })
  37. require.Zero(t, repo.tempUnschedCalls)
  38. })
  39. // 对照组:同样的 502 在未标记请求级瞬时故障时仍按原有语义临时摘号,
  40. // 确认上面的断言来自新增守卫而非其他前置条件。
  41. t.Run("未标记时保持原有临时摘号语义", func(t *testing.T) {
  42. repo := &capacityShedAccountRepoStub{}
  43. svc := &GatewayService{accountRepo: repo}
  44. svc.TempUnscheduleRetryableError(context.Background(), 1, &UpstreamFailoverError{
  45. StatusCode: http.StatusBadGateway,
  46. RetryableOnSameAccount: true,
  47. })
  48. require.Equal(t, 1, repo.tempUnschedCalls)
  49. })
  50. }
  51. // 非池模式账号同样要先在同账号重试:换号不改变降载因素。
  52. func TestStreamFailedEventCapacityShedRetriesOnSameAccount(t *testing.T) {
  53. nonPool := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  54. for _, code := range []string{"server_is_overloaded", "slow_down"} {
  55. payload := []byte(`{"type":"response.failed","response":{"error":{"code":"` + code + `"}}}`)
  56. require.True(t, isOpenAIUpstreamCapacityShedEvent(payload), code)
  57. require.True(t, openAIStreamFailedEventRetryableOnSameAccount(nonPool, payload, "overloaded"), code)
  58. }
  59. // 非降载的 failed 事件在非池模式下仍不做同账号重试,避免放大改动面。
  60. other := []byte(`{"type":"response.failed","response":{"error":{"code":"server_error"}}}`)
  61. require.False(t, isOpenAIUpstreamCapacityShedEvent(other))
  62. require.False(t, openAIStreamFailedEventRetryableOnSameAccount(nonPool, other, "boom"))
  63. }
  64. func TestOpenAIHTTPCapacityShedIsRequestScopedForOAuthAccounts(t *testing.T) {
  65. payload := []byte(`{"error":{"type":"server_error","message":"Our servers are currently overloaded. Please try again later."}}`)
  66. failoverErr := newOpenAIUpstreamFailoverError(
  67. http.StatusBadRequest,
  68. http.Header{"X-Request-Id": []string{"rid-http-capacity"}},
  69. payload,
  70. "Our servers are currently overloaded. Please try again later.",
  71. false,
  72. )
  73. require.True(t, failoverErr.RetryableOnSameAccount)
  74. require.True(t, failoverErr.RequestScopedTransient)
  75. repo := &capacityShedAccountRepoStub{}
  76. (&GatewayService{accountRepo: repo}).TempUnscheduleRetryableError(context.Background(), 1, failoverErr)
  77. require.Zero(t, repo.tempUnschedCalls)
  78. rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  79. gateway := &OpenAIGatewayService{rateLimitService: rateLimitService}
  80. account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  81. require.False(t, gateway.handleOpenAIAccountUpstreamError(
  82. context.Background(),
  83. account,
  84. http.StatusBadRequest,
  85. nil,
  86. payload,
  87. "gpt-5",
  88. ))
  89. require.Zero(t, repo.tempUnschedCalls)
  90. }
  91. // 上游降载的真实序列是「event: error → event: response.failed」。error 帧不算
  92. // 客户端输出:若把它当首输出 flush,clientOutputStarted 被固化,随后的 failed
  93. // 事件就进不了 pre-output failover 分支,只能把致命错误原样转发给客户端。
  94. func TestOpenAIStreamErrorFrameDoesNotStartClientOutput(t *testing.T) {
  95. cases := []struct {
  96. data string
  97. eventType string
  98. want bool
  99. }{
  100. {`{"type":"error","error":{"code":"server_is_overloaded","message":"overloaded"}}`, "error", false},
  101. {`{"type":"error","error":{"code":"slow_down","message":"slow down"}}`, "error", false},
  102. {`{"type":"error","error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"limited"}}`, "error", false},
  103. // 不可重试类错误帧维持原样转发(不进 failover),保留上游错误细节。
  104. {`{"type":"error","error":{"type":"invalid_request_error","code":"content_policy_violation","message":"blocked"}}`, "error", true},
  105. {`{"type":"response.failed","response":{"error":{"code":"server_is_overloaded"}}}`, "response.failed", false},
  106. {`{"type":"response.created","response":{"id":"resp_1"}}`, "response.created", false},
  107. {`{"type":"response.in_progress","response":{"id":"resp_1"}}`, "response.in_progress", false},
  108. {`{"type":"response.output_item.added","item":{"type":"reasoning","summary":[]}}`, "response.output_item.added", false},
  109. {`{"type":"response.output_item.added","item":{"type":"reasoning","encrypted_content":"ciphertext"}}`, "response.output_item.added", true},
  110. {`{"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":""}}`, "response.reasoning_summary_part.added", false},
  111. {`{"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":"thinking"}}`, "response.reasoning_summary_part.added", true},
  112. {`{"type":"response.content_part.added","part":{"type":"output_text","text":""}}`, "response.content_part.added", false},
  113. {`{"type":"response.output_text.delta","delta":"hi"}`, "response.output_text.delta", true},
  114. {`[DONE]`, "", true},
  115. }
  116. for _, tc := range cases {
  117. require.Equal(t, tc.want, openAIStreamDataStartsClientOutput(tc.data, tc.eventType), "data=%s type=%s", tc.data, tc.eventType)
  118. }
  119. }
  120. func TestOpenAIStreamMetadataPreambleAndMessageOnlyOverloadFailOver(t *testing.T) {
  121. gin.SetMode(gin.TestMode)
  122. largeMetadata := strings.Repeat("x", 16*1024)
  123. stream := strings.Join([]string{
  124. "event: response.created",
  125. `data: {"type":"response.created","response":{"id":"resp_1","metadata":{"padding":"` + largeMetadata + `"}}}`,
  126. "",
  127. "event: response.output_item.added",
  128. `data: {"type":"response.output_item.added","item":{"type":"reasoning","summary":[]}}`,
  129. "",
  130. "event: response.reasoning_summary_part.added",
  131. `data: {"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":""}}`,
  132. "",
  133. "event: error",
  134. `data: {"type":"error","error":{"type":"service_unavailable_error","message":"Our servers are currently overloaded. Please try again later."}}`,
  135. "",
  136. }, "\n")
  137. tests := []struct {
  138. name string
  139. run func(*OpenAIGatewayService, *gin.Context, *http.Response, *Account) error
  140. }{
  141. {
  142. name: "native",
  143. run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
  144. _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "model", "model")
  145. return err
  146. },
  147. },
  148. {
  149. name: "passthrough",
  150. run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error {
  151. _, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "model", "model")
  152. return err
  153. },
  154. },
  155. }
  156. for _, tt := range tests {
  157. t.Run(tt.name, func(t *testing.T) {
  158. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}}
  159. rec := httptest.NewRecorder()
  160. c, _ := gin.CreateTestContext(rec)
  161. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  162. resp := &http.Response{
  163. StatusCode: http.StatusOK,
  164. Body: io.NopCloser(strings.NewReader(stream)),
  165. Header: http.Header{"X-Request-Id": []string{"rid-message-only-overload"}},
  166. }
  167. account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "acc"}
  168. err := tt.run(svc, c, resp, account)
  169. require.Error(t, err)
  170. var failoverErr *UpstreamFailoverError
  171. require.ErrorAs(t, err, &failoverErr)
  172. require.True(t, failoverErr.RetryableOnSameAccount)
  173. require.True(t, failoverErr.RequestScopedTransient)
  174. require.Equal(t, http.StatusServiceUnavailable, failoverErr.ClientStatusCode)
  175. require.Contains(t, failoverErr.ClientMessage, "servers are currently overloaded")
  176. require.False(t, c.Writer.Written())
  177. require.Empty(t, rec.Body.String())
  178. })
  179. }
  180. }
  181. // 回归用例(真实上游降载序列):created → in_progress → error 帧 → response.failed。
  182. // 期望仍然走 pre-output failover(同账号重试 + 请求级瞬时标记),且不向客户端写出任何字节。
  183. func TestOpenAIStreamCapacityShedErrorFramePrecedingFailedStillFailsOver(t *testing.T) {
  184. gin.SetMode(gin.TestMode)
  185. cfg := &config.Config{
  186. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  187. }
  188. svc := &OpenAIGatewayService{cfg: cfg}
  189. rec := httptest.NewRecorder()
  190. c, _ := gin.CreateTestContext(rec)
  191. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  192. resp := &http.Response{
  193. StatusCode: http.StatusOK,
  194. Body: io.NopCloser(strings.NewReader(strings.Join([]string{
  195. "event: response.created",
  196. `data: {"type":"response.created","response":{"id":"resp_1"},"sequence_number":0}`,
  197. "",
  198. "event: response.in_progress",
  199. `data: {"type":"response.in_progress","response":{"id":"resp_1"},"sequence_number":1}`,
  200. "",
  201. "event: error",
  202. `data: {"type":"error","error":{"type":"service_unavailable_error","code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."},"sequence_number":2}`,
  203. "",
  204. "event: response.failed",
  205. `data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}},"sequence_number":3}`,
  206. "",
  207. }, "\n"))),
  208. Header: http.Header{"X-Request-Id": []string{"rid-shed-error-then-failed"}},
  209. }
  210. _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "acc"}, time.Now(), "model", "model")
  211. require.Error(t, err)
  212. var failoverErr *UpstreamFailoverError
  213. require.ErrorAs(t, err, &failoverErr)
  214. require.True(t, failoverErr.RetryableOnSameAccount)
  215. require.True(t, failoverErr.RequestScopedTransient)
  216. require.False(t, c.Writer.Written())
  217. require.Empty(t, rec.Body.String())
  218. }
  219. // 流中途(已有真实输出)降载时无法再 failover,此时必须把降载码改写为客户端
  220. // 可重试的 server_error 再通过唯一 response.failed 终态转发——Codex 对
  221. // server_is_overloaded/slow_down 判致命并终止会话,对其余错误码执行内置退避重试。
  222. func TestOpenAIStreamCapacityShedAfterOutputRewritesCodeForClient(t *testing.T) {
  223. gin.SetMode(gin.TestMode)
  224. logSink, restore := captureStructuredLog(t)
  225. defer restore()
  226. cfg := &config.Config{
  227. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  228. }
  229. svc := &OpenAIGatewayService{cfg: cfg}
  230. rec := httptest.NewRecorder()
  231. c, _ := gin.CreateTestContext(rec)
  232. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  233. resp := &http.Response{
  234. StatusCode: http.StatusOK,
  235. Body: io.NopCloser(strings.NewReader(strings.Join([]string{
  236. "event: response.created",
  237. `data: {"type":"response.created","response":{"id":"resp_1"}}`,
  238. "",
  239. "event: response.output_text.delta",
  240. `data: {"type":"response.output_text.delta","delta":"partial"}`,
  241. "",
  242. "event: error",
  243. `data: {"type":"error","error":{"type":"service_unavailable_error","code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."},"sequence_number":2}`,
  244. "",
  245. "event: response.failed",
  246. `data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}},"sequence_number":3}`,
  247. "",
  248. }, "\n"))),
  249. Header: http.Header{"X-Request-Id": []string{"rid-shed-after-output"}},
  250. }
  251. _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "acc"}, time.Now(), "model", "model")
  252. require.Error(t, err)
  253. var failoverErr *UpstreamFailoverError
  254. require.False(t, errors.As(err, &failoverErr))
  255. body := rec.Body.String()
  256. require.Contains(t, body, "partial")
  257. require.NotContains(t, body, "event: error")
  258. require.Equal(t, 1, strings.Count(body, "event: response.failed"))
  259. require.Equal(t, 1, strings.Count(body, `"code":"server_error"`))
  260. require.Contains(t, body, `"code":"server_error"`)
  261. require.NotContains(t, body, "server_is_overloaded")
  262. require.Contains(t, body, "Our servers are currently overloaded")
  263. require.True(t, logSink.ContainsMessage("gateway.failover_suppressed_after_semantic_output"))
  264. require.True(t, logSink.ContainsFieldValue("path", "native_sse"))
  265. require.True(t, logSink.ContainsFieldValue("upstream_request_id", "rid-shed-after-output"))
  266. }
  267. func TestOpenAIStreamProcessingFailureAfterOutputIsRecorded(t *testing.T) {
  268. gin.SetMode(gin.TestMode)
  269. stream := strings.Join([]string{
  270. "event: response.created",
  271. `data: {"type":"response.created","response":{"id":"resp_processing_failure"}}`,
  272. "",
  273. "event: response.output_text.delta",
  274. `data: {"type":"response.output_text.delta","delta":"partial"}`,
  275. "",
  276. "event: response.failed",
  277. `data: {"type":"response.failed","response":{"id":"resp_processing_failure","status":"failed","error":{"code":"server_error","message":"An error occurred while processing your request. Please include the request ID rid-processing-failure in your message."}}}`,
  278. "",
  279. }, "\n")
  280. for _, passthrough := range []bool{false, true} {
  281. t.Run(map[bool]string{false: "native", true: "passthrough"}[passthrough], func(t *testing.T) {
  282. svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}}
  283. rec := httptest.NewRecorder()
  284. c, _ := gin.CreateTestContext(rec)
  285. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  286. resp := &http.Response{
  287. StatusCode: http.StatusOK,
  288. Body: io.NopCloser(strings.NewReader(stream)),
  289. Header: http.Header{"X-Request-Id": []string{"rid-processing-failure"}},
  290. }
  291. account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "codex-account"}
  292. var err error
  293. if passthrough {
  294. _, err = svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "gpt-5.6-terra", "gpt-5.6-terra")
  295. } else {
  296. _, err = svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "gpt-5.6-terra", "gpt-5.6-terra")
  297. }
  298. require.Error(t, err)
  299. var failoverErr *UpstreamFailoverError
  300. require.False(t, errors.As(err, &failoverErr))
  301. require.NotEmpty(t, rec.Body.String())
  302. require.Contains(t, rec.Body.String(), "response.failed")
  303. require.NotNil(t, c)
  304. rawEvents, ok := c.Get(OpsUpstreamErrorsKey)
  305. require.True(t, ok)
  306. events, ok := rawEvents.([]*OpsUpstreamErrorEvent)
  307. require.True(t, ok)
  308. require.NotEmpty(t, events)
  309. event := events[len(events)-1]
  310. require.Equal(t, "stream_failed", event.Kind)
  311. require.Equal(t, "rid-processing-failure", event.UpstreamRequestID)
  312. require.Contains(t, event.Message, "An error occurred while processing your request")
  313. })
  314. }
  315. }
  316. // helper 单测:只有降载码被改写,其余错误码(尤其 rate_limit_exceeded,客户端
  317. // 依赖其原码解析重试延时)必须原样保留。
  318. func TestSanitizeOpenAICapacityShedErrorCodeForClient(t *testing.T) {
  319. cases := []struct {
  320. name string
  321. payload string
  322. wantChanged bool
  323. wantContain string
  324. }{
  325. {
  326. name: "failed事件嵌套code改写",
  327. payload: `{"type":"response.failed","response":{"error":{"code":"server_is_overloaded","message":"overloaded"}}}`,
  328. wantChanged: true,
  329. wantContain: `"code":"server_error"`,
  330. },
  331. {
  332. name: "error帧裸code改写",
  333. payload: `{"type":"error","error":{"code":"slow_down","message":"slow down"}}`,
  334. wantChanged: true,
  335. wantContain: `"code":"server_error"`,
  336. },
  337. {
  338. name: "failed事件只有过载文案时补充code",
  339. payload: `{"type":"response.failed","response":{"error":{"message":"Our servers are currently overloaded. Please try again later."}}}`,
  340. wantChanged: true,
  341. wantContain: `"code":"server_error"`,
  342. },
  343. {
  344. name: "error帧只有过载文案时补充code",
  345. payload: `{"type":"error","error":{"message":"Server is overloaded. Please try again later."}}`,
  346. wantChanged: true,
  347. wantContain: `"code":"server_error"`,
  348. },
  349. {
  350. name: "rate_limit不改写",
  351. payload: `{"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","message":"try again in 3s"}}}`,
  352. wantChanged: false,
  353. wantContain: `"code":"rate_limit_exceeded"`,
  354. },
  355. {
  356. name: "普通server_error不改写",
  357. payload: `{"type":"response.failed","response":{"error":{"code":"server_error","message":"boom"}}}`,
  358. wantChanged: false,
  359. wantContain: `"code":"server_error"`,
  360. },
  361. {
  362. name: "非JSON不改写",
  363. payload: `not-json`,
  364. wantChanged: false,
  365. wantContain: `not-json`,
  366. },
  367. }
  368. for _, tc := range cases {
  369. t.Run(tc.name, func(t *testing.T) {
  370. out, changed := sanitizeOpenAICapacityShedErrorCodeForClient([]byte(tc.payload))
  371. require.Equal(t, tc.wantChanged, changed)
  372. require.Contains(t, string(out), tc.wantContain)
  373. if changed {
  374. require.NotContains(t, string(out), "server_is_overloaded")
  375. require.NotContains(t, string(out), "slow_down")
  376. }
  377. })
  378. }
  379. }
  380. // 出站身份的版本声明只能有一个来源:UA 的版本段、version 头、探针版本三处必须同源,
  381. // 各自硬编码会漂移成互相矛盾的身份,而自相矛盾或陈旧的身份会被上游优先降载。
  382. func TestCodexOutboundVersionHasSingleSource(t *testing.T) {
  383. require.True(t,
  384. strings.HasPrefix(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion+" "),
  385. "codexCLIUserAgent=%q 必须以 codexCLIVersion=%q 作为版本段", codexCLIUserAgent, codexCLIVersion,
  386. )
  387. require.GreaterOrEqual(t, CompareVersions(codexCLIVersion, codexUpstreamMinVersion), 0,
  388. "codexCLIVersion=%q 不得低于上游最低门槛 %q", codexCLIVersion, codexUpstreamMinVersion,
  389. )
  390. }