openai_gateway_compat_buffered_read_failover_test.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. package service
  2. import (
  3. "bufio"
  4. "context"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "testing"
  10. "time"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. type openAICompatBufferedReadErrorCloser struct {
  16. err error
  17. }
  18. func (r *openAICompatBufferedReadErrorCloser) Read([]byte) (int, error) { return 0, r.err }
  19. func (r *openAICompatBufferedReadErrorCloser) Close() error { return nil }
  20. func TestChatCompletionsBufferedResponsesReadErrorReturnsFailover(t *testing.T) {
  21. gin.SetMode(gin.TestMode)
  22. readErrors := []struct {
  23. name string
  24. err error
  25. expectedCode string
  26. }{
  27. {name: "unexpected_eof", err: io.ErrUnexpectedEOF, expectedCode: OpenAIUpstreamStreamReadErrorCode},
  28. {name: "http2_reset", err: errors.New("stream error: stream ID 7; INTERNAL_ERROR; received from peer"), expectedCode: OpenAIUpstreamHTTP2StreamErrorCode},
  29. }
  30. for _, readError := range readErrors {
  31. t.Run(readError.name, func(t *testing.T) {
  32. rec := httptest.NewRecorder()
  33. c, _ := gin.CreateTestContext(rec)
  34. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
  35. resp := &http.Response{
  36. StatusCode: http.StatusOK,
  37. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "X-Request-Id": []string{"upstream-rid"}},
  38. Body: &openAICompatBufferedReadErrorCloser{err: readError.err},
  39. }
  40. account := &Account{ID: 40, Name: "openai-oauth", Platform: PlatformOpenAI}
  41. result, err := (&OpenAIGatewayService{}).handleChatBufferedStreamingResponse(
  42. resp, c, account, "gpt-5.6-sol", "gpt-5.6-sol", "gpt-5.6-sol", time.Now(),
  43. )
  44. require.Error(t, err)
  45. require.Nil(t, result)
  46. var failoverErr *UpstreamFailoverError
  47. require.ErrorAs(t, err, &failoverErr)
  48. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  49. require.Equal(t, "upstream-rid", failoverErr.ResponseHeaders.Get("x-request-id"))
  50. require.Equal(t, readError.expectedCode, gjson.GetBytes(failoverErr.ResponseBody, "error.code").String())
  51. require.Empty(t, rec.Body.String())
  52. require.False(t, c.Writer.Written())
  53. })
  54. }
  55. }
  56. func TestChatCompletionsBufferedResponsesReadErrorDoesNotFailoverAfterClientCancel(t *testing.T) {
  57. gin.SetMode(gin.TestMode)
  58. rec := httptest.NewRecorder()
  59. c, _ := gin.CreateTestContext(rec)
  60. requestContext, cancel := context.WithCancel(context.Background())
  61. cancel()
  62. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(requestContext)
  63. resp := &http.Response{
  64. StatusCode: http.StatusOK,
  65. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  66. Body: &openAICompatBufferedReadErrorCloser{err: io.ErrUnexpectedEOF},
  67. }
  68. result, err := (&OpenAIGatewayService{}).handleChatBufferedStreamingResponse(
  69. resp,
  70. c,
  71. &Account{ID: 40, Name: "openai-oauth", Platform: PlatformOpenAI},
  72. "gpt-5.6-sol",
  73. "gpt-5.6-sol",
  74. "gpt-5.6-sol",
  75. time.Now(),
  76. )
  77. require.Error(t, err)
  78. require.Nil(t, result)
  79. var failoverErr *UpstreamFailoverError
  80. require.NotErrorAs(t, err, &failoverErr)
  81. require.Empty(t, rec.Body.String())
  82. }
  83. func TestChatCompletionsBufferedResponsesOversizedLineDoesNotFailover(t *testing.T) {
  84. gin.SetMode(gin.TestMode)
  85. rec := httptest.NewRecorder()
  86. c, _ := gin.CreateTestContext(rec)
  87. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
  88. resp := &http.Response{
  89. StatusCode: http.StatusOK,
  90. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  91. Body: &openAICompatBufferedReadErrorCloser{err: bufio.ErrTooLong},
  92. }
  93. result, err := (&OpenAIGatewayService{}).handleChatBufferedStreamingResponse(
  94. resp,
  95. c,
  96. &Account{ID: 40, Name: "openai-oauth", Platform: PlatformOpenAI},
  97. "gpt-5.6-sol",
  98. "gpt-5.6-sol",
  99. "gpt-5.6-sol",
  100. time.Now(),
  101. )
  102. require.ErrorIs(t, err, bufio.ErrTooLong)
  103. require.Nil(t, result)
  104. var failoverErr *UpstreamFailoverError
  105. require.NotErrorAs(t, err, &failoverErr)
  106. }
  107. func TestAnthropicBufferedResponsesReadErrorKeepsExistingBehavior(t *testing.T) {
  108. gin.SetMode(gin.TestMode)
  109. rec := httptest.NewRecorder()
  110. c, _ := gin.CreateTestContext(rec)
  111. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  112. resp := &http.Response{
  113. StatusCode: http.StatusOK,
  114. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  115. Body: &openAICompatBufferedReadErrorCloser{err: io.ErrUnexpectedEOF},
  116. }
  117. result, err := (&OpenAIGatewayService{}).handleAnthropicBufferedStreamingResponse(
  118. resp,
  119. c,
  120. &Account{ID: 40, Name: "openai-oauth", Platform: PlatformOpenAI},
  121. "gpt-5.6-sol",
  122. "gpt-5.6-sol",
  123. "gpt-5.6-sol",
  124. time.Now(),
  125. )
  126. require.ErrorIs(t, err, io.ErrUnexpectedEOF)
  127. require.Equal(t, io.ErrUnexpectedEOF, err, "Messages 路径必须保持原始读取错误,不引入 Chat failover 包装")
  128. require.Nil(t, result)
  129. var failoverErr *UpstreamFailoverError
  130. require.NotErrorAs(t, err, &failoverErr)
  131. }