Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 319 additions & 0 deletions tests/core/execution/rate-limit-manager.test.js
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.
Comment on lines +4 to +5
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove preemptiveThrottle from the header or add the missing spec.

This file never exercises preemptiveThrottle, so the opening comment currently overstates the covered surface.

📝 Minimal doc fix
- * Cobre executeWithRetry, calculateDelay, isRateLimitError,
- * preemptiveThrottle, metrics, events e withRateLimit wrapper.
+ * Cobre executeWithRetry, calculateDelay, isRateLimitError,
+ * metrics, events e withRateLimit wrapper.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* Cobre executeWithRetry, calculateDelay, isRateLimitError,
* preemptiveThrottle, metrics, events e withRateLimit wrapper.
* Cobre executeWithRetry, calculateDelay, isRateLimitError,
* metrics, events e withRateLimit wrapper.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/core/execution/rate-limit-manager.test.js` around lines 4 - 5, The test
header currently claims it covers preemptiveThrottle but the file never
exercises that function; either remove "preemptiveThrottle" from the opening
comment so it accurately lists only executeWithRetry, calculateDelay,
isRateLimitError, withRateLimit, metrics and events, or add a missing spec that
imports and invokes preemptiveThrottle (e.g., a new describe/it block that calls
preemptiveThrottle with appropriate stubbed inputs and asserts expected
behavior). Ensure the unique symbol preemptiveThrottle is referenced in the new
spec if you choose to add it and keep the header and actual tests consistent.

*
* @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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Assert the capped value, not just the upper bound.

With baseDelay: 1000 and attempt 10, this path should always return exactly 30000. The current matcher would still pass if the cap logic regressed and returned a smaller value.

✅ 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
Verify each finding against the current code and only fix it if needed.

In `@tests/core/execution/rate-limit-manager.test.js` around lines 152 - 156, The
test currently checks that mgr.calculateDelay(10, err) is <= 30000 which is too
loose; change the assertion to require the exact capped value by asserting
expect(mgr.calculateDelay(10, err)).toBe(30000). This targets the calculateDelay
behavior (with baseDelay: 1000 and attempt 10) to ensure the cap logic in the
RateLimitManager is enforced precisely.

});
});

// ============================================================================
// 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

This wrapper spec does not prove the manager is actually involved.

Because original succeeds on the first call, the test would still pass if withRateLimit() simply returned original and ignored mgr entirely. Force one rate-limit failure and assert a retry side effect such as mgr.metrics.totalRetries or a sleep() call.

🔍 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
Verify each finding against the current code and only fix it if needed.

In `@tests/core/execution/rate-limit-manager.test.js` around lines 295 - 306, The
test currently never exercises retry behavior because original resolves
immediately; update the spec for withRateLimit to force a retry by making
original reject once with the rate-limit error (e.g., mockRejectedValueOnce(new
RateLimitError(...) or a sentinel) and then resolve on the next call, then call
the wrapped function and assert both the final result and that the manager was
used (e.g., expect(mgr.sleep).toHaveBeenCalled() or
expect(mgr.metrics.totalRetries).toBeGreaterThan(0)); reference the
withRateLimit wrapper and the RateLimitManager instance (mgr) and use the
original mock's rejected-then-resolved sequence to prove retry logic runs.

});

// ============================================================================
// 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
});
});
Loading