error_passthrough_runtime_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "io"
  8. "net/http"
  9. "net/http/httptest"
  10. "testing"
  11. "github.com/Wei-Shaw/sub2api/internal/model"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/assert"
  14. "github.com/stretchr/testify/require"
  15. )
  16. func TestApplyErrorPassthroughRule_NoBoundService(t *testing.T) {
  17. gin.SetMode(gin.TestMode)
  18. rec := httptest.NewRecorder()
  19. c, _ := gin.CreateTestContext(rec)
  20. status, errType, errMsg, matched := applyErrorPassthroughRule(
  21. c,
  22. PlatformAnthropic,
  23. http.StatusUnprocessableEntity,
  24. []byte(`{"error":{"message":"invalid schema"}}`),
  25. http.StatusBadGateway,
  26. "upstream_error",
  27. "Upstream request failed",
  28. )
  29. assert.False(t, matched)
  30. assert.Equal(t, http.StatusBadGateway, status)
  31. assert.Equal(t, "upstream_error", errType)
  32. assert.Equal(t, "Upstream request failed", errMsg)
  33. }
  34. func TestGatewayHandleErrorResponse_NoRuleKeepsDefault(t *testing.T) {
  35. gin.SetMode(gin.TestMode)
  36. rec := httptest.NewRecorder()
  37. c, _ := gin.CreateTestContext(rec)
  38. svc := &GatewayService{}
  39. respBody := []byte(`{"error":{"message":"Invalid schema for field messages"}}`)
  40. resp := &http.Response{
  41. StatusCode: http.StatusUnprocessableEntity,
  42. Body: io.NopCloser(bytes.NewReader(respBody)),
  43. Header: http.Header{},
  44. }
  45. account := &Account{ID: 11, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
  46. _, err := svc.handleErrorResponse(context.Background(), resp, c, account)
  47. require.Error(t, err)
  48. assert.Equal(t, http.StatusBadGateway, rec.Code)
  49. var payload map[string]any
  50. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  51. errField, ok := payload["error"].(map[string]any)
  52. require.True(t, ok)
  53. assert.Equal(t, "upstream_error", errField["type"])
  54. assert.Equal(t, "Upstream request failed", errField["message"])
  55. }
  56. func TestOpenAIHandleErrorResponse_NoRuleKeepsDefault(t *testing.T) {
  57. gin.SetMode(gin.TestMode)
  58. rec := httptest.NewRecorder()
  59. c, _ := gin.CreateTestContext(rec)
  60. svc := &OpenAIGatewayService{}
  61. respBody := []byte(`{"error":{"message":"Invalid schema for field messages"}}`)
  62. resp := &http.Response{
  63. StatusCode: http.StatusUnprocessableEntity,
  64. Body: io.NopCloser(bytes.NewReader(respBody)),
  65. Header: http.Header{},
  66. }
  67. account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  68. _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil)
  69. require.Error(t, err)
  70. assert.Equal(t, http.StatusBadGateway, rec.Code)
  71. var payload map[string]any
  72. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  73. errField, ok := payload["error"].(map[string]any)
  74. require.True(t, ok)
  75. assert.Equal(t, "upstream_error", errField["type"])
  76. assert.Equal(t, "Upstream request failed", errField["message"])
  77. }
  78. func TestOpenAIHandleErrorResponse_ContextWindow502KeepsMessageWithoutFailover(t *testing.T) {
  79. gin.SetMode(gin.TestMode)
  80. rec := httptest.NewRecorder()
  81. c, _ := gin.CreateTestContext(rec)
  82. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  83. svc := &OpenAIGatewayService{}
  84. respBody := []byte(`{"error":{"message":"Your input exceeds the context window of this model. Please adjust your input and try again.","type":"upstream_error","code":null}}`)
  85. resp := &http.Response{
  86. StatusCode: http.StatusBadGateway,
  87. Body: io.NopCloser(bytes.NewReader(respBody)),
  88. Header: http.Header{},
  89. }
  90. account := &Account{ID: 14, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  91. _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil)
  92. require.Error(t, err)
  93. var failoverErr *UpstreamFailoverError
  94. require.False(t, errors.As(err, &failoverErr))
  95. assert.Equal(t, http.StatusBadGateway, rec.Code)
  96. var payload map[string]any
  97. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  98. errField, ok := payload["error"].(map[string]any)
  99. require.True(t, ok)
  100. assert.Equal(t, "upstream_error", errField["type"])
  101. assert.Equal(t, "Your input exceeds the context window of this model. Please adjust your input and try again.", errField["message"])
  102. }
  103. func TestGeminiWriteGeminiMappedError_NoRuleKeepsDefault(t *testing.T) {
  104. gin.SetMode(gin.TestMode)
  105. rec := httptest.NewRecorder()
  106. c, _ := gin.CreateTestContext(rec)
  107. svc := &GeminiMessagesCompatService{}
  108. respBody := []byte(`{"error":{"code":422,"message":"Invalid schema for field messages","status":"INVALID_ARGUMENT"}}`)
  109. account := &Account{ID: 13, Platform: PlatformGemini, Type: AccountTypeAPIKey}
  110. err := svc.writeGeminiMappedError(c, account, http.StatusUnprocessableEntity, "req-2", respBody)
  111. require.Error(t, err)
  112. assert.Equal(t, http.StatusBadRequest, rec.Code)
  113. var payload map[string]any
  114. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  115. errField, ok := payload["error"].(map[string]any)
  116. require.True(t, ok)
  117. assert.Equal(t, "invalid_request_error", errField["type"])
  118. assert.Equal(t, "Upstream request failed", errField["message"])
  119. }
  120. func TestGatewayHandleErrorResponse_AppliesRuleFor422(t *testing.T) {
  121. gin.SetMode(gin.TestMode)
  122. rec := httptest.NewRecorder()
  123. c, _ := gin.CreateTestContext(rec)
  124. ruleSvc := &ErrorPassthroughService{}
  125. ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{newNonFailoverPassthroughRule(http.StatusUnprocessableEntity, "invalid schema", http.StatusTeapot, "上游请求失败")})
  126. BindErrorPassthroughService(c, ruleSvc)
  127. svc := &GatewayService{}
  128. respBody := []byte(`{"error":{"message":"Invalid schema for field messages"}}`)
  129. resp := &http.Response{
  130. StatusCode: http.StatusUnprocessableEntity,
  131. Body: io.NopCloser(bytes.NewReader(respBody)),
  132. Header: http.Header{},
  133. }
  134. account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
  135. _, err := svc.handleErrorResponse(context.Background(), resp, c, account)
  136. require.Error(t, err)
  137. assert.Equal(t, http.StatusTeapot, rec.Code)
  138. var payload map[string]any
  139. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  140. errField, ok := payload["error"].(map[string]any)
  141. require.True(t, ok)
  142. assert.Equal(t, "upstream_error", errField["type"])
  143. assert.Equal(t, "上游请求失败", errField["message"])
  144. }
  145. func TestOpenAIHandleErrorResponse_AppliesRuleFor422(t *testing.T) {
  146. gin.SetMode(gin.TestMode)
  147. rec := httptest.NewRecorder()
  148. c, _ := gin.CreateTestContext(rec)
  149. ruleSvc := &ErrorPassthroughService{}
  150. ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{newNonFailoverPassthroughRule(http.StatusUnprocessableEntity, "invalid schema", http.StatusTeapot, "OpenAI上游失败")})
  151. BindErrorPassthroughService(c, ruleSvc)
  152. svc := &OpenAIGatewayService{}
  153. respBody := []byte(`{"error":{"message":"Invalid schema for field messages"}}`)
  154. resp := &http.Response{
  155. StatusCode: http.StatusUnprocessableEntity,
  156. Body: io.NopCloser(bytes.NewReader(respBody)),
  157. Header: http.Header{},
  158. }
  159. account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  160. _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil)
  161. require.Error(t, err)
  162. assert.Equal(t, http.StatusTeapot, rec.Code)
  163. var payload map[string]any
  164. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  165. errField, ok := payload["error"].(map[string]any)
  166. require.True(t, ok)
  167. assert.Equal(t, "upstream_error", errField["type"])
  168. assert.Equal(t, "OpenAI上游失败", errField["message"])
  169. }
  170. func TestGeminiWriteGeminiMappedError_AppliesRuleFor422(t *testing.T) {
  171. gin.SetMode(gin.TestMode)
  172. rec := httptest.NewRecorder()
  173. c, _ := gin.CreateTestContext(rec)
  174. ruleSvc := &ErrorPassthroughService{}
  175. ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{newNonFailoverPassthroughRule(http.StatusUnprocessableEntity, "invalid schema", http.StatusTeapot, "Gemini上游失败")})
  176. BindErrorPassthroughService(c, ruleSvc)
  177. svc := &GeminiMessagesCompatService{}
  178. respBody := []byte(`{"error":{"code":422,"message":"Invalid schema for field messages","status":"INVALID_ARGUMENT"}}`)
  179. account := &Account{ID: 3, Platform: PlatformGemini, Type: AccountTypeAPIKey}
  180. err := svc.writeGeminiMappedError(c, account, http.StatusUnprocessableEntity, "req-1", respBody)
  181. require.Error(t, err)
  182. assert.Equal(t, http.StatusTeapot, rec.Code)
  183. var payload map[string]any
  184. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  185. errField, ok := payload["error"].(map[string]any)
  186. require.True(t, ok)
  187. assert.Equal(t, "upstream_error", errField["type"])
  188. assert.Equal(t, "Gemini上游失败", errField["message"])
  189. }
  190. func TestApplyErrorPassthroughRule_SkipMonitoringSetsContextKey(t *testing.T) {
  191. gin.SetMode(gin.TestMode)
  192. rec := httptest.NewRecorder()
  193. c, _ := gin.CreateTestContext(rec)
  194. rule := newNonFailoverPassthroughRule(http.StatusBadRequest, "prompt is too long", http.StatusBadRequest, "上下文超限")
  195. rule.SkipMonitoring = true
  196. ruleSvc := &ErrorPassthroughService{}
  197. ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{rule})
  198. BindErrorPassthroughService(c, ruleSvc)
  199. _, _, _, matched := applyErrorPassthroughRule(
  200. c,
  201. PlatformAnthropic,
  202. http.StatusBadRequest,
  203. []byte(`{"error":{"message":"prompt is too long"}}`),
  204. http.StatusBadGateway,
  205. "upstream_error",
  206. "Upstream request failed",
  207. )
  208. assert.True(t, matched)
  209. v, exists := c.Get(OpsSkipPassthroughKey)
  210. assert.True(t, exists, "OpsSkipPassthroughKey should be set when skip_monitoring=true")
  211. boolVal, ok := v.(bool)
  212. assert.True(t, ok, "value should be bool")
  213. assert.True(t, boolVal)
  214. }
  215. func TestApplyErrorPassthroughRule_NoSkipMonitoringDoesNotSetContextKey(t *testing.T) {
  216. gin.SetMode(gin.TestMode)
  217. rec := httptest.NewRecorder()
  218. c, _ := gin.CreateTestContext(rec)
  219. rule := newNonFailoverPassthroughRule(http.StatusBadRequest, "prompt is too long", http.StatusBadRequest, "上下文超限")
  220. rule.SkipMonitoring = false
  221. ruleSvc := &ErrorPassthroughService{}
  222. ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{rule})
  223. BindErrorPassthroughService(c, ruleSvc)
  224. _, _, _, matched := applyErrorPassthroughRule(
  225. c,
  226. PlatformAnthropic,
  227. http.StatusBadRequest,
  228. []byte(`{"error":{"message":"prompt is too long"}}`),
  229. http.StatusBadGateway,
  230. "upstream_error",
  231. "Upstream request failed",
  232. )
  233. assert.True(t, matched)
  234. _, exists := c.Get(OpsSkipPassthroughKey)
  235. assert.False(t, exists, "OpsSkipPassthroughKey should NOT be set when skip_monitoring=false")
  236. }
  237. // ---- ResponseCommittedKey: service 层写完错误响应后标记,handler 层检查跳过兜底写入 ----
  238. func TestHandleErrorResponse_SetsResponseCommitted(t *testing.T) {
  239. gin.SetMode(gin.TestMode)
  240. rec := httptest.NewRecorder()
  241. c, _ := gin.CreateTestContext(rec)
  242. svc := &GatewayService{}
  243. resp := &http.Response{
  244. StatusCode: http.StatusBadRequest,
  245. Body: io.NopCloser(bytes.NewReader([]byte(`{"error":{"message":"temperature: range: 0..1"}}`))),
  246. Header: http.Header{},
  247. }
  248. account := &Account{ID: 100, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
  249. _, err := svc.handleErrorResponse(context.Background(), resp, c, account)
  250. require.Error(t, err)
  251. assert.True(t, IsResponseCommitted(c), "non-failover error path must mark response committed")
  252. var payload map[string]any
  253. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  254. }
  255. func TestHandleErrorResponse_PassthroughRuleSetsCommitted(t *testing.T) {
  256. gin.SetMode(gin.TestMode)
  257. rec := httptest.NewRecorder()
  258. c, _ := gin.CreateTestContext(rec)
  259. ruleSvc := &ErrorPassthroughService{}
  260. ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{
  261. newNonFailoverPassthroughRule(http.StatusBadRequest, "temperature", http.StatusBadRequest, "参数错误"),
  262. })
  263. BindErrorPassthroughService(c, ruleSvc)
  264. svc := &GatewayService{}
  265. resp := &http.Response{
  266. StatusCode: http.StatusBadRequest,
  267. Body: io.NopCloser(bytes.NewReader([]byte(`{"error":{"message":"temperature: range: 0..1"}}`))),
  268. Header: http.Header{},
  269. }
  270. account := &Account{ID: 200, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
  271. _, err := svc.handleErrorResponse(context.Background(), resp, c, account)
  272. require.Error(t, err)
  273. assert.True(t, IsResponseCommitted(c), "passthrough rule path must mark response committed")
  274. assert.Equal(t, http.StatusBadRequest, rec.Code)
  275. var payload map[string]any
  276. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
  277. errField, ok := payload["error"].(map[string]any)
  278. require.True(t, ok, "payload[\"error\"] should be map[string]any")
  279. assert.Equal(t, "参数错误", errField["message"])
  280. }
  281. func TestOpenAIHandleErrorResponse_SetsResponseCommitted(t *testing.T) {
  282. gin.SetMode(gin.TestMode)
  283. rec := httptest.NewRecorder()
  284. c, _ := gin.CreateTestContext(rec)
  285. svc := &OpenAIGatewayService{}
  286. resp := &http.Response{
  287. StatusCode: http.StatusTooManyRequests,
  288. Body: io.NopCloser(bytes.NewReader([]byte(`{"error":{"message":"rate limit exceeded"}}`))),
  289. Header: http.Header{},
  290. }
  291. account := &Account{ID: 101, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  292. _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil)
  293. require.Error(t, err)
  294. assert.True(t, IsResponseCommitted(c), "OpenAI non-failover path must mark response committed")
  295. }
  296. func TestGeminiWriteGeminiMappedError_SetsResponseCommitted(t *testing.T) {
  297. gin.SetMode(gin.TestMode)
  298. rec := httptest.NewRecorder()
  299. c, _ := gin.CreateTestContext(rec)
  300. svc := &GeminiMessagesCompatService{}
  301. body := []byte(`{"error":{"message":"invalid field"}}`)
  302. account := &Account{ID: 102, Platform: PlatformGemini, Type: AccountTypeAPIKey}
  303. err := svc.writeGeminiMappedError(c, account, http.StatusBadRequest, "req-99", body)
  304. require.Error(t, err)
  305. assert.True(t, IsResponseCommitted(c), "Gemini path must mark response committed")
  306. }
  307. func newNonFailoverPassthroughRule(statusCode int, keyword string, respCode int, customMessage string) *model.ErrorPassthroughRule {
  308. return &model.ErrorPassthroughRule{
  309. ID: 1,
  310. Name: "non-failover-rule",
  311. Enabled: true,
  312. Priority: 1,
  313. ErrorCodes: []int{statusCode},
  314. Keywords: []string{keyword},
  315. MatchMode: model.MatchModeAll,
  316. PassthroughCode: false,
  317. ResponseCode: &respCode,
  318. PassthroughBody: false,
  319. CustomMessage: &customMessage,
  320. }
  321. }