adaptive-memory-multi-model-router 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,348 @@
1
+ /**
2
+ * ProviderRetryHandler Tests
3
+ */
4
+
5
+ import assert from 'assert';
6
+ import {
7
+ ProviderRetryHandler,
8
+ createRetryHandler,
9
+ DEFAULT_RETRY_CONFIG,
10
+ DEFAULT_PROVIDER_CONFIG,
11
+ PROVIDER_CONTEXT_LIMITS,
12
+ RetryConfig,
13
+ ProviderRetryConfig,
14
+ } from '../src/routing/providerRetry';
15
+
16
+ // ============================================================
17
+ // HELPERS
18
+ // ============================================================
19
+
20
+ function assertApproximatelyEqual(actual: number, expected: number, tolerance = 0.2): void {
21
+ const diff = Math.abs(actual - expected);
22
+ const acceptableRange = expected * tolerance;
23
+ if (diff > acceptableRange) {
24
+ throw new Error(`Expected ${actual} to be approximately ${expected} (tolerance: ${acceptableRange})`);
25
+ }
26
+ }
27
+
28
+ // ============================================================
29
+ // TEST SUITE
30
+ // ============================================================
31
+
32
+ async function runTests() {
33
+ console.log('Running ProviderRetryHandler tests...\n');
34
+
35
+ await testBackoffDelayCalculation();
36
+ await testRetryOnTransientErrors();
37
+ await testNoRetryOnNonRetryableErrors();
38
+ await test429HandlingWithRetryAfter();
39
+ await testContextWindowValidation();
40
+ await testProviderSpecificConfigs();
41
+ await testConfigureProvider();
42
+ await testRetryStats();
43
+ await testRateLimitErrorDetection();
44
+
45
+ console.log('\n✅ All tests passed!');
46
+ }
47
+
48
+ // ============================================================
49
+ // TEST CASES
50
+ // ============================================================
51
+
52
+ async function testBackoffDelayCalculation() {
53
+ console.log('Test: Backoff delay calculation');
54
+
55
+ const handler = new ProviderRetryHandler();
56
+
57
+ // Test exponential backoff without jitter (we'll check bounds)
58
+ const config: RetryConfig = {
59
+ maxRetries: 3,
60
+ initialDelayMs: 1000,
61
+ maxDelayMs: 30000,
62
+ backoffMultiplier: 2,
63
+ retryableErrors: ['ECONNRESET'],
64
+ };
65
+
66
+ // Attempt 0: base = 1000 * 2^0 = 1000, jitter [500, 1000]
67
+ let delay = handler.calculateBackoffDelay(0, config);
68
+ assert(delay >= 500 && delay <= 1000, `Attempt 0: Expected delay in [500, 1000], got ${delay}`);
69
+ console.log(' ✓ Attempt 0: delay in expected range [500, 1000]');
70
+
71
+ // Attempt 1: base = 1000 * 2^1 = 2000, jitter [1000, 2000]
72
+ delay = handler.calculateBackoffDelay(1, config);
73
+ assert(delay >= 1000 && delay <= 2000, `Attempt 1: Expected delay in [1000, 2000], got ${delay}`);
74
+ console.log(' ✓ Attempt 1: delay in expected range [1000, 2000]');
75
+
76
+ // Attempt 2: base = 1000 * 2^2 = 4000, jitter [2000, 4000]
77
+ delay = handler.calculateBackoffDelay(2, config);
78
+ assert(delay >= 2000 && delay <= 4000, `Attempt 2: Expected delay in [2000, 4000], got ${delay}`);
79
+ console.log(' ✓ Attempt 2: delay in expected range [2000, 4000]');
80
+
81
+ // Attempt 3: base = 1000 * 2^3 = 8000, jitter [4000, 8000]
82
+ delay = handler.calculateBackoffDelay(3, config);
83
+ assert(delay >= 4000 && delay <= 8000, `Attempt 3: Expected delay in [4000, 8000], got ${delay}`);
84
+ console.log(' ✓ Attempt 3: delay in expected range [4000, 8000]');
85
+
86
+ // Test max delay cap
87
+ const maxConfig: RetryConfig = { ...config, maxDelayMs: 5000 };
88
+ delay = handler.calculateBackoffDelay(10, maxConfig);
89
+ assert(delay <= 5000, `Should cap at maxDelayMs: ${delay} > 5000`);
90
+ console.log(' ✓ Respects maxDelayMs cap');
91
+
92
+ console.log(' ✅ Backoff delay calculation tests passed\n');
93
+ }
94
+
95
+ async function testRetryOnTransientErrors() {
96
+ console.log('Test: Retry on transient errors');
97
+
98
+ const handler = new ProviderRetryHandler();
99
+
100
+ // Test that retryable errors trigger retries
101
+ const retryableErrors = [
102
+ { code: 'ECONNRESET', shouldRetry: true },
103
+ { code: 'ETIMEDOUT', shouldRetry: true },
104
+ { code: 'ECONNREFUSED', shouldRetry: true },
105
+ { status: 503, shouldRetry: true },
106
+ { status: 500, shouldRetry: true },
107
+ { status: 502, shouldRetry: true },
108
+ { status: 504, shouldRetry: true },
109
+ { message: 'socket hang up', shouldRetry: true },
110
+ ];
111
+
112
+ for (const error of retryableErrors) {
113
+ const result = handler.isRetryableError(error);
114
+ assert(result === error.shouldRetry,
115
+ `Error ${JSON.stringify(error)} should${error.shouldRetry ? '' : ' NOT'} be retryable`);
116
+ }
117
+ console.log(' ✓ All configured retryable errors detected correctly');
118
+
119
+ console.log(' ✅ Retry on transient errors tests passed\n');
120
+ }
121
+
122
+ async function testNoRetryOnNonRetryableErrors() {
123
+ console.log('Test: No retry on non-retryable errors');
124
+
125
+ const handler = new ProviderRetryHandler();
126
+
127
+ // Test that non-retryable errors don't trigger retries
128
+ const nonRetryableErrors = [
129
+ { status: 400, message: 'Bad Request' },
130
+ { status: 401, message: 'Unauthorized' },
131
+ { status: 403, message: 'Forbidden' },
132
+ { status: 404, message: 'Not Found' },
133
+ { code: 'VALIDATION_ERROR', message: 'Invalid input' },
134
+ { code: 'INVALID_API_KEY', message: 'API key invalid' },
135
+ ];
136
+
137
+ for (const error of nonRetryableErrors) {
138
+ const result = handler.isRetryableError(error);
139
+ assert(result === false,
140
+ `Error ${JSON.stringify(error)} should NOT be retryable`);
141
+ }
142
+ console.log(' ✓ All non-retryable errors correctly rejected');
143
+
144
+ console.log(' ✅ No retry on non-retryable errors tests passed\n');
145
+ }
146
+
147
+ async function test429HandlingWithRetryAfter() {
148
+ console.log('Test: 429 handling with Retry-After header');
149
+
150
+ const handler = new ProviderRetryHandler();
151
+
152
+ // Test with Retry-After in seconds
153
+ const errorWithRetryAfterSeconds = {
154
+ status: 429,
155
+ headers: { 'retry-after': '5' },
156
+ };
157
+
158
+ const delay = handler.calculateBackoffDelay(0, DEFAULT_RETRY_CONFIG, errorWithRetryAfterSeconds);
159
+ assert(delay >= 4500 && delay <= 5500,
160
+ `Expected delay ~5000ms from Retry-After: 5, got ${delay}`);
161
+ console.log(' ✓ Retry-After seconds correctly parsed and applied');
162
+
163
+ // Test with Retry-After as HTTP date
164
+ const futureDate = new Date(Date.now() + 10000).toUTCString();
165
+ const errorWithRetryAfterDate = {
166
+ status: 429,
167
+ headers: { 'retry-after': futureDate },
168
+ };
169
+
170
+ const delayFromDate = handler.calculateBackoffDelay(0, DEFAULT_RETRY_CONFIG, errorWithRetryAfterDate);
171
+ assert(delayFromDate >= 8000 && delayFromDate <= 12000,
172
+ `Expected delay ~10000ms from Retry-After date, got ${delayFromDate}`);
173
+ console.log(' ✓ Retry-After HTTP date correctly parsed and applied');
174
+
175
+ // Test without Retry-After falls back to backoff
176
+ const errorWithoutRetryAfter = {
177
+ status: 429,
178
+ headers: {},
179
+ };
180
+
181
+ const fallbackDelay = handler.calculateBackoffDelay(0, DEFAULT_RETRY_CONFIG, errorWithoutRetryAfter);
182
+ // Should use exponential backoff: 1000 * 2^0 * jitter = [500, 1000]
183
+ assert(fallbackDelay >= 500 && fallbackDelay <= 1500,
184
+ `Expected delay in backoff range [500, 1500], got ${fallbackDelay}`);
185
+ console.log(' ✓ Falls back to exponential backoff when no Retry-After');
186
+
187
+ console.log(' ✅ 429 handling tests passed\n');
188
+ }
189
+
190
+ async function testContextWindowValidation() {
191
+ console.log('Test: Context window validation');
192
+
193
+ const handler = new ProviderRetryHandler();
194
+
195
+ // Test valid context window
196
+ const shortPrompt = 'Hello, this is a short prompt.';
197
+ const validResult = handler.validateContextWindow('openai', shortPrompt);
198
+ assert(validResult.valid === true, 'Short prompt should be valid');
199
+ console.log(' ✓ Short prompt passes validation');
200
+
201
+ // Test invalid context window (very long prompt for small context provider)
202
+ const longPrompt = 'Lorem ipsum '.repeat(10000); // ~130K chars
203
+ const invalidResult = handler.validateContextWindow('cerebras', longPrompt);
204
+ assert(invalidResult.valid === false, 'Long prompt should fail for small context');
205
+ assert(invalidResult.reason !== undefined, 'Should provide reason');
206
+ assert(invalidResult.suggestedProvider !== undefined, 'Should suggest alternative');
207
+ console.log(` ✓ Long prompt correctly rejected with reason: ${invalidResult.reason?.substring(0, 50)}...`);
208
+
209
+ // Test with known large context provider
210
+ const largeContextResult = handler.validateContextWindow('anthropic', longPrompt);
211
+ assert(largeContextResult.valid === true, 'Same prompt should fit in anthropic');
212
+ console.log(' ✓ Long prompt fits in large context provider');
213
+
214
+ console.log(' ✅ Context window validation tests passed\n');
215
+ }
216
+
217
+ async function testProviderSpecificConfigs() {
218
+ console.log('Test: Provider-specific configs');
219
+
220
+ const handler = new ProviderRetryHandler();
221
+
222
+ // DeepSeek should have longer timeout and more retries
223
+ const deepseekConfig = handler.getConfig('deepseek');
224
+ assert(deepseekConfig.timeout === 30000, `DeepSeek timeout should be 30000, got ${deepseekConfig.timeout}`);
225
+ assert(deepseekConfig.retry.maxRetries === 5, `DeepSeek maxRetries should be 5, got ${deepseekConfig.retry.maxRetries}`);
226
+ assert(deepseekConfig.rateLimitRetries === 3, `DeepSeek rateLimitRetries should be 3, got ${deepseekConfig.rateLimitRetries}`);
227
+ console.log(' ✓ DeepSeek config: timeout=30000, maxRetries=5, rateLimitRetries=3');
228
+
229
+ // Groq should have shorter timeout and fewer retries
230
+ const groqConfig = handler.getConfig('groq');
231
+ assert(groqConfig.timeout === 10000, `Groq timeout should be 10000, got ${groqConfig.timeout}`);
232
+ assert(groqConfig.retry.maxRetries === 2, `Groq maxRetries should be 2, got ${groqConfig.retry.maxRetries}`);
233
+ assert(groqConfig.rateLimitRetries === 1, `Groq rateLimitRetries should be 1, got ${groqConfig.rateLimitRetries}`);
234
+ console.log(' ✓ Groq config: timeout=10000, maxRetries=2, rateLimitRetries=1');
235
+
236
+ // Unknown provider should fallback to default
237
+ const unknownConfig = handler.getConfig('unknown-provider');
238
+ assert(unknownConfig.timeout === 15000, `Unknown provider should use default timeout=15000, got ${unknownConfig.timeout}`);
239
+ assert(unknownConfig.retry.maxRetries === 3, `Unknown provider should use default maxRetries=3, got ${unknownConfig.retry.maxRetries}`);
240
+ console.log(' ✓ Unknown provider correctly falls back to default config');
241
+
242
+ console.log(' ✅ Provider-specific config tests passed\n');
243
+ }
244
+
245
+ async function testConfigureProvider() {
246
+ console.log('Test: configureProvider method');
247
+
248
+ const handler = new ProviderRetryHandler();
249
+
250
+ // Configure a new provider
251
+ handler.configureProvider('custom-provider', {
252
+ timeout: 5000,
253
+ retry: {
254
+ maxRetries: 10,
255
+ initialDelayMs: 500,
256
+ maxDelayMs: 60000,
257
+ backoffMultiplier: 1.5,
258
+ },
259
+ rateLimitRetries: 5,
260
+ });
261
+
262
+ const customConfig = handler.getConfig('custom-provider');
263
+ assert(customConfig.timeout === 5000, `Custom timeout should be 5000, got ${customConfig.timeout}`);
264
+ assert(customConfig.retry.maxRetries === 10, `Custom maxRetries should be 10, got ${customConfig.retry.maxRetries}`);
265
+ assert(customConfig.retry.initialDelayMs === 500, `Custom initialDelayMs should be 500, got ${customConfig.retry.initialDelayMs}`);
266
+ assert(customConfig.rateLimitRetries === 5, `Custom rateLimitRetries should be 5, got ${customConfig.rateLimitRetries}`);
267
+ console.log(' ✓ New custom provider configured correctly');
268
+
269
+ // Override existing provider
270
+ handler.configureProvider('deepseek', { timeout: 99999 });
271
+ const updatedConfig = handler.getConfig('deepseek');
272
+ assert(updatedConfig.timeout === 99999, `Updated timeout should be 99999, got ${updatedConfig.timeout}`);
273
+ // Other settings should remain
274
+ assert(updatedConfig.retry.maxRetries === 5, `maxRetries should remain 5`);
275
+ console.log(' ✓ Existing provider correctly updated');
276
+
277
+ console.log(' ✅ configureProvider tests passed\n');
278
+ }
279
+
280
+ async function testRetryStats() {
281
+ console.log('Test: Retry statistics tracking');
282
+
283
+ const handler = new ProviderRetryHandler();
284
+
285
+ // Initially stats should be zeroed
286
+ const initialStats = handler.getStats('openai');
287
+ assert(initialStats.totalRequests === 0, 'Initial totalRequests should be 0');
288
+ assert(initialStats.successfulRequests === 0, 'Initial successfulRequests should be 0');
289
+ console.log(' ✓ Initial stats are zeroed');
290
+
291
+ // Test stats after simulated operations
292
+ handler.resetStats('openai');
293
+ const resetStats = handler.getStats('openai');
294
+ assert(resetStats.totalRequests === 0, 'After reset, totalRequests should be 0');
295
+ console.log(' ✓ resetStats correctly resets provider stats');
296
+
297
+ // Test getAllStats
298
+ const allStats = handler.getAllStats();
299
+ assert(typeof allStats === 'object', 'getAllStats should return an object');
300
+ assert(allStats['openai'] !== undefined, 'getAllStats should include openai');
301
+ console.log(' ✓ getAllStats returns all provider stats');
302
+
303
+ console.log(' ✅ Retry statistics tests passed\n');
304
+ }
305
+
306
+ async function testRateLimitErrorDetection() {
307
+ console.log('Test: Rate limit error detection');
308
+
309
+ const handler = new ProviderRetryHandler();
310
+
311
+ // Test various 429 error formats
312
+ const rateLimitErrors = [
313
+ { status: 429 },
314
+ { statusCode: 429 },
315
+ { code: '429', message: 'Rate limit exceeded' },
316
+ { error: { status: 429 } },
317
+ ];
318
+
319
+ for (const error of rateLimitErrors) {
320
+ assert(handler.isRateLimitError(error) === true,
321
+ `Error ${JSON.stringify(error)} should be detected as rate limit`);
322
+ }
323
+ console.log(' ✓ All 429 variants correctly detected as rate limit');
324
+
325
+ // Non-429 errors should not be rate limits
326
+ const nonRateLimitErrors = [
327
+ { status: 400 },
328
+ { status: 500 },
329
+ { code: 'ECONNRESET' },
330
+ ];
331
+
332
+ for (const error of nonRateLimitErrors) {
333
+ assert(handler.isRateLimitError(error) === false,
334
+ `Error ${JSON.stringify(error)} should NOT be detected as rate limit`);
335
+ }
336
+ console.log(' ✓ Non-429 errors correctly not marked as rate limit');
337
+
338
+ console.log(' ✅ Rate limit error detection tests passed\n');
339
+ }
340
+
341
+ // ============================================================
342
+ // RUN TESTS
343
+ // ============================================================
344
+
345
+ runTests().catch((err) => {
346
+ console.error('❌ Tests failed:', err);
347
+ process.exit(1);
348
+ });