@@ -7,6 +7,10 @@ import type {
77import { HostedKeyRateLimiter } from './hosted-key-rate-limiter'
88import type { CustomRateLimit , PerRequestRateLimit } from './types'
99
10+ /** Force the queue wait to give up on the first iteration by reporting a retry time
11+ * larger than the 5-minute MAX_QUEUE_WAIT_MS cap. */
12+ const RETRY_PAST_CAP_MS = 6 * 60 * 1000
13+
1014interface MockAdapter {
1115 consumeTokens : Mock
1216 getTokenStatus : Mock
@@ -72,11 +76,12 @@ describe('HostedKeyRateLimiter', () => {
7276 expect ( result . error ) . toContain ( 'No hosted keys configured' )
7377 } )
7478
75- it ( 'should rate limit billing actor when they exceed their limit' , async ( ) => {
79+ it ( 'should rate limit billing actor when wait exceeds the queue cap' , async ( ) => {
80+ // resetAt past the 5-minute cap forces the wait loop to bail immediately.
7681 const rateLimitedResult : ConsumeResult = {
7782 allowed : false ,
7883 tokensRemaining : 0 ,
79- resetAt : new Date ( Date . now ( ) + 30000 ) ,
84+ resetAt : new Date ( Date . now ( ) + RETRY_PAST_CAP_MS ) ,
8085 }
8186 mockAdapter . consumeTokens . mockResolvedValue ( rateLimitedResult )
8287
@@ -93,6 +98,33 @@ describe('HostedKeyRateLimiter', () => {
9398 expect ( result . error ) . toContain ( 'Rate limit exceeded' )
9499 } )
95100
101+ it ( 'should wait for capacity then succeed when bucket refills within the cap' , async ( ) => {
102+ // First call: bucket empty, refills in 100ms (well under cap).
103+ // Second call: bucket has capacity, consumed.
104+ const blocked : ConsumeResult = {
105+ allowed : false ,
106+ tokensRemaining : 0 ,
107+ resetAt : new Date ( Date . now ( ) + 100 ) ,
108+ }
109+ const allowed : ConsumeResult = {
110+ allowed : true ,
111+ tokensRemaining : 9 ,
112+ resetAt : new Date ( Date . now ( ) + 60000 ) ,
113+ }
114+ mockAdapter . consumeTokens . mockResolvedValueOnce ( blocked ) . mockResolvedValueOnce ( allowed )
115+
116+ const result = await rateLimiter . acquireKey (
117+ testProvider ,
118+ envKeyPrefix ,
119+ perRequestRateLimit ,
120+ 'workspace-wait'
121+ )
122+
123+ expect ( result . success ) . toBe ( true )
124+ expect ( result . key ) . toBe ( 'test-key-1' )
125+ expect ( mockAdapter . consumeTokens ) . toHaveBeenCalledTimes ( 2 )
126+ } )
127+
96128 it ( 'should allow billing actor within their rate limit' , async ( ) => {
97129 const allowedResult : ConsumeResult = {
98130 allowed : true ,
@@ -197,11 +229,11 @@ describe('HostedKeyRateLimiter', () => {
197229 ] ,
198230 }
199231
200- it ( 'should enforce requestsPerMinute for custom mode' , async ( ) => {
232+ it ( 'should enforce requestsPerMinute for custom mode when wait exceeds the cap ' , async ( ) => {
201233 const rateLimitedResult : ConsumeResult = {
202234 allowed : false ,
203235 tokensRemaining : 0 ,
204- resetAt : new Date ( Date . now ( ) + 30000 ) ,
236+ resetAt : new Date ( Date . now ( ) + RETRY_PAST_CAP_MS ) ,
205237 }
206238 mockAdapter . consumeTokens . mockResolvedValue ( rateLimitedResult )
207239
@@ -246,7 +278,7 @@ describe('HostedKeyRateLimiter', () => {
246278 expect ( mockAdapter . getTokenStatus ) . toHaveBeenCalledTimes ( 1 )
247279 } )
248280
249- it ( 'should block request when a dimension is depleted ' , async ( ) => {
281+ it ( 'should block request when a dimension wait exceeds the cap ' , async ( ) => {
250282 const allowedConsume : ConsumeResult = {
251283 allowed : true ,
252284 tokensRemaining : 4 ,
@@ -258,7 +290,7 @@ describe('HostedKeyRateLimiter', () => {
258290 tokensAvailable : 0 ,
259291 maxTokens : 2000 ,
260292 lastRefillAt : new Date ( ) ,
261- nextRefillAt : new Date ( Date . now ( ) + 45000 ) ,
293+ nextRefillAt : new Date ( Date . now ( ) + RETRY_PAST_CAP_MS ) ,
262294 }
263295 mockAdapter . getTokenStatus . mockResolvedValue ( depleted )
264296
@@ -274,6 +306,39 @@ describe('HostedKeyRateLimiter', () => {
274306 expect ( result . error ) . toContain ( 'tokens' )
275307 } )
276308
309+ it ( 'should wait for dimension capacity then succeed when budget refills' , async ( ) => {
310+ const allowedConsume : ConsumeResult = {
311+ allowed : true ,
312+ tokensRemaining : 4 ,
313+ resetAt : new Date ( Date . now ( ) + 60000 ) ,
314+ }
315+ mockAdapter . consumeTokens . mockResolvedValue ( allowedConsume )
316+
317+ const depleted : TokenStatus = {
318+ tokensAvailable : 0 ,
319+ maxTokens : 2000 ,
320+ lastRefillAt : new Date ( ) ,
321+ nextRefillAt : new Date ( Date . now ( ) + 100 ) ,
322+ }
323+ const refilled : TokenStatus = {
324+ tokensAvailable : 500 ,
325+ maxTokens : 2000 ,
326+ lastRefillAt : new Date ( ) ,
327+ nextRefillAt : new Date ( Date . now ( ) + 60000 ) ,
328+ }
329+ mockAdapter . getTokenStatus . mockResolvedValueOnce ( depleted ) . mockResolvedValueOnce ( refilled )
330+
331+ const result = await rateLimiter . acquireKey (
332+ testProvider ,
333+ envKeyPrefix ,
334+ customRateLimit ,
335+ 'workspace-dim-wait'
336+ )
337+
338+ expect ( result . success ) . toBe ( true )
339+ expect ( mockAdapter . getTokenStatus ) . toHaveBeenCalledTimes ( 2 )
340+ } )
341+
277342 it ( 'should pre-check all dimensions and block on first depleted one' , async ( ) => {
278343 const multiDimensionConfig : CustomRateLimit = {
279344 mode : 'custom' ,
@@ -309,7 +374,7 @@ describe('HostedKeyRateLimiter', () => {
309374 tokensAvailable : 0 ,
310375 maxTokens : 100 ,
311376 lastRefillAt : new Date ( ) ,
312- nextRefillAt : new Date ( Date . now ( ) + 30000 ) ,
377+ nextRefillAt : new Date ( Date . now ( ) + RETRY_PAST_CAP_MS ) ,
313378 }
314379 mockAdapter . getTokenStatus
315380 . mockResolvedValueOnce ( tokensBudget )
0 commit comments