Skip to content

Commit d7a6339

Browse files
Add queueing for hosted keys
1 parent 31cfb74 commit d7a6339

4 files changed

Lines changed: 404 additions & 47 deletions

File tree

apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import type {
77
import { HostedKeyRateLimiter } from './hosted-key-rate-limiter'
88
import 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+
1014
interface 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

Comments
 (0)