-
-
Notifications
You must be signed in to change notification settings - Fork 873
test(execution): 32 testes para RateLimitManager (#52) #609
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,319 @@ | ||
| /** | ||
| * Testes unitários para RateLimitManager | ||
| * | ||
| * Cobre executeWithRetry, calculateDelay, isRateLimitError, | ||
| * preemptiveThrottle, metrics, events e withRateLimit wrapper. | ||
| * | ||
| * @see .aiox-core/core/execution/rate-limit-manager.js | ||
| * @issue #52 | ||
| */ | ||
|
|
||
| 'use strict'; | ||
|
|
||
| const RateLimitManager = require('../../../.aiox-core/core/execution/rate-limit-manager'); | ||
| const { withRateLimit, getGlobalManager } = RateLimitManager; | ||
|
|
||
| // ============================================================================ | ||
| // Constructor | ||
| // ============================================================================ | ||
|
|
||
| describe('RateLimitManager — constructor', () => { | ||
| it('deve usar defaults quando sem config', () => { | ||
| const mgr = new RateLimitManager(); | ||
| expect(mgr.maxRetries).toBe(5); | ||
| expect(mgr.baseDelay).toBe(1000); | ||
| expect(mgr.maxDelay).toBe(30000); | ||
| expect(mgr.requestsPerMinute).toBe(50); | ||
| }); | ||
|
|
||
| it('deve aceitar config custom', () => { | ||
| const mgr = new RateLimitManager({ | ||
| maxRetries: 3, | ||
| baseDelay: 500, | ||
| maxDelay: 10000, | ||
| requestsPerMinute: 20, | ||
| }); | ||
| expect(mgr.maxRetries).toBe(3); | ||
| expect(mgr.baseDelay).toBe(500); | ||
| expect(mgr.maxDelay).toBe(10000); | ||
| expect(mgr.requestsPerMinute).toBe(20); | ||
| }); | ||
|
|
||
| it('deve inicializar metrics zerados', () => { | ||
| const mgr = new RateLimitManager(); | ||
| expect(mgr.metrics.rateLimitHits).toBe(0); | ||
| expect(mgr.metrics.totalRetries).toBe(0); | ||
| expect(mgr.metrics.totalRequests).toBe(0); | ||
| }); | ||
|
|
||
| it('deve ser EventEmitter', () => { | ||
| const mgr = new RateLimitManager(); | ||
| expect(typeof mgr.on).toBe('function'); | ||
| expect(typeof mgr.emit).toBe('function'); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================================ | ||
| // isRateLimitError | ||
| // ============================================================================ | ||
|
|
||
| describe('RateLimitManager — isRateLimitError', () => { | ||
| let mgr; | ||
| beforeEach(() => { mgr = new RateLimitManager(); }); | ||
|
|
||
| it('deve detectar HTTP 429 via status', () => { | ||
| expect(mgr.isRateLimitError({ status: 429, message: '' })).toBe(true); | ||
| }); | ||
|
|
||
| it('deve detectar HTTP 429 via statusCode', () => { | ||
| expect(mgr.isRateLimitError({ statusCode: 429, message: '' })).toBe(true); | ||
| }); | ||
|
|
||
| it('deve detectar "rate limit" na mensagem', () => { | ||
| expect(mgr.isRateLimitError(new Error('rate limit exceeded'))).toBe(true); | ||
| }); | ||
|
|
||
| it('deve detectar "too many requests" na mensagem', () => { | ||
| expect(mgr.isRateLimitError(new Error('too many requests'))).toBe(true); | ||
| }); | ||
|
|
||
| it('deve detectar "throttl" na mensagem', () => { | ||
| expect(mgr.isRateLimitError(new Error('request throttled'))).toBe(true); | ||
| }); | ||
|
|
||
| it('deve detectar "quota exceeded" na mensagem', () => { | ||
| expect(mgr.isRateLimitError(new Error('API quota exceeded'))).toBe(true); | ||
| }); | ||
|
|
||
| it('deve detectar "overloaded" (Anthropic)', () => { | ||
| expect(mgr.isRateLimitError(new Error('API is overloaded'))).toBe(true); | ||
| }); | ||
|
|
||
| it('deve detectar code RATE_LIMITED', () => { | ||
| const err = new Error('fail'); | ||
| err.code = 'RATE_LIMITED'; | ||
| expect(mgr.isRateLimitError(err)).toBe(true); | ||
| }); | ||
|
|
||
| it('deve detectar code TOO_MANY_REQUESTS', () => { | ||
| const err = new Error('fail'); | ||
| err.code = 'TOO_MANY_REQUESTS'; | ||
| expect(mgr.isRateLimitError(err)).toBe(true); | ||
| }); | ||
|
|
||
| it('deve retornar false para erros normais', () => { | ||
| expect(mgr.isRateLimitError(new Error('connection refused'))).toBe(false); | ||
| expect(mgr.isRateLimitError(new Error('timeout'))).toBe(false); | ||
| expect(mgr.isRateLimitError({ status: 500, message: 'server error' })).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================================ | ||
| // calculateDelay | ||
| // ============================================================================ | ||
|
|
||
| describe('RateLimitManager — calculateDelay', () => { | ||
| let mgr; | ||
| beforeEach(() => { mgr = new RateLimitManager({ baseDelay: 1000, maxDelay: 30000 }); }); | ||
|
|
||
| it('deve usar retryAfter do erro quando disponível', () => { | ||
| const err = new Error('rate limited'); | ||
| err.retryAfter = 5; | ||
| const delay = mgr.calculateDelay(1, err); | ||
| expect(delay).toBe(5000); | ||
| }); | ||
|
|
||
| it('deve limitar retryAfter ao maxDelay', () => { | ||
| const err = new Error('rate limited'); | ||
| err.retryAfter = 60; | ||
| const delay = mgr.calculateDelay(1, err); | ||
| expect(delay).toBe(30000); | ||
| }); | ||
|
|
||
| it('deve extrair retry-after da mensagem de erro', () => { | ||
| const err = new Error('Retry-After: 3'); | ||
| const delay = mgr.calculateDelay(1, err); | ||
| expect(delay).toBe(3000); | ||
| }); | ||
|
|
||
| it('deve usar backoff exponencial sem retryAfter', () => { | ||
| const err = new Error('rate limited'); | ||
| // attempt 1: 1000 * 2^0 = 1000 + jitter(0-1000) ≈ 1000-2000 | ||
| const delay1 = mgr.calculateDelay(1, err); | ||
| expect(delay1).toBeGreaterThanOrEqual(1000); | ||
| expect(delay1).toBeLessThanOrEqual(2001); | ||
|
|
||
| // attempt 3: 1000 * 2^2 = 4000 + jitter(0-1000) ≈ 4000-5000 | ||
| const delay3 = mgr.calculateDelay(3, err); | ||
| expect(delay3).toBeGreaterThanOrEqual(4000); | ||
| expect(delay3).toBeLessThanOrEqual(5001); | ||
| }); | ||
|
|
||
| it('deve limitar ao maxDelay', () => { | ||
| const err = new Error('rate limited'); | ||
| // attempt 10: 1000 * 2^9 = 512000 → capped at 30000 | ||
| const delay = mgr.calculateDelay(10, err); | ||
| expect(delay).toBeLessThanOrEqual(30000); | ||
|
Comment on lines
+152
to
+156
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assert the capped value, not just the upper bound. With ✅ Tighten the cap assertion it('deve limitar ao maxDelay', () => {
const err = new Error('rate limited');
// attempt 10: 1000 * 2^9 = 512000 → capped at 30000
const delay = mgr.calculateDelay(10, err);
- expect(delay).toBeLessThanOrEqual(30000);
+ expect(delay).toBe(30000);
});🤖 Prompt for AI Agents |
||
| }); | ||
| }); | ||
|
|
||
| // ============================================================================ | ||
| // executeWithRetry | ||
| // ============================================================================ | ||
|
|
||
| describe('RateLimitManager — executeWithRetry', () => { | ||
| let mgr; | ||
| beforeEach(() => { | ||
| mgr = new RateLimitManager({ maxRetries: 3, baseDelay: 1 }); | ||
| mgr.sleep = jest.fn().mockResolvedValue(undefined); // Skip delays | ||
| }); | ||
|
|
||
| it('deve retornar resultado quando fn sucede na primeira tentativa', async () => { | ||
| const result = await mgr.executeWithRetry(() => Promise.resolve('ok')); | ||
| expect(result).toBe('ok'); | ||
| expect(mgr.metrics.totalRequests).toBe(1); | ||
| expect(mgr.metrics.rateLimitHits).toBe(0); | ||
| }); | ||
|
|
||
| it('deve fazer retry em rate limit e suceder na segunda', async () => { | ||
| let attempt = 0; | ||
| const fn = () => { | ||
| attempt++; | ||
| if (attempt === 1) { | ||
| const err = new Error('rate limit'); | ||
| throw err; | ||
| } | ||
| return Promise.resolve('recovered'); | ||
| }; | ||
|
|
||
| const result = await mgr.executeWithRetry(fn); | ||
| expect(result).toBe('recovered'); | ||
| expect(mgr.metrics.rateLimitHits).toBe(1); | ||
| expect(mgr.metrics.successAfterRetry).toBe(1); | ||
| expect(mgr.metrics.totalRetries).toBe(1); | ||
| }); | ||
|
|
||
| it('deve lançar após maxRetries em rate limit', async () => { | ||
| const fn = () => { throw new Error('rate limit exceeded'); }; | ||
|
|
||
| await expect(mgr.executeWithRetry(fn)).rejects.toThrow('Rate limit exceeded after 3 retries'); | ||
| expect(mgr.metrics.rateLimitHits).toBe(3); | ||
| }); | ||
|
|
||
| it('deve lançar imediatamente para erros não-rate-limit', async () => { | ||
| const fn = () => { throw new Error('connection refused'); }; | ||
|
|
||
| await expect(mgr.executeWithRetry(fn)).rejects.toThrow('connection refused'); | ||
| expect(mgr.metrics.rateLimitHits).toBe(0); | ||
| expect(mgr.metrics.totalRetries).toBe(0); | ||
| }); | ||
|
|
||
| it('deve emitir eventos rate_limit_hit e waiting', async () => { | ||
| const events = []; | ||
| mgr.on('rate_limit_hit', (data) => events.push({ type: 'hit', ...data })); | ||
| mgr.on('waiting', (data) => events.push({ type: 'wait', ...data })); | ||
|
|
||
| let attempt = 0; | ||
| const fn = () => { | ||
| attempt++; | ||
| if (attempt <= 2) throw new Error('rate limit'); | ||
| return Promise.resolve('ok'); | ||
| }; | ||
|
|
||
| await mgr.executeWithRetry(fn); | ||
| expect(events.filter((e) => e.type === 'hit')).toHaveLength(2); | ||
| expect(events.filter((e) => e.type === 'wait')).toHaveLength(2); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================================ | ||
| // Metrics & Events | ||
| // ============================================================================ | ||
|
|
||
| describe('RateLimitManager — metrics & events', () => { | ||
| let mgr; | ||
| beforeEach(() => { mgr = new RateLimitManager(); }); | ||
|
|
||
| it('getMetrics deve calcular averageWaitTime e successRate', () => { | ||
| mgr.metrics.totalRequests = 10; | ||
| mgr.metrics.rateLimitHits = 2; | ||
| mgr.metrics.totalRetries = 3; | ||
| mgr.metrics.totalWaitTime = 9000; | ||
|
|
||
| const m = mgr.getMetrics(); | ||
| expect(m.averageWaitTime).toBe(3000); | ||
| expect(m.successRate).toBe(80); | ||
| }); | ||
|
|
||
| it('getMetrics deve retornar 100% successRate sem requests', () => { | ||
| const m = mgr.getMetrics(); | ||
| expect(m.successRate).toBe(100); | ||
| expect(m.averageWaitTime).toBe(0); | ||
| }); | ||
|
|
||
| it('logEvent deve respeitar maxEventLog', () => { | ||
| for (let i = 0; i < 120; i++) { | ||
| mgr.logEvent('test', { i }); | ||
| } | ||
| expect(mgr.eventLog.length).toBe(100); | ||
| expect(mgr.eventLog[0].i).toBe(20); // Primeiros 20 descartados | ||
| }); | ||
|
|
||
| it('getRecentEvents deve retornar últimos N eventos', () => { | ||
| for (let i = 0; i < 10; i++) { | ||
| mgr.logEvent('test', { i }); | ||
| } | ||
| const recent = mgr.getRecentEvents(3); | ||
| expect(recent).toHaveLength(3); | ||
| expect(recent[0].i).toBe(7); | ||
| }); | ||
|
|
||
| it('resetMetrics deve zerar tudo', () => { | ||
| mgr.metrics.totalRequests = 10; | ||
| mgr.logEvent('test', {}); | ||
|
|
||
| mgr.resetMetrics(); | ||
| expect(mgr.metrics.totalRequests).toBe(0); | ||
| expect(mgr.eventLog).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('formatStatus deve retornar string formatada', () => { | ||
| mgr.metrics.totalRequests = 5; | ||
| mgr.metrics.rateLimitHits = 1; | ||
| const status = mgr.formatStatus(); | ||
|
|
||
| expect(status).toContain('Rate Limit Manager Status'); | ||
| expect(status).toContain('Total Requests: 5'); | ||
| expect(status).toContain('Rate Limit Hits: 1'); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================================ | ||
| // withRateLimit wrapper | ||
| // ============================================================================ | ||
|
|
||
| describe('withRateLimit', () => { | ||
| it('deve wrappear função com retry automático', async () => { | ||
| const mgr = new RateLimitManager({ maxRetries: 2, baseDelay: 1 }); | ||
| mgr.sleep = jest.fn().mockResolvedValue(undefined); | ||
|
|
||
| const original = jest.fn().mockResolvedValue('result'); | ||
| const wrapped = withRateLimit(original, mgr, { label: 'test' }); | ||
|
|
||
| const result = await wrapped('arg1', 'arg2'); | ||
| expect(result).toBe('result'); | ||
| expect(original).toHaveBeenCalledWith('arg1', 'arg2'); | ||
| }); | ||
|
Comment on lines
+295
to
+306
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This wrapper spec does not prove the manager is actually involved. Because 🔍 Stronger wrapper coverage- const original = jest.fn().mockResolvedValue('result');
+ const original = jest.fn()
+ .mockRejectedValueOnce(new Error('rate limit'))
+ .mockResolvedValueOnce('result');
const wrapped = withRateLimit(original, mgr, { label: 'test' });
const result = await wrapped('arg1', 'arg2');
expect(result).toBe('result');
- expect(original).toHaveBeenCalledWith('arg1', 'arg2');
+ expect(original).toHaveBeenNthCalledWith(1, 'arg1', 'arg2');
+ expect(original).toHaveBeenNthCalledWith(2, 'arg1', 'arg2');
+ expect(mgr.sleep).toHaveBeenCalledTimes(1);
+ expect(mgr.metrics.totalRetries).toBe(1);🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| // ============================================================================ | ||
| // getGlobalManager singleton | ||
| // ============================================================================ | ||
|
|
||
| describe('getGlobalManager', () => { | ||
| it('deve retornar instância singleton', () => { | ||
| const mgr1 = getGlobalManager({ maxRetries: 10 }); | ||
| const mgr2 = getGlobalManager({ maxRetries: 99 }); | ||
| expect(mgr1).toBe(mgr2); // Mesmo objeto | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove
preemptiveThrottlefrom the header or add the missing spec.This file never exercises
preemptiveThrottle, so the opening comment currently overstates the covered surface.📝 Minimal doc fix
📝 Committable suggestion
🤖 Prompt for AI Agents