openai_responses_failover_cancel_test.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. //go:build unit
  2. package handler
  3. import (
  4. "bytes"
  5. "context"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "sync"
  10. "testing"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  13. "github.com/Wei-Shaw/sub2api/internal/service"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. "github.com/tidwall/gjson"
  17. )
  18. // openAIResponsesFailoverCancelUpstream 固定返回 HTTP 520,可在首次上游调用时
  19. // 触发回调(用于模拟“上游在途期间客户端断开”)。
  20. type openAIResponsesFailoverCancelUpstream struct {
  21. service.HTTPUpstream
  22. mu sync.Mutex
  23. accountIDs []int64
  24. onFirstDo func()
  25. }
  26. func (u *openAIResponsesFailoverCancelUpstream) Do(_ *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
  27. u.mu.Lock()
  28. u.accountIDs = append(u.accountIDs, accountID)
  29. first := len(u.accountIDs) == 1
  30. u.mu.Unlock()
  31. if first && u.onFirstDo != nil {
  32. u.onFirstDo()
  33. }
  34. return &http.Response{
  35. StatusCode: 520,
  36. Header: http.Header{"Content-Type": []string{"text/html"}},
  37. Body: io.NopCloser(bytes.NewBufferString("<html>520: unknown error</html>")),
  38. }, nil
  39. }
  40. func (u *openAIResponsesFailoverCancelUpstream) calls() []int64 {
  41. u.mu.Lock()
  42. defer u.mu.Unlock()
  43. return append([]int64(nil), u.accountIDs...)
  44. }
  45. func newOpenAIResponsesFailoverTestHandler(t *testing.T, upstream service.HTTPUpstream) *OpenAIGatewayHandler {
  46. t.Helper()
  47. accounts := []service.Account{
  48. {
  49. ID: 1,
  50. Name: "responses-account-1",
  51. Platform: service.PlatformOpenAI,
  52. Type: service.AccountTypeOAuth,
  53. Status: service.StatusActive,
  54. Schedulable: true,
  55. Concurrency: 0,
  56. Priority: 0,
  57. Credentials: map[string]any{"access_token": "token-1"},
  58. },
  59. {
  60. ID: 2,
  61. Name: "responses-account-2",
  62. Platform: service.PlatformOpenAI,
  63. Type: service.AccountTypeOAuth,
  64. Status: service.StatusActive,
  65. Schedulable: true,
  66. Concurrency: 0,
  67. Priority: 1,
  68. Credentials: map[string]any{"access_token": "token-2"},
  69. },
  70. }
  71. accountRepo := openAIImagesFailoverAccountRepo{accounts: accounts}
  72. cfg := &config.Config{RunMode: config.RunModeSimple}
  73. gatewayService := service.NewOpenAIGatewayService(
  74. accountRepo,
  75. nil,
  76. nil,
  77. nil,
  78. nil,
  79. nil,
  80. nil,
  81. cfg,
  82. nil,
  83. nil,
  84. nil,
  85. nil,
  86. nil,
  87. upstream,
  88. nil,
  89. nil,
  90. nil,
  91. nil,
  92. nil,
  93. nil,
  94. nil,
  95. nil,
  96. )
  97. billingService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
  98. t.Cleanup(billingService.Stop)
  99. concurrencyService := service.NewConcurrencyService(nil)
  100. handler := NewOpenAIGatewayHandler(
  101. gatewayService,
  102. concurrencyService,
  103. billingService,
  104. service.NewAPIKeyService(nil, nil, nil, nil, nil, nil, cfg),
  105. nil,
  106. nil,
  107. nil,
  108. nil,
  109. cfg,
  110. )
  111. handler.maxAccountSwitches = 10
  112. return handler
  113. }
  114. func newOpenAIResponsesFailoverTestContext(t *testing.T, ctx context.Context) (*gin.Context, *httptest.ResponseRecorder) {
  115. t.Helper()
  116. groupID := int64(3131)
  117. body := []byte(`{"model":"gpt-5.1","stream":false,"input":"hello"}`)
  118. req := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  119. if ctx != nil {
  120. req = req.WithContext(ctx)
  121. }
  122. req.Header.Set("Content-Type", "application/json")
  123. rec := httptest.NewRecorder()
  124. c, _ := gin.CreateTestContext(rec)
  125. c.Request = req
  126. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  127. ID: 99,
  128. GroupID: &groupID,
  129. Group: &service.Group{
  130. ID: groupID,
  131. Platform: service.PlatformOpenAI,
  132. },
  133. User: &service.User{ID: 100},
  134. })
  135. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 100, Concurrency: 0})
  136. return c, rec
  137. }
  138. // TestOpenAIGatewayHandlerResponses_FailoverAbortsWhenClientDisconnected 复现
  139. // #4257:客户端在上游请求在途期间断开,上游随后返回可 failover 的 520。
  140. // 期望:不再用已取消的 context 重新选号(不触达账号 2)、不把取消误报成
  141. // 502 账号耗尽、请求按 499 归类。
  142. func TestOpenAIGatewayHandlerResponses_FailoverAbortsWhenClientDisconnected(t *testing.T) {
  143. gin.SetMode(gin.TestMode)
  144. ctx, cancel := context.WithCancel(context.Background())
  145. defer cancel()
  146. upstream := &openAIResponsesFailoverCancelUpstream{onFirstDo: cancel}
  147. handler := newOpenAIResponsesFailoverTestHandler(t, upstream)
  148. c, rec := newOpenAIResponsesFailoverTestContext(t, ctx)
  149. handler.Responses(c)
  150. require.Equal(t, []int64{1}, upstream.calls(), "客户端断开后不应再切换到账号 2")
  151. require.Equal(t, statusClientClosedRequest, c.Writer.Status(), "应按 499 归类")
  152. require.Zero(t, rec.Body.Len(), "不应写入 502 错误响应体")
  153. _, hasFinalUpstreamErr := c.Get(service.OpsUpstreamStatusCodeKey)
  154. require.False(t, hasFinalUpstreamErr, "不应记录 failover 耗尽的上游错误终态")
  155. // 真实发生过的 520 应保留 failover 事件(service 层在返回 failover 错误前记录)
  156. rawEvents, ok := c.Get(service.OpsUpstreamErrorsKey)
  157. require.True(t, ok)
  158. events, ok := rawEvents.([]*service.OpsUpstreamErrorEvent)
  159. require.True(t, ok)
  160. require.Len(t, events, 1)
  161. require.Equal(t, "failover", events[0].Kind)
  162. require.Equal(t, 520, events[0].UpstreamStatusCode)
  163. }
  164. // TestOpenAIGatewayHandlerResponses_FailoverContinuesForConnectedClient 回归
  165. // 守卫:客户端在线时 failover 行为不变——切换到账号 2,两个账号都 520 后按
  166. // 耗尽返回 502。
  167. func TestOpenAIGatewayHandlerResponses_FailoverContinuesForConnectedClient(t *testing.T) {
  168. gin.SetMode(gin.TestMode)
  169. upstream := &openAIResponsesFailoverCancelUpstream{}
  170. handler := newOpenAIResponsesFailoverTestHandler(t, upstream)
  171. c, rec := newOpenAIResponsesFailoverTestContext(t, nil)
  172. handler.Responses(c)
  173. require.Equal(t, []int64{1, 2}, upstream.calls(), "在线客户端应正常切换账号")
  174. require.Equal(t, http.StatusBadGateway, rec.Code)
  175. require.Equal(t, "upstream_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
  176. }