image_concurrency_limiter_test.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. package handler
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "strings"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. "github.com/tidwall/gjson"
  15. )
  16. func TestImageConcurrencyLimiter_DefaultDisabledAllowsRequests(t *testing.T) {
  17. limiter := &imageConcurrencyLimiter{}
  18. release, acquired := limiter.TryAcquire(false, 1)
  19. require.True(t, acquired)
  20. require.Nil(t, release)
  21. }
  22. func TestImageConcurrencyLimiter_RejectsWhenLimitReachedAndAllowsAfterRelease(t *testing.T) {
  23. limiter := &imageConcurrencyLimiter{}
  24. release, acquired := limiter.TryAcquire(true, 1)
  25. require.True(t, acquired)
  26. require.NotNil(t, release)
  27. secondRelease, secondAcquired := limiter.TryAcquire(true, 1)
  28. require.False(t, secondAcquired)
  29. require.Nil(t, secondRelease)
  30. release()
  31. thirdRelease, thirdAcquired := limiter.TryAcquire(true, 1)
  32. require.True(t, thirdAcquired)
  33. require.NotNil(t, thirdRelease)
  34. thirdRelease()
  35. }
  36. func TestImageConcurrencyLimiter_WaitsUntilSlotReleased(t *testing.T) {
  37. limiter := &imageConcurrencyLimiter{}
  38. release, acquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1)
  39. require.True(t, acquired)
  40. require.NotNil(t, release)
  41. acquiredCh := make(chan func(), 1)
  42. go func() {
  43. waitRelease, waitAcquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1)
  44. require.True(t, waitAcquired)
  45. acquiredCh <- waitRelease
  46. }()
  47. time.Sleep(20 * time.Millisecond)
  48. release()
  49. select {
  50. case waitRelease := <-acquiredCh:
  51. require.NotNil(t, waitRelease)
  52. waitRelease()
  53. case <-time.After(time.Second):
  54. t.Fatal("timed out waiting for image concurrency slot")
  55. }
  56. }
  57. func TestImageConcurrencyLimiter_WaitTimesOut(t *testing.T) {
  58. limiter := &imageConcurrencyLimiter{}
  59. release, acquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1)
  60. require.True(t, acquired)
  61. require.NotNil(t, release)
  62. defer release()
  63. waitRelease, waitAcquired := limiter.Acquire(context.Background(), true, 1, true, 10*time.Millisecond, 1)
  64. require.False(t, waitAcquired)
  65. require.Nil(t, waitRelease)
  66. }
  67. func TestImageConcurrencyLimiter_MaxWaitingRequestsRejectsOverflow(t *testing.T) {
  68. limiter := &imageConcurrencyLimiter{}
  69. release, acquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1)
  70. require.True(t, acquired)
  71. require.NotNil(t, release)
  72. defer release()
  73. waitingStarted := make(chan struct{})
  74. waitingDone := make(chan struct{})
  75. go func() {
  76. close(waitingStarted)
  77. waitRelease, waitAcquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1)
  78. if waitAcquired && waitRelease != nil {
  79. waitRelease()
  80. }
  81. close(waitingDone)
  82. }()
  83. <-waitingStarted
  84. time.Sleep(20 * time.Millisecond)
  85. overflowRelease, overflowAcquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1)
  86. require.False(t, overflowAcquired)
  87. require.Nil(t, overflowRelease)
  88. release()
  89. <-waitingDone
  90. }
  91. func TestOpenAIGatewayHandlerAcquireImageGenerationSlot_Returns429WhenFull(t *testing.T) {
  92. gin.SetMode(gin.TestMode)
  93. rec := httptest.NewRecorder()
  94. c, _ := gin.CreateTestContext(rec)
  95. c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
  96. h := &OpenAIGatewayHandler{
  97. cfg: &config.Config{
  98. Gateway: config.GatewayConfig{
  99. ImageConcurrency: config.ImageConcurrencyConfig{
  100. Enabled: true,
  101. MaxConcurrentRequests: 1,
  102. OverflowMode: config.ImageConcurrencyOverflowModeReject,
  103. },
  104. },
  105. },
  106. imageLimiter: &imageConcurrencyLimiter{},
  107. }
  108. release, acquired := h.acquireImageGenerationSlot(c, false)
  109. require.True(t, acquired)
  110. require.NotNil(t, release)
  111. defer release()
  112. blockedRelease, blocked := h.acquireImageGenerationSlot(c, false)
  113. require.False(t, blocked)
  114. require.Nil(t, blockedRelease)
  115. require.Equal(t, http.StatusTooManyRequests, rec.Code)
  116. require.Equal(t, "rate_limit_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
  117. require.Contains(t, rec.Body.String(), "Image generation concurrency limit exceeded")
  118. }
  119. func TestOpenAIGatewayHandlerResponses_ImageIntentRejectedByImageConcurrency(t *testing.T) {
  120. gin.SetMode(gin.TestMode)
  121. body := `{"model":"gpt-5.4","input":"draw","tools":[{"type":"image_generation"}]}`
  122. rec := httptest.NewRecorder()
  123. c, _ := gin.CreateTestContext(rec)
  124. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
  125. groupID := int64(1)
  126. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  127. ID: 10,
  128. GroupID: &groupID,
  129. Group: &service.Group{
  130. ID: groupID,
  131. AllowImageGeneration: true,
  132. },
  133. User: &service.User{ID: 20},
  134. })
  135. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 20, Concurrency: 1})
  136. h := &OpenAIGatewayHandler{
  137. gatewayService: &service.OpenAIGatewayService{},
  138. billingCacheService: &service.BillingCacheService{},
  139. apiKeyService: &service.APIKeyService{},
  140. concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService(&helperConcurrencyCacheStub{userSeq: []bool{true}})},
  141. errorPassthroughService: nil,
  142. cfg: &config.Config{Gateway: config.GatewayConfig{ImageConcurrency: config.ImageConcurrencyConfig{
  143. Enabled: true,
  144. MaxConcurrentRequests: 1,
  145. OverflowMode: config.ImageConcurrencyOverflowModeReject,
  146. }}},
  147. imageLimiter: &imageConcurrencyLimiter{},
  148. }
  149. release, acquired := h.acquireImageGenerationSlot(c, false)
  150. require.True(t, acquired)
  151. require.NotNil(t, release)
  152. defer release()
  153. rec.Body.Reset()
  154. rec.Code = 0
  155. h.Responses(c)
  156. require.Equal(t, http.StatusTooManyRequests, rec.Code)
  157. require.Equal(t, "rate_limit_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
  158. require.Contains(t, rec.Body.String(), "Image generation concurrency limit exceeded")
  159. }
  160. func TestOpenAIGatewayHandlerResponses_TextOnlyNotRejectedByImageConcurrency(t *testing.T) {
  161. gin.SetMode(gin.TestMode)
  162. body := `{"model":"gpt-5.4","input":"write code"}`
  163. rec := httptest.NewRecorder()
  164. c, _ := gin.CreateTestContext(rec)
  165. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
  166. groupID := int64(1)
  167. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  168. ID: 10,
  169. GroupID: &groupID,
  170. Group: &service.Group{
  171. ID: groupID,
  172. AllowImageGeneration: true,
  173. },
  174. User: &service.User{ID: 20},
  175. })
  176. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 20, Concurrency: 1})
  177. h := &OpenAIGatewayHandler{
  178. gatewayService: &service.OpenAIGatewayService{},
  179. billingCacheService: service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil),
  180. apiKeyService: &service.APIKeyService{},
  181. concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService(&helperConcurrencyCacheStub{userSeq: []bool{true}})},
  182. cfg: &config.Config{Gateway: config.GatewayConfig{ImageConcurrency: config.ImageConcurrencyConfig{
  183. Enabled: true,
  184. MaxConcurrentRequests: 1,
  185. OverflowMode: config.ImageConcurrencyOverflowModeReject,
  186. }}},
  187. imageLimiter: &imageConcurrencyLimiter{},
  188. }
  189. release, acquired := h.acquireImageGenerationSlot(c, false)
  190. require.True(t, acquired)
  191. require.NotNil(t, release)
  192. defer release()
  193. rec.Body.Reset()
  194. rec.Code = 0
  195. h.Responses(c)
  196. require.NotEqual(t, http.StatusTooManyRequests, rec.Code)
  197. require.NotContains(t, rec.Body.String(), "Image generation concurrency limit exceeded")
  198. }