ai-functions 2.1.1 → 2.3.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.
- package/.turbo/turbo-build.log +1 -4
- package/CHANGELOG.md +68 -1
- package/README.md +397 -157
- package/dist/ai-promise.d.ts +50 -3
- package/dist/ai-promise.d.ts.map +1 -1
- package/dist/ai-promise.js +410 -51
- package/dist/ai-promise.js.map +1 -1
- package/dist/ai-schemas.d.ts +56 -0
- package/dist/ai-schemas.d.ts.map +1 -0
- package/dist/ai-schemas.js +53 -0
- package/dist/ai-schemas.js.map +1 -0
- package/dist/ai.d.ts +16 -242
- package/dist/ai.d.ts.map +1 -1
- package/dist/ai.js +54 -837
- package/dist/ai.js.map +1 -1
- package/dist/batch/anthropic.d.ts +6 -4
- package/dist/batch/anthropic.d.ts.map +1 -1
- package/dist/batch/anthropic.js +83 -145
- package/dist/batch/anthropic.js.map +1 -1
- package/dist/batch/bedrock.d.ts +8 -30
- package/dist/batch/bedrock.d.ts.map +1 -1
- package/dist/batch/bedrock.js +155 -338
- package/dist/batch/bedrock.js.map +1 -1
- package/dist/batch/cloudflare.d.ts +8 -20
- package/dist/batch/cloudflare.d.ts.map +1 -1
- package/dist/batch/cloudflare.js +68 -189
- package/dist/batch/cloudflare.js.map +1 -1
- package/dist/batch/google.d.ts +6 -20
- package/dist/batch/google.d.ts.map +1 -1
- package/dist/batch/google.js +70 -238
- package/dist/batch/google.js.map +1 -1
- package/dist/batch/index.d.ts +4 -1
- package/dist/batch/index.d.ts.map +1 -1
- package/dist/batch/index.js +4 -1
- package/dist/batch/index.js.map +1 -1
- package/dist/batch/memory.d.ts +1 -1
- package/dist/batch/memory.d.ts.map +1 -1
- package/dist/batch/memory.js +14 -10
- package/dist/batch/memory.js.map +1 -1
- package/dist/batch/openai.d.ts +11 -14
- package/dist/batch/openai.d.ts.map +1 -1
- package/dist/batch/openai.js +52 -156
- package/dist/batch/openai.js.map +1 -1
- package/dist/batch/provider.d.ts +111 -0
- package/dist/batch/provider.d.ts.map +1 -0
- package/dist/batch/provider.js +233 -0
- package/dist/batch/provider.js.map +1 -0
- package/dist/batch-map.d.ts.map +1 -1
- package/dist/batch-map.js +23 -17
- package/dist/batch-map.js.map +1 -1
- package/dist/batch-queue.d.ts +65 -0
- package/dist/batch-queue.d.ts.map +1 -1
- package/dist/batch-queue.js +169 -14
- package/dist/batch-queue.js.map +1 -1
- package/dist/budget.d.ts +272 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +513 -0
- package/dist/budget.js.map +1 -0
- package/dist/cache.d.ts +295 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +433 -0
- package/dist/cache.js.map +1 -0
- package/dist/context.d.ts +42 -8
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +64 -62
- package/dist/context.js.map +1 -1
- package/dist/digital-objects-registry.d.ts +229 -0
- package/dist/digital-objects-registry.d.ts.map +1 -0
- package/dist/digital-objects-registry.js +617 -0
- package/dist/digital-objects-registry.js.map +1 -0
- package/dist/embeddings.d.ts +2 -2
- package/dist/embeddings.d.ts.map +1 -1
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/errors.js.map +1 -0
- package/dist/eval/runner.d.ts +10 -1
- package/dist/eval/runner.d.ts.map +1 -1
- package/dist/eval/runner.js +41 -35
- package/dist/eval/runner.js.map +1 -1
- package/dist/eval-log/in-memory.d.ts +34 -0
- package/dist/eval-log/in-memory.d.ts.map +1 -0
- package/dist/eval-log/in-memory.js +84 -0
- package/dist/eval-log/in-memory.js.map +1 -0
- package/dist/eval-log/index.d.ts +29 -0
- package/dist/eval-log/index.d.ts.map +1 -0
- package/dist/eval-log/index.js +39 -0
- package/dist/eval-log/index.js.map +1 -0
- package/dist/eval-log/types.d.ts +101 -0
- package/dist/eval-log/types.d.ts.map +1 -0
- package/dist/eval-log/types.js +16 -0
- package/dist/eval-log/types.js.map +1 -0
- package/dist/function-registry.d.ts +116 -0
- package/dist/function-registry.d.ts.map +1 -0
- package/dist/function-registry.js +546 -0
- package/dist/function-registry.js.map +1 -0
- package/dist/generate.d.ts +9 -3
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +18 -22
- package/dist/generate.js.map +1 -1
- package/dist/index.d.ts +35 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +89 -42
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +118 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +187 -0
- package/dist/logger.js.map +1 -0
- package/dist/middleware/budget.d.ts +84 -0
- package/dist/middleware/budget.d.ts.map +1 -0
- package/dist/middleware/budget.js +110 -0
- package/dist/middleware/budget.js.map +1 -0
- package/dist/middleware/cache.d.ts +103 -0
- package/dist/middleware/cache.d.ts.map +1 -0
- package/dist/middleware/cache.js +228 -0
- package/dist/middleware/cache.js.map +1 -0
- package/dist/middleware/embed-cache.d.ts +99 -0
- package/dist/middleware/embed-cache.d.ts.map +1 -0
- package/dist/middleware/embed-cache.js +128 -0
- package/dist/middleware/embed-cache.js.map +1 -0
- package/dist/middleware/index.d.ts +11 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +11 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/middleware/trace.d.ts +103 -0
- package/dist/middleware/trace.d.ts.map +1 -0
- package/dist/middleware/trace.js +176 -0
- package/dist/middleware/trace.js.map +1 -0
- package/dist/primitives.d.ts +120 -1
- package/dist/primitives.d.ts.map +1 -1
- package/dist/primitives.js +398 -26
- package/dist/primitives.js.map +1 -1
- package/dist/retry.d.ts +368 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +646 -0
- package/dist/retry.js.map +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -10
- package/dist/schema.js.map +1 -1
- package/dist/telemetry.d.ts +128 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +285 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/template.d.ts.map +1 -1
- package/dist/template.js +6 -1
- package/dist/template.js.map +1 -1
- package/dist/tool-orchestration.d.ts +453 -0
- package/dist/tool-orchestration.d.ts.map +1 -0
- package/dist/tool-orchestration.js +763 -0
- package/dist/tool-orchestration.js.map +1 -0
- package/dist/type-guards.d.ts +28 -0
- package/dist/type-guards.d.ts.map +1 -0
- package/dist/type-guards.js +29 -0
- package/dist/type-guards.js.map +1 -0
- package/dist/types.d.ts +135 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +36 -1
- package/dist/types.js.map +1 -1
- package/dist/wrap-for-v3.d.ts +80 -0
- package/dist/wrap-for-v3.d.ts.map +1 -0
- package/dist/wrap-for-v3.js +89 -0
- package/dist/wrap-for-v3.js.map +1 -0
- package/examples/00-quickstart.ts +232 -0
- package/examples/01-rag-chatbot.ts +212 -0
- package/examples/02-multi-agent-research.ts +290 -0
- package/examples/03-email-classification.ts +379 -0
- package/examples/04-content-moderation.ts +400 -0
- package/examples/05-document-extraction.ts +455 -0
- package/examples/06-streaming-chat-nextjs.ts +437 -0
- package/examples/07-cloudflare-worker.ts +483 -0
- package/examples/08-batch-processing.ts +491 -0
- package/examples/09-budget-constrained.ts +527 -0
- package/examples/10-tool-orchestration.ts +565 -0
- package/examples/11-retry-resilience.ts +403 -0
- package/examples/12-caching-strategies.ts +422 -0
- package/examples/README.md +145 -0
- package/package.json +10 -6
- package/src/ai-promise.ts +528 -99
- package/src/ai-schemas.ts +122 -0
- package/src/ai.ts +69 -1153
- package/src/batch/anthropic.ts +96 -161
- package/src/batch/bedrock.ts +203 -454
- package/src/batch/cloudflare.ts +99 -282
- package/src/batch/google.ts +91 -297
- package/src/batch/index.ts +4 -1
- package/src/batch/memory.ts +15 -10
- package/src/batch/openai.ts +65 -193
- package/src/batch/provider.ts +336 -0
- package/src/batch-map.ts +29 -24
- package/src/batch-queue.ts +200 -11
- package/src/budget.ts +740 -0
- package/src/cache.ts +681 -0
- package/src/context.ts +122 -76
- package/src/digital-objects-registry.ts +750 -0
- package/src/errors.ts +37 -0
- package/src/eval/runner.ts +63 -38
- package/src/eval-log/in-memory.ts +90 -0
- package/src/eval-log/index.ts +46 -0
- package/src/eval-log/types.ts +110 -0
- package/src/function-registry.ts +671 -0
- package/src/generate.ts +33 -33
- package/src/index.ts +325 -49
- package/src/logger.ts +232 -0
- package/src/middleware/budget.ts +171 -0
- package/src/middleware/cache.ts +299 -0
- package/src/middleware/embed-cache.ts +195 -0
- package/src/middleware/index.ts +23 -0
- package/src/middleware/trace.ts +248 -0
- package/src/primitives.ts +589 -62
- package/src/retry.ts +902 -0
- package/src/schema.ts +8 -17
- package/src/telemetry.ts +403 -0
- package/src/template.ts +8 -4
- package/src/tool-orchestration.ts +1173 -0
- package/src/type-guards.ts +31 -0
- package/src/types.ts +164 -25
- package/src/wrap-for-v3.ts +105 -0
- package/test/ai-promise.test.ts +1080 -0
- package/test/ai-proxy.test.ts +1 -1
- package/test/backward-compat.test.ts +147 -0
- package/test/batch-autosubmit-errors.test.ts +610 -0
- package/test/batch-blog-posts.test.ts +87 -129
- package/test/budget-tracking.test.ts +800 -0
- package/test/cache.test.ts +712 -0
- package/test/context-isolation.test.ts +687 -0
- package/test/core-functions.test.ts +183 -579
- package/test/decide.test.ts +154 -322
- package/test/define.test.ts +211 -8
- package/test/digital-objects-registry.test.ts +760 -0
- package/test/embedding-cache-middleware.test.ts +140 -0
- package/test/evals/deterministic.eval.test.ts +376 -0
- package/test/generate-core.test.ts +140 -229
- package/test/implicit-batch.test.ts +22 -65
- package/test/json-parse-error-handling.test.ts +463 -0
- package/test/retry-policy-integration.test.ts +117 -0
- package/test/retry.test.ts +1016 -0
- package/test/schema.test.ts +55 -19
- package/test/streaming.test.ts +316 -0
- package/test/template.test.ts +1164 -0
- package/test/tool-orchestration.test.ts +1040 -0
- package/test/wrap-for-v3.test.ts +612 -0
- package/vitest.config.js +6 -0
- package/vitest.config.ts +20 -0
- package/dist/rpc/auth.d.ts +0 -69
- package/dist/rpc/auth.d.ts.map +0 -1
- package/dist/rpc/auth.js +0 -136
- package/dist/rpc/auth.js.map +0 -1
- package/dist/rpc/client.d.ts +0 -62
- package/dist/rpc/client.d.ts.map +0 -1
- package/dist/rpc/client.js +0 -103
- package/dist/rpc/client.js.map +0 -1
- package/dist/rpc/deferred.d.ts +0 -60
- package/dist/rpc/deferred.d.ts.map +0 -1
- package/dist/rpc/deferred.js +0 -96
- package/dist/rpc/deferred.js.map +0 -1
- package/dist/rpc/index.d.ts +0 -22
- package/dist/rpc/index.d.ts.map +0 -1
- package/dist/rpc/index.js +0 -38
- package/dist/rpc/index.js.map +0 -1
- package/dist/rpc/local.d.ts +0 -42
- package/dist/rpc/local.d.ts.map +0 -1
- package/dist/rpc/local.js +0 -50
- package/dist/rpc/local.js.map +0 -1
- package/dist/rpc/server.d.ts +0 -165
- package/dist/rpc/server.d.ts.map +0 -1
- package/dist/rpc/server.js +0 -405
- package/dist/rpc/server.js.map +0 -1
- package/dist/rpc/session.d.ts +0 -32
- package/dist/rpc/session.d.ts.map +0 -1
- package/dist/rpc/session.js +0 -43
- package/dist/rpc/session.js.map +0 -1
- package/dist/rpc/transport.d.ts +0 -306
- package/dist/rpc/transport.d.ts.map +0 -1
- package/dist/rpc/transport.js +0 -731
- package/dist/rpc/transport.js.map +0 -1
- package/src/batch/anthropic.js +0 -256
- package/src/batch/bedrock.js +0 -584
- package/src/batch/cloudflare.js +0 -287
- package/src/batch/google.js +0 -359
- package/src/batch/index.js +0 -30
- package/src/batch/memory.js +0 -187
- package/src/batch/openai.js +0 -402
- package/src/eval/index.js +0 -7
- package/src/eval/models.js +0 -119
- package/src/eval/runner.js +0 -147
- package/test/schema.test.js +0 -96
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for retry/fallback patterns with exponential backoff
|
|
3
|
+
*
|
|
4
|
+
* TDD Approach: RED Phase - Write failing tests first
|
|
5
|
+
*
|
|
6
|
+
* Tests cover:
|
|
7
|
+
* 1. Exponential backoff (delays: 1s, 2s, 4s, 8s...)
|
|
8
|
+
* 2. Jitter (+-20% randomization)
|
|
9
|
+
* 3. Circuit breaker (fail fast after N consecutive failures)
|
|
10
|
+
* 4. Fallback models (sonnet fails -> try opus -> try gpt-4o)
|
|
11
|
+
* 5. Partial retry for batch items
|
|
12
|
+
* 6. Error classification (network vs rate-limit vs invalid-input)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
16
|
+
import {
|
|
17
|
+
RetryPolicy,
|
|
18
|
+
CircuitBreaker,
|
|
19
|
+
FallbackChain,
|
|
20
|
+
withRetry,
|
|
21
|
+
calculateBackoff,
|
|
22
|
+
classifyError,
|
|
23
|
+
RetryableError,
|
|
24
|
+
NonRetryableError,
|
|
25
|
+
RateLimitError,
|
|
26
|
+
NetworkError,
|
|
27
|
+
CircuitOpenError,
|
|
28
|
+
ErrorCategory,
|
|
29
|
+
type RetryOptions,
|
|
30
|
+
type CircuitBreakerOptions,
|
|
31
|
+
type FallbackOptions,
|
|
32
|
+
} from '../src/retry.js'
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// 1. EXPONENTIAL BACKOFF TESTS
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
describe('Exponential Backoff', () => {
|
|
39
|
+
describe('calculateBackoff', () => {
|
|
40
|
+
it('calculates correct delays: 1s, 2s, 4s, 8s...', () => {
|
|
41
|
+
const baseDelay = 1000 // 1 second
|
|
42
|
+
|
|
43
|
+
expect(calculateBackoff(0, { baseDelay })).toBe(1000)
|
|
44
|
+
expect(calculateBackoff(1, { baseDelay })).toBe(2000)
|
|
45
|
+
expect(calculateBackoff(2, { baseDelay })).toBe(4000)
|
|
46
|
+
expect(calculateBackoff(3, { baseDelay })).toBe(8000)
|
|
47
|
+
expect(calculateBackoff(4, { baseDelay })).toBe(16000)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('respects maxDelay cap', () => {
|
|
51
|
+
const options = { baseDelay: 1000, maxDelay: 5000 }
|
|
52
|
+
|
|
53
|
+
expect(calculateBackoff(0, options)).toBe(1000)
|
|
54
|
+
expect(calculateBackoff(1, options)).toBe(2000)
|
|
55
|
+
expect(calculateBackoff(2, options)).toBe(4000)
|
|
56
|
+
expect(calculateBackoff(3, options)).toBe(5000) // Capped
|
|
57
|
+
expect(calculateBackoff(10, options)).toBe(5000) // Still capped
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('supports custom multiplier', () => {
|
|
61
|
+
const options = { baseDelay: 1000, multiplier: 3 }
|
|
62
|
+
|
|
63
|
+
expect(calculateBackoff(0, options)).toBe(1000)
|
|
64
|
+
expect(calculateBackoff(1, options)).toBe(3000)
|
|
65
|
+
expect(calculateBackoff(2, options)).toBe(9000)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('RetryPolicy', () => {
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
vi.useFakeTimers()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
vi.useRealTimers()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('retries on failure with exponential delays', async () => {
|
|
79
|
+
const attempts: number[] = []
|
|
80
|
+
let attemptCount = 0
|
|
81
|
+
|
|
82
|
+
const policy = new RetryPolicy({
|
|
83
|
+
maxRetries: 3,
|
|
84
|
+
baseDelay: 1000,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const operation = vi.fn(async () => {
|
|
88
|
+
attempts.push(Date.now())
|
|
89
|
+
attemptCount++
|
|
90
|
+
if (attemptCount < 3) {
|
|
91
|
+
throw new Error('Temporary failure')
|
|
92
|
+
}
|
|
93
|
+
return 'success'
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const promise = policy.execute(operation)
|
|
97
|
+
|
|
98
|
+
// First attempt fails immediately
|
|
99
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
100
|
+
|
|
101
|
+
// Wait for first retry delay (1s)
|
|
102
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
103
|
+
|
|
104
|
+
// Wait for second retry delay (2s)
|
|
105
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
106
|
+
|
|
107
|
+
const result = await promise
|
|
108
|
+
expect(result).toBe('success')
|
|
109
|
+
expect(operation).toHaveBeenCalledTimes(3)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('respects maxRetries limit', async () => {
|
|
113
|
+
// Use real timers with very short delays for this test
|
|
114
|
+
vi.useRealTimers()
|
|
115
|
+
|
|
116
|
+
const policy = new RetryPolicy({
|
|
117
|
+
maxRetries: 2,
|
|
118
|
+
baseDelay: 10, // Very short delays for fast test
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const operation = vi.fn(async () => {
|
|
122
|
+
throw new Error('Always fails')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
await expect(policy.execute(operation)).rejects.toThrow('Always fails')
|
|
126
|
+
expect(operation).toHaveBeenCalledTimes(3) // Initial + 2 retries
|
|
127
|
+
|
|
128
|
+
// Restore fake timers for subsequent tests
|
|
129
|
+
vi.useFakeTimers()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('stops retrying on success', async () => {
|
|
133
|
+
const policy = new RetryPolicy({
|
|
134
|
+
maxRetries: 5,
|
|
135
|
+
baseDelay: 100,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
let attemptCount = 0
|
|
139
|
+
const operation = vi.fn(async () => {
|
|
140
|
+
attemptCount++
|
|
141
|
+
if (attemptCount === 1) {
|
|
142
|
+
throw new Error('First attempt fails')
|
|
143
|
+
}
|
|
144
|
+
return 'success'
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const promise = policy.execute(operation)
|
|
148
|
+
|
|
149
|
+
// Advance to first retry
|
|
150
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
151
|
+
|
|
152
|
+
const result = await promise
|
|
153
|
+
expect(result).toBe('success')
|
|
154
|
+
expect(operation).toHaveBeenCalledTimes(2)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('provides attempt info to operation', async () => {
|
|
158
|
+
const policy = new RetryPolicy({
|
|
159
|
+
maxRetries: 2,
|
|
160
|
+
baseDelay: 100,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const attemptInfos: { attempt: number; maxRetries: number }[] = []
|
|
164
|
+
const operation = vi.fn(async (info: { attempt: number; maxRetries: number }) => {
|
|
165
|
+
attemptInfos.push(info)
|
|
166
|
+
if (info.attempt < 2) {
|
|
167
|
+
throw new Error('Retry needed')
|
|
168
|
+
}
|
|
169
|
+
return 'success'
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const promise = policy.execute(operation)
|
|
173
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
174
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
175
|
+
await promise
|
|
176
|
+
|
|
177
|
+
expect(attemptInfos).toEqual([
|
|
178
|
+
{ attempt: 0, maxRetries: 2 },
|
|
179
|
+
{ attempt: 1, maxRetries: 2 },
|
|
180
|
+
{ attempt: 2, maxRetries: 2 },
|
|
181
|
+
])
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// 2. JITTER TESTS
|
|
188
|
+
// ============================================================================
|
|
189
|
+
|
|
190
|
+
describe('Jitter', () => {
|
|
191
|
+
it('adds randomness within +-20% bounds', () => {
|
|
192
|
+
const baseDelay = 1000
|
|
193
|
+
const jitterFactor = 0.2 // +-20%
|
|
194
|
+
|
|
195
|
+
// Generate 100 samples
|
|
196
|
+
const samples: number[] = []
|
|
197
|
+
for (let i = 0; i < 100; i++) {
|
|
198
|
+
const delay = calculateBackoff(0, {
|
|
199
|
+
baseDelay,
|
|
200
|
+
jitter: jitterFactor,
|
|
201
|
+
})
|
|
202
|
+
samples.push(delay)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// All samples should be within bounds
|
|
206
|
+
const minExpected = baseDelay * (1 - jitterFactor) // 800
|
|
207
|
+
const maxExpected = baseDelay * (1 + jitterFactor) // 1200
|
|
208
|
+
|
|
209
|
+
samples.forEach((delay) => {
|
|
210
|
+
expect(delay).toBeGreaterThanOrEqual(minExpected)
|
|
211
|
+
expect(delay).toBeLessThanOrEqual(maxExpected)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// Should have variance (not all same value)
|
|
215
|
+
const uniqueValues = new Set(samples)
|
|
216
|
+
expect(uniqueValues.size).toBeGreaterThan(1)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('applies jitter consistently across retry attempts', () => {
|
|
220
|
+
const options = {
|
|
221
|
+
baseDelay: 1000,
|
|
222
|
+
jitter: 0.2,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Each attempt level should have jittered values
|
|
226
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
227
|
+
const samples: number[] = []
|
|
228
|
+
for (let i = 0; i < 20; i++) {
|
|
229
|
+
samples.push(calculateBackoff(attempt, options))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const expectedBase = 1000 * Math.pow(2, attempt)
|
|
233
|
+
const minExpected = expectedBase * 0.8
|
|
234
|
+
const maxExpected = expectedBase * 1.2
|
|
235
|
+
|
|
236
|
+
samples.forEach((delay) => {
|
|
237
|
+
expect(delay).toBeGreaterThanOrEqual(minExpected)
|
|
238
|
+
expect(delay).toBeLessThanOrEqual(maxExpected)
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('supports full jitter strategy', () => {
|
|
244
|
+
const baseDelay = 1000
|
|
245
|
+
|
|
246
|
+
const samples: number[] = []
|
|
247
|
+
for (let i = 0; i < 100; i++) {
|
|
248
|
+
const delay = calculateBackoff(0, {
|
|
249
|
+
baseDelay,
|
|
250
|
+
jitterStrategy: 'full',
|
|
251
|
+
})
|
|
252
|
+
samples.push(delay)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Full jitter: random value between 0 and calculated delay
|
|
256
|
+
samples.forEach((delay) => {
|
|
257
|
+
expect(delay).toBeGreaterThanOrEqual(0)
|
|
258
|
+
expect(delay).toBeLessThanOrEqual(baseDelay)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('supports decorrelated jitter strategy', () => {
|
|
263
|
+
const options = {
|
|
264
|
+
baseDelay: 1000,
|
|
265
|
+
jitterStrategy: 'decorrelated' as const,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Decorrelated jitter uses previous delay to calculate next
|
|
269
|
+
// delay = random(baseDelay, previousDelay * 3)
|
|
270
|
+
let prevDelay = options.baseDelay
|
|
271
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
272
|
+
const delay = calculateBackoff(attempt, { ...options, previousDelay: prevDelay })
|
|
273
|
+
expect(delay).toBeGreaterThanOrEqual(options.baseDelay)
|
|
274
|
+
expect(delay).toBeLessThanOrEqual(prevDelay * 3)
|
|
275
|
+
prevDelay = delay
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// ============================================================================
|
|
281
|
+
// 3. CIRCUIT BREAKER TESTS
|
|
282
|
+
// ============================================================================
|
|
283
|
+
|
|
284
|
+
describe('CircuitBreaker', () => {
|
|
285
|
+
beforeEach(() => {
|
|
286
|
+
vi.useFakeTimers()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
afterEach(() => {
|
|
290
|
+
vi.useRealTimers()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('opens after N consecutive failures', async () => {
|
|
294
|
+
const breaker = new CircuitBreaker({
|
|
295
|
+
failureThreshold: 3,
|
|
296
|
+
resetTimeout: 10000,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const failingOperation = vi.fn(async () => {
|
|
300
|
+
throw new Error('Service unavailable')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// First 3 failures should go through
|
|
304
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow()
|
|
305
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow()
|
|
306
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow()
|
|
307
|
+
|
|
308
|
+
expect(breaker.state).toBe('open')
|
|
309
|
+
|
|
310
|
+
// Fourth call should fail fast without calling operation
|
|
311
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow(CircuitOpenError)
|
|
312
|
+
expect(failingOperation).toHaveBeenCalledTimes(3) // Not 4
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('stays open for configured duration', async () => {
|
|
316
|
+
const breaker = new CircuitBreaker({
|
|
317
|
+
failureThreshold: 2,
|
|
318
|
+
resetTimeout: 5000,
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const failingOperation = vi.fn(async () => {
|
|
322
|
+
throw new Error('Fail')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// Open the circuit
|
|
326
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow()
|
|
327
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow()
|
|
328
|
+
|
|
329
|
+
expect(breaker.state).toBe('open')
|
|
330
|
+
|
|
331
|
+
// Still open after 4 seconds
|
|
332
|
+
await vi.advanceTimersByTimeAsync(4000)
|
|
333
|
+
expect(breaker.state).toBe('open')
|
|
334
|
+
|
|
335
|
+
// Transitions to half-open after 5 seconds
|
|
336
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
337
|
+
expect(breaker.state).toBe('half-open')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('allows single test request in half-open state', async () => {
|
|
341
|
+
const breaker = new CircuitBreaker({
|
|
342
|
+
failureThreshold: 2,
|
|
343
|
+
resetTimeout: 1000,
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
const failingOperation = vi.fn(async () => {
|
|
347
|
+
throw new Error('Fail')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
// Open the circuit
|
|
351
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow()
|
|
352
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow()
|
|
353
|
+
|
|
354
|
+
// Transition to half-open
|
|
355
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
356
|
+
expect(breaker.state).toBe('half-open')
|
|
357
|
+
|
|
358
|
+
// Should allow one test request
|
|
359
|
+
const successOperation = vi.fn(async () => 'success')
|
|
360
|
+
const result = await breaker.execute(successOperation)
|
|
361
|
+
|
|
362
|
+
expect(result).toBe('success')
|
|
363
|
+
expect(successOperation).toHaveBeenCalledTimes(1)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('closes after successful request in half-open', async () => {
|
|
367
|
+
const breaker = new CircuitBreaker({
|
|
368
|
+
failureThreshold: 2,
|
|
369
|
+
resetTimeout: 1000,
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
const failingOperation = vi.fn(async () => {
|
|
373
|
+
throw new Error('Fail')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
// Open the circuit
|
|
377
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow()
|
|
378
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow()
|
|
379
|
+
|
|
380
|
+
// Transition to half-open
|
|
381
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
382
|
+
|
|
383
|
+
// Successful request closes the circuit
|
|
384
|
+
const successOperation = vi.fn(async () => 'success')
|
|
385
|
+
await breaker.execute(successOperation)
|
|
386
|
+
|
|
387
|
+
expect(breaker.state).toBe('closed')
|
|
388
|
+
|
|
389
|
+
// Should now allow normal operations
|
|
390
|
+
await breaker.execute(successOperation)
|
|
391
|
+
await breaker.execute(successOperation)
|
|
392
|
+
expect(successOperation).toHaveBeenCalledTimes(3)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('reopens if half-open request fails', async () => {
|
|
396
|
+
const breaker = new CircuitBreaker({
|
|
397
|
+
failureThreshold: 2,
|
|
398
|
+
resetTimeout: 1000,
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
const failingOperation = vi.fn(async () => {
|
|
402
|
+
throw new Error('Fail')
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
// Open the circuit
|
|
406
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow()
|
|
407
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow()
|
|
408
|
+
|
|
409
|
+
// Transition to half-open
|
|
410
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
411
|
+
expect(breaker.state).toBe('half-open')
|
|
412
|
+
|
|
413
|
+
// Failed test request reopens circuit
|
|
414
|
+
await expect(breaker.execute(failingOperation)).rejects.toThrow()
|
|
415
|
+
expect(breaker.state).toBe('open')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('resets failure count on success', async () => {
|
|
419
|
+
const breaker = new CircuitBreaker({
|
|
420
|
+
failureThreshold: 3,
|
|
421
|
+
resetTimeout: 1000,
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
let shouldFail = true
|
|
425
|
+
const operation = vi.fn(async () => {
|
|
426
|
+
if (shouldFail) throw new Error('Fail')
|
|
427
|
+
return 'success'
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
// 2 failures
|
|
431
|
+
await expect(breaker.execute(operation)).rejects.toThrow()
|
|
432
|
+
await expect(breaker.execute(operation)).rejects.toThrow()
|
|
433
|
+
expect(breaker.failureCount).toBe(2)
|
|
434
|
+
|
|
435
|
+
// Success resets count
|
|
436
|
+
shouldFail = false
|
|
437
|
+
await breaker.execute(operation)
|
|
438
|
+
expect(breaker.failureCount).toBe(0)
|
|
439
|
+
|
|
440
|
+
// Now need 3 more failures to open
|
|
441
|
+
shouldFail = true
|
|
442
|
+
await expect(breaker.execute(operation)).rejects.toThrow()
|
|
443
|
+
expect(breaker.state).toBe('closed')
|
|
444
|
+
expect(breaker.failureCount).toBe(1)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('provides circuit breaker metrics', () => {
|
|
448
|
+
const breaker = new CircuitBreaker({
|
|
449
|
+
failureThreshold: 3,
|
|
450
|
+
resetTimeout: 1000,
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
const metrics = breaker.getMetrics()
|
|
454
|
+
|
|
455
|
+
expect(metrics).toMatchObject({
|
|
456
|
+
state: 'closed',
|
|
457
|
+
failureCount: 0,
|
|
458
|
+
successCount: 0,
|
|
459
|
+
lastFailure: null,
|
|
460
|
+
lastSuccess: null,
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
// ============================================================================
|
|
466
|
+
// 4. FALLBACK MODELS TESTS
|
|
467
|
+
// ============================================================================
|
|
468
|
+
|
|
469
|
+
describe('FallbackChain', () => {
|
|
470
|
+
it('tries secondary model when primary fails', async () => {
|
|
471
|
+
const primaryModel = vi.fn(async () => {
|
|
472
|
+
throw new Error('Primary model unavailable')
|
|
473
|
+
})
|
|
474
|
+
const secondaryModel = vi.fn(async () => 'fallback result')
|
|
475
|
+
|
|
476
|
+
const chain = new FallbackChain([
|
|
477
|
+
{ name: 'primary', execute: primaryModel },
|
|
478
|
+
{ name: 'secondary', execute: secondaryModel },
|
|
479
|
+
])
|
|
480
|
+
|
|
481
|
+
const result = await chain.execute()
|
|
482
|
+
|
|
483
|
+
expect(result).toBe('fallback result')
|
|
484
|
+
expect(primaryModel).toHaveBeenCalledTimes(1)
|
|
485
|
+
expect(secondaryModel).toHaveBeenCalledTimes(1)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('supports fallback chain with multiple models', async () => {
|
|
489
|
+
const model1 = vi.fn(async () => {
|
|
490
|
+
throw new Error('Model 1 failed')
|
|
491
|
+
})
|
|
492
|
+
const model2 = vi.fn(async () => {
|
|
493
|
+
throw new Error('Model 2 failed')
|
|
494
|
+
})
|
|
495
|
+
const model3 = vi.fn(async () => 'model 3 success')
|
|
496
|
+
const model4 = vi.fn(async () => 'model 4 unused')
|
|
497
|
+
|
|
498
|
+
const chain = new FallbackChain([
|
|
499
|
+
{ name: 'sonnet', execute: model1 },
|
|
500
|
+
{ name: 'opus', execute: model2 },
|
|
501
|
+
{ name: 'gpt-4o', execute: model3 },
|
|
502
|
+
{ name: 'gemini', execute: model4 },
|
|
503
|
+
])
|
|
504
|
+
|
|
505
|
+
const result = await chain.execute()
|
|
506
|
+
|
|
507
|
+
expect(result).toBe('model 3 success')
|
|
508
|
+
expect(model1).toHaveBeenCalledTimes(1)
|
|
509
|
+
expect(model2).toHaveBeenCalledTimes(1)
|
|
510
|
+
expect(model3).toHaveBeenCalledTimes(1)
|
|
511
|
+
expect(model4).not.toHaveBeenCalled()
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it('preserves original request parameters', async () => {
|
|
515
|
+
const capturedParams: unknown[] = []
|
|
516
|
+
|
|
517
|
+
const model1 = vi.fn(async (params: unknown) => {
|
|
518
|
+
capturedParams.push(params)
|
|
519
|
+
throw new Error('Failed')
|
|
520
|
+
})
|
|
521
|
+
const model2 = vi.fn(async (params: unknown) => {
|
|
522
|
+
capturedParams.push(params)
|
|
523
|
+
return 'success'
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
const chain = new FallbackChain([
|
|
527
|
+
{ name: 'primary', execute: model1 },
|
|
528
|
+
{ name: 'secondary', execute: model2 },
|
|
529
|
+
])
|
|
530
|
+
|
|
531
|
+
const requestParams = {
|
|
532
|
+
prompt: 'Test prompt',
|
|
533
|
+
temperature: 0.7,
|
|
534
|
+
maxTokens: 1000,
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
await chain.execute(requestParams)
|
|
538
|
+
|
|
539
|
+
expect(capturedParams).toEqual([requestParams, requestParams])
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('tracks fallback metrics', async () => {
|
|
543
|
+
const model1 = vi.fn(async () => {
|
|
544
|
+
throw new Error('Failed')
|
|
545
|
+
})
|
|
546
|
+
const model2 = vi.fn(async () => 'success')
|
|
547
|
+
|
|
548
|
+
const chain = new FallbackChain([
|
|
549
|
+
{ name: 'primary', execute: model1 },
|
|
550
|
+
{ name: 'secondary', execute: model2 },
|
|
551
|
+
])
|
|
552
|
+
|
|
553
|
+
await chain.execute()
|
|
554
|
+
|
|
555
|
+
const metrics = chain.getMetrics()
|
|
556
|
+
|
|
557
|
+
expect(metrics.attempts).toBe(2)
|
|
558
|
+
expect(metrics.successfulModel).toBe('secondary')
|
|
559
|
+
expect(metrics.failedModels).toEqual(['primary'])
|
|
560
|
+
expect(metrics.totalDuration).toBeGreaterThanOrEqual(0)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('throws when all models fail', async () => {
|
|
564
|
+
const model1 = vi.fn(async () => {
|
|
565
|
+
throw new Error('Model 1 failed')
|
|
566
|
+
})
|
|
567
|
+
const model2 = vi.fn(async () => {
|
|
568
|
+
throw new Error('Model 2 failed')
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
const chain = new FallbackChain([
|
|
572
|
+
{ name: 'model1', execute: model1 },
|
|
573
|
+
{ name: 'model2', execute: model2 },
|
|
574
|
+
])
|
|
575
|
+
|
|
576
|
+
await expect(chain.execute()).rejects.toThrow('All fallback models failed')
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('supports conditional fallback based on error type', async () => {
|
|
580
|
+
const model1 = vi.fn(async () => {
|
|
581
|
+
throw new RateLimitError('Rate limited')
|
|
582
|
+
})
|
|
583
|
+
const model2 = vi.fn(async () => 'success')
|
|
584
|
+
|
|
585
|
+
const chain = new FallbackChain(
|
|
586
|
+
[
|
|
587
|
+
{ name: 'model1', execute: model1 },
|
|
588
|
+
{ name: 'model2', execute: model2 },
|
|
589
|
+
],
|
|
590
|
+
{
|
|
591
|
+
shouldFallback: (error) => error instanceof RateLimitError,
|
|
592
|
+
}
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
const result = await chain.execute()
|
|
596
|
+
expect(result).toBe('success')
|
|
597
|
+
|
|
598
|
+
// Non-retryable errors should not trigger fallback
|
|
599
|
+
const model3 = vi.fn(async () => {
|
|
600
|
+
throw new NonRetryableError('Invalid input')
|
|
601
|
+
})
|
|
602
|
+
const model4 = vi.fn(async () => 'unused')
|
|
603
|
+
|
|
604
|
+
const chain2 = new FallbackChain(
|
|
605
|
+
[
|
|
606
|
+
{ name: 'model3', execute: model3 },
|
|
607
|
+
{ name: 'model4', execute: model4 },
|
|
608
|
+
],
|
|
609
|
+
{
|
|
610
|
+
shouldFallback: (error) => !(error instanceof NonRetryableError),
|
|
611
|
+
}
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
await expect(chain2.execute()).rejects.toThrow(NonRetryableError)
|
|
615
|
+
expect(model4).not.toHaveBeenCalled()
|
|
616
|
+
})
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
// ============================================================================
|
|
620
|
+
// 5. PARTIAL RETRY FOR BATCH ITEMS TESTS
|
|
621
|
+
// ============================================================================
|
|
622
|
+
|
|
623
|
+
describe('Partial Retry for Batch Items', () => {
|
|
624
|
+
beforeEach(() => {
|
|
625
|
+
vi.useFakeTimers()
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
afterEach(() => {
|
|
629
|
+
vi.useRealTimers()
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('retries only failed items in a batch', async () => {
|
|
633
|
+
const batchProcessor = vi.fn(async (items: string[]) => {
|
|
634
|
+
return items.map((item) => {
|
|
635
|
+
if (item === 'fail') {
|
|
636
|
+
return { success: false, error: new Error('Item failed'), item }
|
|
637
|
+
}
|
|
638
|
+
return { success: true, result: `processed-${item}`, item }
|
|
639
|
+
})
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
const policy = new RetryPolicy({
|
|
643
|
+
maxRetries: 2,
|
|
644
|
+
baseDelay: 100,
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
const items = ['a', 'fail', 'c', 'fail', 'e']
|
|
648
|
+
|
|
649
|
+
const promise = policy.executeBatch(items, batchProcessor)
|
|
650
|
+
|
|
651
|
+
// First batch processes all items
|
|
652
|
+
// Then retry processes only failed items
|
|
653
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
654
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
655
|
+
|
|
656
|
+
const results = await promise
|
|
657
|
+
|
|
658
|
+
// Should have all results
|
|
659
|
+
expect(results.length).toBe(5)
|
|
660
|
+
|
|
661
|
+
// Successful items processed once
|
|
662
|
+
expect(results.filter((r) => r.success).length).toBeGreaterThanOrEqual(3)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it('respects per-item retry limits', async () => {
|
|
666
|
+
const attemptCounts = new Map<string, number>()
|
|
667
|
+
|
|
668
|
+
const batchProcessor = vi.fn(async (items: string[]) => {
|
|
669
|
+
return items.map((item) => {
|
|
670
|
+
const count = (attemptCounts.get(item) || 0) + 1
|
|
671
|
+
attemptCounts.set(item, count)
|
|
672
|
+
|
|
673
|
+
if (item === 'always-fail') {
|
|
674
|
+
return { success: false, error: new Error('Permanent failure'), item }
|
|
675
|
+
}
|
|
676
|
+
return { success: true, result: `done-${item}`, item }
|
|
677
|
+
})
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
const policy = new RetryPolicy({
|
|
681
|
+
maxRetries: 3,
|
|
682
|
+
baseDelay: 100,
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
const items = ['ok', 'always-fail']
|
|
686
|
+
|
|
687
|
+
const promise = policy.executeBatch(items, batchProcessor)
|
|
688
|
+
await vi.runAllTimersAsync()
|
|
689
|
+
|
|
690
|
+
const results = await promise
|
|
691
|
+
|
|
692
|
+
// 'always-fail' should have been attempted maxRetries + 1 times
|
|
693
|
+
expect(attemptCounts.get('always-fail')).toBe(4)
|
|
694
|
+
// 'ok' should have been attempted only once
|
|
695
|
+
expect(attemptCounts.get('ok')).toBe(1)
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
it('combines results from multiple retry rounds', async () => {
|
|
699
|
+
let callCount = 0
|
|
700
|
+
|
|
701
|
+
const batchProcessor = vi.fn(async (items: string[]) => {
|
|
702
|
+
callCount++
|
|
703
|
+
return items.map((item) => {
|
|
704
|
+
// 'flaky' succeeds on second attempt
|
|
705
|
+
if (item === 'flaky' && callCount === 1) {
|
|
706
|
+
return { success: false, error: new Error('Transient'), item }
|
|
707
|
+
}
|
|
708
|
+
return { success: true, result: `result-${item}`, item }
|
|
709
|
+
})
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
const policy = new RetryPolicy({
|
|
713
|
+
maxRetries: 2,
|
|
714
|
+
baseDelay: 100,
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
const items = ['stable', 'flaky']
|
|
718
|
+
|
|
719
|
+
const promise = policy.executeBatch(items, batchProcessor)
|
|
720
|
+
await vi.runAllTimersAsync()
|
|
721
|
+
|
|
722
|
+
const results = await promise
|
|
723
|
+
|
|
724
|
+
expect(results.every((r) => r.success)).toBe(true)
|
|
725
|
+
expect(results.find((r) => r.item === 'stable')?.result).toBe('result-stable')
|
|
726
|
+
expect(results.find((r) => r.item === 'flaky')?.result).toBe('result-flaky')
|
|
727
|
+
})
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
// ============================================================================
|
|
731
|
+
// 6. ERROR CLASSIFICATION TESTS
|
|
732
|
+
// ============================================================================
|
|
733
|
+
|
|
734
|
+
describe('Error Classification', () => {
|
|
735
|
+
describe('classifyError', () => {
|
|
736
|
+
it('classifies network errors', () => {
|
|
737
|
+
const errors = [
|
|
738
|
+
new Error('ECONNREFUSED'),
|
|
739
|
+
new Error('ETIMEDOUT'),
|
|
740
|
+
new Error('ENOTFOUND'),
|
|
741
|
+
new Error('socket hang up'),
|
|
742
|
+
new Error('Network request failed'),
|
|
743
|
+
new TypeError('fetch failed'),
|
|
744
|
+
]
|
|
745
|
+
|
|
746
|
+
errors.forEach((error) => {
|
|
747
|
+
const category = classifyError(error)
|
|
748
|
+
expect(category).toBe(ErrorCategory.Network)
|
|
749
|
+
})
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
it('classifies rate limit errors', () => {
|
|
753
|
+
const errors = [
|
|
754
|
+
new Error('Rate limit exceeded'),
|
|
755
|
+
new Error('429 Too Many Requests'),
|
|
756
|
+
new Error('quota exceeded'),
|
|
757
|
+
Object.assign(new Error('Rate limited'), { status: 429 }),
|
|
758
|
+
]
|
|
759
|
+
|
|
760
|
+
errors.forEach((error) => {
|
|
761
|
+
const category = classifyError(error)
|
|
762
|
+
expect(category).toBe(ErrorCategory.RateLimit)
|
|
763
|
+
})
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it('classifies invalid input errors', () => {
|
|
767
|
+
const errors = [
|
|
768
|
+
new Error('Invalid JSON'),
|
|
769
|
+
new Error('400 Bad Request'),
|
|
770
|
+
new Error('Validation failed'),
|
|
771
|
+
Object.assign(new Error('Invalid'), { status: 400 }),
|
|
772
|
+
Object.assign(new Error('Unprocessable'), { status: 422 }),
|
|
773
|
+
]
|
|
774
|
+
|
|
775
|
+
errors.forEach((error) => {
|
|
776
|
+
const category = classifyError(error)
|
|
777
|
+
expect(category).toBe(ErrorCategory.InvalidInput)
|
|
778
|
+
})
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
it('classifies authentication errors', () => {
|
|
782
|
+
const errors = [
|
|
783
|
+
new Error('401 Unauthorized'),
|
|
784
|
+
new Error('403 Forbidden'),
|
|
785
|
+
new Error('Invalid API key'),
|
|
786
|
+
Object.assign(new Error('Auth failed'), { status: 401 }),
|
|
787
|
+
Object.assign(new Error('Not allowed'), { status: 403 }),
|
|
788
|
+
]
|
|
789
|
+
|
|
790
|
+
errors.forEach((error) => {
|
|
791
|
+
const category = classifyError(error)
|
|
792
|
+
expect(category).toBe(ErrorCategory.Authentication)
|
|
793
|
+
})
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('classifies server errors', () => {
|
|
797
|
+
const errors = [
|
|
798
|
+
new Error('500 Internal Server Error'),
|
|
799
|
+
new Error('502 Bad Gateway'),
|
|
800
|
+
new Error('503 Service Unavailable'),
|
|
801
|
+
new Error('504 Gateway Timeout'),
|
|
802
|
+
Object.assign(new Error('Server error'), { status: 500 }),
|
|
803
|
+
Object.assign(new Error('Unavailable'), { status: 503 }),
|
|
804
|
+
]
|
|
805
|
+
|
|
806
|
+
errors.forEach((error) => {
|
|
807
|
+
const category = classifyError(error)
|
|
808
|
+
expect(category).toBe(ErrorCategory.Server)
|
|
809
|
+
})
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it('classifies context length errors', () => {
|
|
813
|
+
const errors = [
|
|
814
|
+
new Error('context length exceeded'),
|
|
815
|
+
new Error('maximum context length'),
|
|
816
|
+
new Error('token limit exceeded'),
|
|
817
|
+
new Error("This model's maximum context length is 128000 tokens"),
|
|
818
|
+
]
|
|
819
|
+
|
|
820
|
+
errors.forEach((error) => {
|
|
821
|
+
const category = classifyError(error)
|
|
822
|
+
expect(category).toBe(ErrorCategory.ContextLength)
|
|
823
|
+
})
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
it('classifies unknown errors', () => {
|
|
827
|
+
const errors = [
|
|
828
|
+
new Error('Something went wrong'),
|
|
829
|
+
new Error('Unexpected error'),
|
|
830
|
+
new TypeError('Cannot read property'),
|
|
831
|
+
]
|
|
832
|
+
|
|
833
|
+
errors.forEach((error) => {
|
|
834
|
+
const category = classifyError(error)
|
|
835
|
+
expect(category).toBe(ErrorCategory.Unknown)
|
|
836
|
+
})
|
|
837
|
+
})
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
describe('Error retryability', () => {
|
|
841
|
+
it('marks network errors as retryable', () => {
|
|
842
|
+
const error = new NetworkError('Connection failed')
|
|
843
|
+
expect(error.retryable).toBe(true)
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
it('marks rate limit errors as retryable with delay', () => {
|
|
847
|
+
const error = new RateLimitError('Too many requests', { retryAfter: 5000 })
|
|
848
|
+
expect(error.retryable).toBe(true)
|
|
849
|
+
expect(error.retryAfter).toBe(5000)
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
it('marks invalid input errors as non-retryable', () => {
|
|
853
|
+
const error = new NonRetryableError('Invalid parameters')
|
|
854
|
+
expect(error.retryable).toBe(false)
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
it('extracts retry-after from headers', () => {
|
|
858
|
+
const error = RateLimitError.fromResponse({
|
|
859
|
+
status: 429,
|
|
860
|
+
headers: {
|
|
861
|
+
'retry-after': '30',
|
|
862
|
+
},
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
expect(error.retryAfter).toBe(30000) // Converted to ms
|
|
866
|
+
})
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
describe('Retry behavior based on error type', () => {
|
|
870
|
+
beforeEach(() => {
|
|
871
|
+
vi.useFakeTimers()
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
afterEach(() => {
|
|
875
|
+
vi.useRealTimers()
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
it('retries network errors', async () => {
|
|
879
|
+
let attempts = 0
|
|
880
|
+
const operation = vi.fn(async () => {
|
|
881
|
+
attempts++
|
|
882
|
+
if (attempts < 2) {
|
|
883
|
+
throw new NetworkError('Connection reset')
|
|
884
|
+
}
|
|
885
|
+
return 'success'
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
const policy = new RetryPolicy({
|
|
889
|
+
maxRetries: 3,
|
|
890
|
+
baseDelay: 100,
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
const promise = policy.execute(operation)
|
|
894
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
895
|
+
|
|
896
|
+
const result = await promise
|
|
897
|
+
expect(result).toBe('success')
|
|
898
|
+
expect(attempts).toBe(2)
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
it('respects rate limit retry-after', async () => {
|
|
902
|
+
let attempts = 0
|
|
903
|
+
const operation = vi.fn(async () => {
|
|
904
|
+
attempts++
|
|
905
|
+
if (attempts === 1) {
|
|
906
|
+
throw new RateLimitError('Rate limited', { retryAfter: 5000 })
|
|
907
|
+
}
|
|
908
|
+
return 'success'
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
const policy = new RetryPolicy({
|
|
912
|
+
maxRetries: 3,
|
|
913
|
+
baseDelay: 100,
|
|
914
|
+
respectRetryAfter: true,
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
const promise = policy.execute(operation)
|
|
918
|
+
|
|
919
|
+
// Should wait for retry-after duration (5s) instead of baseDelay (100ms)
|
|
920
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
921
|
+
expect(attempts).toBe(1) // Still waiting
|
|
922
|
+
|
|
923
|
+
await vi.advanceTimersByTimeAsync(4900)
|
|
924
|
+
expect(attempts).toBe(2) // Now retried
|
|
925
|
+
|
|
926
|
+
const result = await promise
|
|
927
|
+
expect(result).toBe('success')
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
it('does not retry non-retryable errors', async () => {
|
|
931
|
+
const operation = vi.fn(async () => {
|
|
932
|
+
throw new NonRetryableError('Invalid input - will never work')
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
const policy = new RetryPolicy({
|
|
936
|
+
maxRetries: 5,
|
|
937
|
+
baseDelay: 100,
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
await expect(policy.execute(operation)).rejects.toThrow(NonRetryableError)
|
|
941
|
+
expect(operation).toHaveBeenCalledTimes(1)
|
|
942
|
+
})
|
|
943
|
+
})
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
// ============================================================================
|
|
947
|
+
// 7. INTEGRATION: withRetry HELPER TESTS
|
|
948
|
+
// ============================================================================
|
|
949
|
+
|
|
950
|
+
describe('withRetry helper', () => {
|
|
951
|
+
beforeEach(() => {
|
|
952
|
+
vi.useFakeTimers()
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
afterEach(() => {
|
|
956
|
+
vi.useRealTimers()
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
it('wraps an async function with retry logic', async () => {
|
|
960
|
+
let attempts = 0
|
|
961
|
+
const unreliableFunction = async (x: number) => {
|
|
962
|
+
attempts++
|
|
963
|
+
if (attempts < 3) {
|
|
964
|
+
throw new Error('Not yet')
|
|
965
|
+
}
|
|
966
|
+
return x * 2
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const reliableFunction = withRetry(unreliableFunction, {
|
|
970
|
+
maxRetries: 3,
|
|
971
|
+
baseDelay: 100,
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
const promise = reliableFunction(5)
|
|
975
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
976
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
977
|
+
|
|
978
|
+
const result = await promise
|
|
979
|
+
expect(result).toBe(10)
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
it('preserves function signature', async () => {
|
|
983
|
+
const original = async (a: string, b: number): Promise<string> => {
|
|
984
|
+
return `${a}-${b}`
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const wrapped = withRetry(original, { maxRetries: 2, baseDelay: 100 })
|
|
988
|
+
|
|
989
|
+
const result = await wrapped('hello', 42)
|
|
990
|
+
expect(result).toBe('hello-42')
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
it('works with generator options', async () => {
|
|
994
|
+
let attempts = 0
|
|
995
|
+
|
|
996
|
+
const wrapped = withRetry(
|
|
997
|
+
async () => {
|
|
998
|
+
attempts++
|
|
999
|
+
if (attempts < 2) throw new Error('Retry')
|
|
1000
|
+
return 'done'
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
maxRetries: 5,
|
|
1004
|
+
baseDelay: 50,
|
|
1005
|
+
maxDelay: 1000,
|
|
1006
|
+
jitter: 0.1,
|
|
1007
|
+
}
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
const promise = wrapped()
|
|
1011
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
1012
|
+
|
|
1013
|
+
const result = await promise
|
|
1014
|
+
expect(result).toBe('done')
|
|
1015
|
+
})
|
|
1016
|
+
})
|