openai_nonstreaming_terminal_failure_failover_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. package service
  2. import (
  3. "errors"
  4. "net/http"
  5. "net/http/httptest"
  6. "strings"
  7. "testing"
  8. "github.com/gin-gonic/gin"
  9. "github.com/stretchr/testify/require"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. )
  12. // issue #5281:stream=false 时上游仍可能回 SSE(其他 sub2api 实例、部分 OpenAI 兼容
  13. // 上游),容量/限流错误经 HTTP 200 的终止事件回传。handleSSEToJSON 与
  14. // handlePassthroughSSEToJSON 把所有终止事件塞进固定 502,而几百行外的流式读取器对
  15. // 同一帧走 openAIStreamFailedEventShouldFailover / openAIStreamErrorEventShouldFailover
  16. // 判定并换号——同一个上游、同一个事件,只因请求上的 stream 标志而结果相反。
  17. func newNonStreamingFailoverContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
  18. t.Helper()
  19. gin.SetMode(gin.TestMode)
  20. rec := httptest.NewRecorder()
  21. c, _ := gin.CreateTestContext(rec)
  22. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  23. return c, rec
  24. }
  25. func newNonStreamingFailoverService() *OpenAIGatewayService {
  26. return &OpenAIGatewayService{cfg: &config.Config{}}
  27. }
  28. func newNonStreamingFailoverAccount() *Account {
  29. return &Account{
  30. ID: 1,
  31. Platform: PlatformOpenAI,
  32. Type: AccountTypeAPIKey,
  33. Name: "pool-account",
  34. Credentials: map[string]any{
  35. "pool_mode": true,
  36. },
  37. }
  38. }
  39. func newNonStreamingSSEResponse() *http.Response {
  40. return &http.Response{
  41. StatusCode: http.StatusOK,
  42. Header: http.Header{
  43. "Content-Type": []string{"text/event-stream"},
  44. "X-Request-Id": []string{"rid-nonstreaming-failed"},
  45. },
  46. }
  47. }
  48. func sseTerminalBody(eventType, data string) []byte {
  49. return []byte(strings.Join([]string{
  50. "event: " + eventType,
  51. "data: " + data,
  52. "",
  53. "data: [DONE]",
  54. }, "\n"))
  55. }
  56. // 主复现:issue 报告的容量错误,与流式兄弟用例
  57. // TestOpenAIStreamingResponseFailedBeforeOutputCapacityErrorReturnsFailover 逐项对齐。
  58. func TestNonStreamingSSEToJSON_CapacityFailedEventFailsOver(t *testing.T) {
  59. c, rec := newNonStreamingFailoverContext(t)
  60. svc := newNonStreamingFailoverService()
  61. body := sseTerminalBody("response.failed",
  62. `{"type":"response.failed","error":{"message":"Selected model is at capacity. Please try a different model.","type":"invalid_request_error"}}`)
  63. result, err := svc.handleSSEToJSON(newNonStreamingSSEResponse(), c, newNonStreamingFailoverAccount(), body, "model", "model")
  64. require.Nil(t, result)
  65. var failoverErr *UpstreamFailoverError
  66. require.ErrorAs(t, err, &failoverErr)
  67. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  68. // 容量降载是请求级信号,先在同账号有界重试——与流式路径同一套策略。
  69. require.True(t, failoverErr.RetryableOnSameAccount)
  70. require.Contains(t, string(failoverErr.ResponseBody), "Selected model is at capacity")
  71. // 换号的前提:一个字节都没写出去。
  72. require.False(t, c.Writer.Written())
  73. require.Empty(t, rec.Body.String())
  74. }
  75. // 行为翻转点:未被分类为不可重试的泛化 response.failed,此前回 502,现在换号。
  76. // 与流式路径对齐的结果,显式钉住以免被当成回归。
  77. func TestNonStreamingSSEToJSON_UnclassifiedFailedEventFailsOver(t *testing.T) {
  78. c, rec := newNonStreamingFailoverContext(t)
  79. svc := newNonStreamingFailoverService()
  80. payload := []byte(`{"type":"response.failed","error":{"message":"upstream rejected request"}}`)
  81. body := sseTerminalBody("response.failed", string(payload))
  82. // 前提:流式分类器对同一帧的裁决就是「换号」。翻转不是新政策,是补齐。
  83. require.True(t, openAIStreamFailedEventShouldFailover(payload, "upstream rejected request"))
  84. result, err := svc.handleSSEToJSON(newNonStreamingSSEResponse(), c, newNonStreamingFailoverAccount(), body, "model", "model")
  85. require.Nil(t, result)
  86. var failoverErr *UpstreamFailoverError
  87. require.ErrorAs(t, err, &failoverErr)
  88. require.False(t, c.Writer.Written())
  89. require.Empty(t, rec.Body.String())
  90. }
  91. // 「response.failed 必须回写协议错误」这一契约没有丢:明确不可重试的错误仍写 502。
  92. func TestNonStreamingSSEToJSON_NonRetryableFailedEventStillWritesProtocolError(t *testing.T) {
  93. cases := []struct {
  94. name string
  95. data string
  96. wantMsg string
  97. }{
  98. {
  99. name: "invalid_request",
  100. data: `{"type":"response.failed","error":{"type":"invalid_request_error","code":"invalid_request","message":"unknown parameter foo"}}`,
  101. wantMsg: "unknown parameter foo",
  102. },
  103. {
  104. name: "context_window",
  105. data: `{"type":"response.failed","response":{"id":"resp_failed","status":"failed","output":[],"error":{"code":"upstream_error","message":"input exceeds the context window"}}}`,
  106. wantMsg: "input exceeds the context window",
  107. },
  108. {
  109. name: "content_policy",
  110. data: `{"type":"response.failed","error":{"type":"content_policy_violation","message":"blocked by our content policy"}}`,
  111. wantMsg: "blocked by our content policy",
  112. },
  113. }
  114. for _, tc := range cases {
  115. t.Run(tc.name, func(t *testing.T) {
  116. c, rec := newNonStreamingFailoverContext(t)
  117. svc := newNonStreamingFailoverService()
  118. result, err := svc.handleSSEToJSON(newNonStreamingSSEResponse(), c,
  119. newNonStreamingFailoverAccount(), sseTerminalBody("response.failed", tc.data), "model", "model")
  120. require.Nil(t, result)
  121. require.Error(t, err)
  122. var failoverErr *UpstreamFailoverError
  123. require.False(t, errors.As(err, &failoverErr), "不可重试的上游错误不得换号")
  124. require.Equal(t, http.StatusBadGateway, rec.Code)
  125. require.Contains(t, rec.Body.String(), tc.wantMsg)
  126. require.Contains(t, rec.Header().Get("Content-Type"), "application/json")
  127. })
  128. }
  129. }
  130. // 裸 error 帧走的是更保守的那个分类器:只有正向识别为瞬时才换号。
  131. // 这条用例证明两种终止事件确实被分派到了各自的判定,而不是共用一个。
  132. func TestNonStreamingSSEToJSON_BareErrorEventUsesConservativeClassifier(t *testing.T) {
  133. t.Run("non_transient_stays_protocol_error", func(t *testing.T) {
  134. c, rec := newNonStreamingFailoverContext(t)
  135. svc := newNonStreamingFailoverService()
  136. data := `{"type":"error","error":{"message":"upstream rejected request"}}`
  137. // 同一条文案:failed 判换号,error 判不换号——差异就在分派。
  138. require.True(t, openAIStreamFailedEventShouldFailover([]byte(data), "upstream rejected request"))
  139. require.False(t, openAIStreamErrorEventShouldFailover([]byte(data), "upstream rejected request"))
  140. result, err := svc.handleSSEToJSON(newNonStreamingSSEResponse(), c,
  141. newNonStreamingFailoverAccount(), sseTerminalBody("error", data), "model", "model")
  142. require.Nil(t, result)
  143. require.Error(t, err)
  144. var failoverErr *UpstreamFailoverError
  145. require.False(t, errors.As(err, &failoverErr))
  146. require.Equal(t, http.StatusBadGateway, rec.Code)
  147. })
  148. t.Run("transient_fails_over", func(t *testing.T) {
  149. c, _ := newNonStreamingFailoverContext(t)
  150. svc := newNonStreamingFailoverService()
  151. data := `{"type":"error","error":{"message":"Temporary upstream failure, please retry"}}`
  152. result, err := svc.handleSSEToJSON(newNonStreamingSSEResponse(), c,
  153. newNonStreamingFailoverAccount(), sseTerminalBody("error", data), "model", "model")
  154. require.Nil(t, result)
  155. var failoverErr *UpstreamFailoverError
  156. require.ErrorAs(t, err, &failoverErr)
  157. require.False(t, c.Writer.Written())
  158. })
  159. }
  160. // 透传路径必须与合成路径同步修复,否则又造出一处新的不对称。
  161. func TestNonStreamingPassthroughSSEToJSON_CapacityFailedEventFailsOver(t *testing.T) {
  162. c, rec := newNonStreamingFailoverContext(t)
  163. svc := newNonStreamingFailoverService()
  164. body := sseTerminalBody("response.failed",
  165. `{"type":"response.failed","error":{"message":"Selected model is at capacity. Please try a different model.","type":"invalid_request_error"}}`)
  166. result, err := svc.handlePassthroughSSEToJSON(newNonStreamingSSEResponse(), c, newNonStreamingFailoverAccount(), body, "model", "model")
  167. require.Nil(t, result)
  168. var failoverErr *UpstreamFailoverError
  169. require.ErrorAs(t, err, &failoverErr)
  170. require.Contains(t, string(failoverErr.ResponseBody), "Selected model is at capacity")
  171. require.False(t, c.Writer.Written())
  172. require.Empty(t, rec.Body.String())
  173. }
  174. // 不变式:非流式的裁决必须与流式分类器逐项一致。任何一边以后改了判定,这条会红。
  175. func TestNonStreamingSSEToJSON_MatchesStreamingClassifierVerdict(t *testing.T) {
  176. payloads := []string{
  177. `{"type":"response.failed","error":{"message":"Selected model is at capacity. Please try a different model.","type":"invalid_request_error"}}`,
  178. `{"type":"response.failed","error":{"message":"upstream rejected request"}}`,
  179. `{"type":"response.failed","error":{"type":"invalid_request_error","code":"invalid_request","message":"unknown parameter foo"}}`,
  180. `{"type":"response.failed","response":{"id":"r","status":"failed","output":[],"error":{"code":"upstream_error","message":"input exceeds the context window"}}}`,
  181. `{"type":"response.failed","error":{"type":"content_policy_violation","message":"blocked by our content policy"}}`,
  182. }
  183. for _, data := range payloads {
  184. t.Run(data[:min(len(data), 60)], func(t *testing.T) {
  185. payload := []byte(data)
  186. want := openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload))
  187. c, _ := newNonStreamingFailoverContext(t)
  188. svc := newNonStreamingFailoverService()
  189. _, err := svc.handleSSEToJSON(newNonStreamingSSEResponse(), c,
  190. newNonStreamingFailoverAccount(), sseTerminalBody("response.failed", data), "model", "model")
  191. var failoverErr *UpstreamFailoverError
  192. require.Equal(t, want, errors.As(err, &failoverErr),
  193. "非流式裁决与流式分类器不一致:%s", data)
  194. })
  195. }
  196. }
  197. // 服务侧已显式提交响应后不得再提议换号;能否真正换号仍由 handler 的
  198. // openAIForwardMayFailover 用 keepalive 调整后的写出量仲裁(#3887),此处不重复实现。
  199. func TestNonStreamingSSEToJSON_CommittedResponseKeepsProtocolError(t *testing.T) {
  200. c, rec := newNonStreamingFailoverContext(t)
  201. svc := newNonStreamingFailoverService()
  202. MarkResponseCommitted(c)
  203. body := sseTerminalBody("response.failed",
  204. `{"type":"response.failed","error":{"message":"Selected model is at capacity. Please try a different model.","type":"invalid_request_error"}}`)
  205. result, err := svc.handleSSEToJSON(newNonStreamingSSEResponse(), c, newNonStreamingFailoverAccount(), body, "model", "model")
  206. require.Nil(t, result)
  207. require.Error(t, err)
  208. var failoverErr *UpstreamFailoverError
  209. require.False(t, errors.As(err, &failoverErr))
  210. require.Equal(t, http.StatusBadGateway, rec.Code)
  211. }
  212. func TestNonStreamingTerminalFailureFailover_NilAccountProposesNothing(t *testing.T) {
  213. c, _ := newNonStreamingFailoverContext(t)
  214. svc := newNonStreamingFailoverService()
  215. payload := []byte(`{"type":"response.failed","error":{"message":"Selected model is at capacity. Please try a different model."}}`)
  216. require.Nil(t, svc.nonStreamingTerminalFailureFailover(
  217. c, newNonStreamingSSEResponse(), nil, false, "response.failed", payload,
  218. "Selected model is at capacity. Please try a different model."))
  219. }