openai_ws_fallback_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. coderws "github.com/coder/websocket"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. )
  14. func TestClassifyOpenAIWSAcquireError(t *testing.T) {
  15. t.Run("dial_426_upgrade_required", func(t *testing.T) {
  16. err := &openAIWSDialError{StatusCode: 426, Err: errors.New("upgrade required")}
  17. require.Equal(t, "upgrade_required", classifyOpenAIWSAcquireError(err))
  18. })
  19. t.Run("queue_full", func(t *testing.T) {
  20. require.Equal(t, "conn_queue_full", classifyOpenAIWSAcquireError(errOpenAIWSConnQueueFull))
  21. })
  22. t.Run("preferred_conn_unavailable", func(t *testing.T) {
  23. require.Equal(t, "preferred_conn_unavailable", classifyOpenAIWSAcquireError(errOpenAIWSPreferredConnUnavailable))
  24. })
  25. t.Run("acquire_timeout", func(t *testing.T) {
  26. require.Equal(t, "acquire_timeout", classifyOpenAIWSAcquireError(context.DeadlineExceeded))
  27. })
  28. t.Run("auth_failed_401", func(t *testing.T) {
  29. err := &openAIWSDialError{StatusCode: 401, Err: errors.New("unauthorized")}
  30. require.Equal(t, "auth_failed", classifyOpenAIWSAcquireError(err))
  31. })
  32. t.Run("upstream_rate_limited", func(t *testing.T) {
  33. err := &openAIWSDialError{StatusCode: 429, Err: errors.New("rate limited")}
  34. require.Equal(t, "upstream_rate_limited", classifyOpenAIWSAcquireError(err))
  35. })
  36. t.Run("upstream_5xx", func(t *testing.T) {
  37. err := &openAIWSDialError{StatusCode: 502, Err: errors.New("bad gateway")}
  38. require.Equal(t, "upstream_5xx", classifyOpenAIWSAcquireError(err))
  39. })
  40. t.Run("dial_failed_other_status", func(t *testing.T) {
  41. err := &openAIWSDialError{StatusCode: 418, Err: errors.New("teapot")}
  42. require.Equal(t, "dial_failed", classifyOpenAIWSAcquireError(err))
  43. })
  44. t.Run("other", func(t *testing.T) {
  45. require.Equal(t, "acquire_conn", classifyOpenAIWSAcquireError(errors.New("x")))
  46. })
  47. t.Run("nil", func(t *testing.T) {
  48. require.Equal(t, "acquire_conn", classifyOpenAIWSAcquireError(nil))
  49. })
  50. }
  51. func TestClassifyOpenAIWSDialError(t *testing.T) {
  52. t.Run("handshake_not_finished", func(t *testing.T) {
  53. err := &openAIWSDialError{
  54. StatusCode: http.StatusBadGateway,
  55. Err: errors.New("WebSocket protocol error: Handshake not finished"),
  56. }
  57. require.Equal(t, "handshake_not_finished", classifyOpenAIWSDialError(err))
  58. })
  59. t.Run("context_deadline", func(t *testing.T) {
  60. err := &openAIWSDialError{
  61. StatusCode: 0,
  62. Err: context.DeadlineExceeded,
  63. }
  64. require.Equal(t, "ctx_deadline_exceeded", classifyOpenAIWSDialError(err))
  65. })
  66. }
  67. func TestSummarizeOpenAIWSDialError(t *testing.T) {
  68. err := &openAIWSDialError{
  69. StatusCode: http.StatusBadGateway,
  70. ResponseHeaders: http.Header{
  71. "Server": []string{"cloudflare"},
  72. "Via": []string{"1.1 example"},
  73. "Cf-Ray": []string{"abcd1234"},
  74. "X-Request-Id": []string{"req_123"},
  75. },
  76. Err: errors.New("WebSocket protocol error: Handshake not finished"),
  77. }
  78. status, class, closeStatus, closeReason, server, via, cfRay, reqID := summarizeOpenAIWSDialError(err)
  79. require.Equal(t, http.StatusBadGateway, status)
  80. require.Equal(t, "handshake_not_finished", class)
  81. require.Equal(t, "-", closeStatus)
  82. require.Equal(t, "-", closeReason)
  83. require.Equal(t, "cloudflare", server)
  84. require.Equal(t, "1.1 example", via)
  85. require.Equal(t, "abcd1234", cfRay)
  86. require.Equal(t, "req_123", reqID)
  87. }
  88. func TestClassifyOpenAIWSErrorEvent(t *testing.T) {
  89. reason, recoverable := classifyOpenAIWSErrorEvent([]byte(`{"type":"error","error":{"code":"upgrade_required","message":"Upgrade required"}}`))
  90. require.Equal(t, "upgrade_required", reason)
  91. require.True(t, recoverable)
  92. reason, recoverable = classifyOpenAIWSErrorEvent([]byte(`{"type":"error","error":{"code":"previous_response_not_found","message":"not found"}}`))
  93. require.Equal(t, "previous_response_not_found", reason)
  94. require.True(t, recoverable)
  95. }
  96. func TestClassifyOpenAIWSReconnectReason(t *testing.T) {
  97. reason, retryable := classifyOpenAIWSReconnectReason(wrapOpenAIWSFallback("policy_violation", errors.New("policy")))
  98. require.Equal(t, "policy_violation", reason)
  99. require.False(t, retryable)
  100. reason, retryable = classifyOpenAIWSReconnectReason(wrapOpenAIWSFallback("read_event", errors.New("io")))
  101. require.Equal(t, "read_event", reason)
  102. require.True(t, retryable)
  103. }
  104. func TestOpenAIWSErrorHTTPStatus(t *testing.T) {
  105. require.Equal(t, http.StatusBadRequest, openAIWSErrorHTTPStatus([]byte(`{"type":"error","error":{"type":"invalid_request_error","code":"invalid_request","message":"invalid input"}}`)))
  106. require.Equal(t, http.StatusUnauthorized, openAIWSErrorHTTPStatus([]byte(`{"type":"error","error":{"type":"authentication_error","code":"invalid_api_key","message":"auth failed"}}`)))
  107. require.Equal(t, http.StatusForbidden, openAIWSErrorHTTPStatus([]byte(`{"type":"error","error":{"type":"permission_error","code":"forbidden","message":"forbidden"}}`)))
  108. require.Equal(t, http.StatusTooManyRequests, openAIWSErrorHTTPStatus([]byte(`{"type":"error","error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"rate limited"}}`)))
  109. require.Equal(t, http.StatusBadGateway, openAIWSErrorHTTPStatus([]byte(`{"type":"error","error":{"type":"server_error","code":"server_error","message":"server"}}`)))
  110. }
  111. func TestResolveOpenAIWSFallbackErrorResponse(t *testing.T) {
  112. t.Run("previous_response_not_found", func(t *testing.T) {
  113. statusCode, errType, clientMessage, upstreamMessage, ok := resolveOpenAIWSFallbackErrorResponse(
  114. wrapOpenAIWSFallback("previous_response_not_found", errors.New("previous response not found")),
  115. )
  116. require.True(t, ok)
  117. require.Equal(t, http.StatusBadRequest, statusCode)
  118. require.Equal(t, "invalid_request_error", errType)
  119. require.Equal(t, "previous response not found", clientMessage)
  120. require.Equal(t, "previous response not found", upstreamMessage)
  121. })
  122. t.Run("auth_failed_uses_dial_status", func(t *testing.T) {
  123. statusCode, errType, clientMessage, upstreamMessage, ok := resolveOpenAIWSFallbackErrorResponse(
  124. wrapOpenAIWSFallback("auth_failed", &openAIWSDialError{
  125. StatusCode: http.StatusForbidden,
  126. Err: errors.New("forbidden"),
  127. }),
  128. )
  129. require.True(t, ok)
  130. require.Equal(t, http.StatusForbidden, statusCode)
  131. require.Equal(t, "upstream_error", errType)
  132. require.Equal(t, "forbidden", clientMessage)
  133. require.Equal(t, "forbidden", upstreamMessage)
  134. })
  135. t.Run("non_fallback_error_not_resolved", func(t *testing.T) {
  136. _, _, _, _, ok := resolveOpenAIWSFallbackErrorResponse(errors.New("plain error"))
  137. require.False(t, ok)
  138. })
  139. }
  140. func TestWriteOpenAIWSFallbackErrorResponseMarksDefaultClientRouteUnknown(t *testing.T) {
  141. gin.SetMode(gin.TestMode)
  142. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  143. svc := &OpenAIGatewayService{}
  144. account := &Account{ID: 42, Name: "default-client", Platform: PlatformOpenAI}
  145. written := svc.writeOpenAIWSFallbackErrorResponse(
  146. c,
  147. account,
  148. wrapOpenAIWSFallback("auth_failed", errors.New("unauthorized")),
  149. )
  150. require.True(t, written)
  151. raw, ok := c.Get(OpsUpstreamErrorsKey)
  152. require.True(t, ok)
  153. events, ok := raw.([]*OpsUpstreamErrorEvent)
  154. require.True(t, ok)
  155. require.Len(t, events, 1)
  156. require.Nil(t, events[0].ProxyID)
  157. require.Equal(t, opsProxyNameUnknown, events[0].ProxyName)
  158. }
  159. func TestWriteOpenAIWSFallbackErrorResponseKeepsManagedProxy(t *testing.T) {
  160. gin.SetMode(gin.TestMode)
  161. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  162. svc := &OpenAIGatewayService{}
  163. proxyID := int64(10060)
  164. account := &Account{ID: 42, Name: "proxied", Platform: PlatformOpenAI, ProxyID: &proxyID, Proxy: &Proxy{ID: proxyID, Name: "ws-proxy"}}
  165. require.True(t, svc.writeOpenAIWSFallbackErrorResponse(c, account, wrapOpenAIWSFallback("auth_failed", errors.New("unauthorized"))))
  166. raw, ok := c.Get(OpsUpstreamErrorsKey)
  167. require.True(t, ok)
  168. events, ok := raw.([]*OpsUpstreamErrorEvent)
  169. require.True(t, ok)
  170. require.Len(t, events, 1)
  171. require.NotNil(t, events[0].ProxyID)
  172. require.Equal(t, proxyID, *events[0].ProxyID)
  173. require.Equal(t, "ws-proxy", events[0].ProxyName)
  174. }
  175. func TestOpsUpstreamWSProxyAttributionNeverReportsDirect(t *testing.T) {
  176. proxyID := int64(7)
  177. id, name := opsUpstreamWSProxyAttribution(nil)
  178. require.Nil(t, id)
  179. require.Equal(t, opsProxyNameUnknown, name)
  180. id, name = opsUpstreamWSProxyAttribution(&Account{})
  181. require.Nil(t, id)
  182. require.Equal(t, opsProxyNameUnknown, name, "no managed proxy => http.DefaultClient => unknown, never direct")
  183. id, name = opsUpstreamWSProxyAttribution(&Account{ProxyID: &proxyID, Proxy: &Proxy{ID: proxyID, Name: "ws-proxy"}})
  184. require.NotNil(t, id)
  185. require.Equal(t, proxyID, *id)
  186. require.Equal(t, "ws-proxy", name)
  187. }
  188. func TestOpenAIWSFallbackCooling(t *testing.T) {
  189. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  190. svc.cfg.Gateway.OpenAIWS.FallbackCooldownSeconds = 1
  191. require.False(t, svc.isOpenAIWSFallbackCooling(1))
  192. svc.markOpenAIWSFallbackCooling(1, "upgrade_required")
  193. require.True(t, svc.isOpenAIWSFallbackCooling(1))
  194. svc.clearOpenAIWSFallbackCooling(1)
  195. require.False(t, svc.isOpenAIWSFallbackCooling(1))
  196. svc.markOpenAIWSFallbackCooling(2, "x")
  197. time.Sleep(1200 * time.Millisecond)
  198. require.False(t, svc.isOpenAIWSFallbackCooling(2))
  199. }
  200. func TestOpenAIWSRetryBackoff(t *testing.T) {
  201. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  202. svc.cfg.Gateway.OpenAIWS.RetryBackoffInitialMS = 100
  203. svc.cfg.Gateway.OpenAIWS.RetryBackoffMaxMS = 400
  204. svc.cfg.Gateway.OpenAIWS.RetryJitterRatio = 0
  205. require.Equal(t, time.Duration(100)*time.Millisecond, svc.openAIWSRetryBackoff(1))
  206. require.Equal(t, time.Duration(200)*time.Millisecond, svc.openAIWSRetryBackoff(2))
  207. require.Equal(t, time.Duration(400)*time.Millisecond, svc.openAIWSRetryBackoff(3))
  208. require.Equal(t, time.Duration(400)*time.Millisecond, svc.openAIWSRetryBackoff(4))
  209. }
  210. func TestOpenAIWSRetryTotalBudget(t *testing.T) {
  211. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  212. svc.cfg.Gateway.OpenAIWS.RetryTotalBudgetMS = 1200
  213. require.Equal(t, 1200*time.Millisecond, svc.openAIWSRetryTotalBudget())
  214. svc.cfg.Gateway.OpenAIWS.RetryTotalBudgetMS = 0
  215. require.Equal(t, time.Duration(0), svc.openAIWSRetryTotalBudget())
  216. }
  217. func TestClassifyOpenAIWSReadFallbackReason(t *testing.T) {
  218. require.Equal(t, "policy_violation", classifyOpenAIWSReadFallbackReason(coderws.CloseError{Code: coderws.StatusPolicyViolation}))
  219. require.Equal(t, "message_too_big", classifyOpenAIWSReadFallbackReason(coderws.CloseError{Code: coderws.StatusMessageTooBig}))
  220. require.Equal(t, "read_event", classifyOpenAIWSReadFallbackReason(errors.New("io")))
  221. }
  222. func TestOpenAIWSStoreDisabledConnMode(t *testing.T) {
  223. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  224. svc.cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn = true
  225. require.Equal(t, openAIWSStoreDisabledConnModeStrict, svc.openAIWSStoreDisabledConnMode())
  226. svc.cfg.Gateway.OpenAIWS.StoreDisabledConnMode = "adaptive"
  227. require.Equal(t, openAIWSStoreDisabledConnModeAdaptive, svc.openAIWSStoreDisabledConnMode())
  228. svc.cfg.Gateway.OpenAIWS.StoreDisabledConnMode = ""
  229. svc.cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn = false
  230. require.Equal(t, openAIWSStoreDisabledConnModeOff, svc.openAIWSStoreDisabledConnMode())
  231. }
  232. func TestShouldForceNewConnOnStoreDisabled(t *testing.T) {
  233. require.True(t, shouldForceNewConnOnStoreDisabled(openAIWSStoreDisabledConnModeStrict, ""))
  234. require.False(t, shouldForceNewConnOnStoreDisabled(openAIWSStoreDisabledConnModeOff, "policy_violation"))
  235. require.True(t, shouldForceNewConnOnStoreDisabled(openAIWSStoreDisabledConnModeAdaptive, "policy_violation"))
  236. require.True(t, shouldForceNewConnOnStoreDisabled(openAIWSStoreDisabledConnModeAdaptive, "prewarm_message_too_big"))
  237. require.False(t, shouldForceNewConnOnStoreDisabled(openAIWSStoreDisabledConnModeAdaptive, "read_event"))
  238. }
  239. func TestOpenAIWSRetryMetricsSnapshot(t *testing.T) {
  240. svc := &OpenAIGatewayService{}
  241. svc.recordOpenAIWSRetryAttempt(150 * time.Millisecond)
  242. svc.recordOpenAIWSRetryAttempt(0)
  243. svc.recordOpenAIWSRetryExhausted()
  244. svc.recordOpenAIWSNonRetryableFastFallback()
  245. snapshot := svc.SnapshotOpenAIWSRetryMetrics()
  246. require.Equal(t, int64(2), snapshot.RetryAttemptsTotal)
  247. require.Equal(t, int64(150), snapshot.RetryBackoffMsTotal)
  248. require.Equal(t, int64(1), snapshot.RetryExhaustedTotal)
  249. require.Equal(t, int64(1), snapshot.NonRetryableFastFallbackTotal)
  250. }
  251. func TestShouldLogOpenAIWSPayloadSchema(t *testing.T) {
  252. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  253. svc.cfg.Gateway.OpenAIWS.PayloadLogSampleRate = 0
  254. require.True(t, svc.shouldLogOpenAIWSPayloadSchema(1), "首次尝试应始终记录 payload_schema")
  255. require.False(t, svc.shouldLogOpenAIWSPayloadSchema(2))
  256. svc.cfg.Gateway.OpenAIWS.PayloadLogSampleRate = 1
  257. require.True(t, svc.shouldLogOpenAIWSPayloadSchema(2))
  258. }