gateway_handler_stream_failover_test.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. package handler
  2. import (
  3. "net/http"
  4. "net/http/httptest"
  5. "strings"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/service"
  8. "github.com/gin-gonic/gin"
  9. "github.com/stretchr/testify/assert"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // partialMessageStartSSE 模拟 handleStreamingResponse 已写入的首批 SSE 事件。
  13. const partialMessageStartSSE = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_01\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4-5\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n" +
  14. "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n"
  15. // TestStreamWrittenGuard_MessagesPath_AbortFailoverOnSSEContentWritten 验证:
  16. // 当 Forward 在返回 UpstreamFailoverError 前已向客户端写入 SSE 内容时,
  17. // 故障转移保护逻辑必须终止循环并发送 SSE 错误事件,而不是进行下一次 Forward。
  18. // 具体验证:
  19. // 1. c.Writer.Size() 检测条件正确触发(字节数已增加)
  20. // 2. handleFailoverExhausted 以 streamStarted=true 调用后,响应体以 SSE 错误事件结尾
  21. // 3. 响应体中只出现一个 message_start,不存在第二个(防止流拼接腐化)
  22. func TestStreamWrittenGuard_MessagesPath_AbortFailoverOnSSEContentWritten(t *testing.T) {
  23. gin.SetMode(gin.TestMode)
  24. w := httptest.NewRecorder()
  25. c, _ := gin.CreateTestContext(w)
  26. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  27. // 步骤 1:记录 Forward 前的 writer size(模拟 writerSizeBeforeForward := c.Writer.Size())
  28. sizeBeforeForward := c.Writer.Size()
  29. require.Equal(t, -1, sizeBeforeForward, "gin writer 初始 Size 应为 -1(未写入任何字节)")
  30. // 步骤 2:模拟 Forward 已向客户端写入部分 SSE 内容(message_start + content_block_start)
  31. _, err := c.Writer.Write([]byte(partialMessageStartSSE))
  32. require.NoError(t, err)
  33. // 步骤 3:验证守卫条件成立(c.Writer.Size() != sizeBeforeForward)
  34. require.NotEqual(t, sizeBeforeForward, c.Writer.Size(),
  35. "写入 SSE 内容后 writer size 必须增加,守卫条件应为 true")
  36. // 步骤 4:模拟 UpstreamFailoverError(上游在流中途返回 403)
  37. failoverErr := &service.UpstreamFailoverError{
  38. StatusCode: http.StatusForbidden,
  39. ResponseBody: []byte(`{"error":{"type":"permission_error","message":"forbidden"}}`),
  40. }
  41. // 步骤 5:守卫触发 → 调用 handleFailoverExhausted,streamStarted=true
  42. h := &GatewayHandler{}
  43. h.handleFailoverExhausted(c, failoverErr, service.PlatformAnthropic, true)
  44. body := w.Body.String()
  45. // 断言 A:响应体中包含最初写入的 message_start SSE 事件行
  46. require.Contains(t, body, "event: message_start", "响应体应包含已写入的 message_start SSE 事件")
  47. // 断言 B:响应体以 SSE 错误事件结尾(data: {"type":"error",...}\n\n)
  48. require.True(t, strings.HasSuffix(strings.TrimRight(body, "\n"), "}"),
  49. "响应体应以 JSON 对象结尾(SSE error event 的 data 字段)")
  50. require.Contains(t, body, `"type":"error"`, "响应体末尾必须包含 SSE 错误事件")
  51. // 断言 C:SSE event 行 "event: message_start" 只出现一次(防止双 message_start 拼接腐化)
  52. firstIdx := strings.Index(body, "event: message_start")
  53. lastIdx := strings.LastIndex(body, "event: message_start")
  54. assert.Equal(t, firstIdx, lastIdx,
  55. "响应体中 'event: message_start' 必须只出现一次,不得因 failover 拼接导致两次")
  56. }
  57. // TestStreamWrittenGuard_GeminiPath_AbortFailoverOnSSEContentWritten 与上述测试相同,
  58. // 验证 Gemini 路径使用 service.PlatformGemini(而非 account.Platform)时行为一致。
  59. func TestStreamWrittenGuard_GeminiPath_AbortFailoverOnSSEContentWritten(t *testing.T) {
  60. gin.SetMode(gin.TestMode)
  61. w := httptest.NewRecorder()
  62. c, _ := gin.CreateTestContext(w)
  63. c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.0-flash:streamGenerateContent", nil)
  64. sizeBeforeForward := c.Writer.Size()
  65. _, err := c.Writer.Write([]byte(partialMessageStartSSE))
  66. require.NoError(t, err)
  67. require.NotEqual(t, sizeBeforeForward, c.Writer.Size())
  68. failoverErr := &service.UpstreamFailoverError{
  69. StatusCode: http.StatusForbidden,
  70. }
  71. h := &GatewayHandler{}
  72. h.handleFailoverExhausted(c, failoverErr, service.PlatformGemini, true)
  73. body := w.Body.String()
  74. require.Contains(t, body, "event: message_start")
  75. require.Contains(t, body, `"type":"error"`)
  76. firstIdx := strings.Index(body, "event: message_start")
  77. lastIdx := strings.LastIndex(body, "event: message_start")
  78. assert.Equal(t, firstIdx, lastIdx, "Gemini 路径不得出现双 message_start")
  79. }
  80. // TestStreamWrittenGuard_NoByteWritten_GuardNotTriggered 验证反向场景:
  81. // 当 Forward 返回 UpstreamFailoverError 时若未向客户端写入任何 SSE 内容,
  82. // 守卫条件(c.Writer.Size() != sizeBeforeForward)为 false,不应中止 failover。
  83. func TestStreamWrittenGuard_NoByteWritten_GuardNotTriggered(t *testing.T) {
  84. gin.SetMode(gin.TestMode)
  85. w := httptest.NewRecorder()
  86. c, _ := gin.CreateTestContext(w)
  87. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  88. // 模拟 writerSizeBeforeForward:初始为 -1
  89. sizeBeforeForward := c.Writer.Size()
  90. // Forward 未写入任何字节直接返回错误(例如 401 发生在连接建立前)
  91. // c.Writer.Size() 仍为 -1
  92. // 守卫条件:sizeBeforeForward == c.Writer.Size() → 不触发
  93. guardTriggered := c.Writer.Size() != sizeBeforeForward
  94. require.False(t, guardTriggered,
  95. "未写入任何字节时,守卫条件必须为 false,应允许正常 failover 继续")
  96. }