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.
Files changed (286) hide show
  1. package/.turbo/turbo-build.log +1 -4
  2. package/CHANGELOG.md +68 -1
  3. package/README.md +397 -157
  4. package/dist/ai-promise.d.ts +50 -3
  5. package/dist/ai-promise.d.ts.map +1 -1
  6. package/dist/ai-promise.js +410 -51
  7. package/dist/ai-promise.js.map +1 -1
  8. package/dist/ai-schemas.d.ts +56 -0
  9. package/dist/ai-schemas.d.ts.map +1 -0
  10. package/dist/ai-schemas.js +53 -0
  11. package/dist/ai-schemas.js.map +1 -0
  12. package/dist/ai.d.ts +16 -242
  13. package/dist/ai.d.ts.map +1 -1
  14. package/dist/ai.js +54 -837
  15. package/dist/ai.js.map +1 -1
  16. package/dist/batch/anthropic.d.ts +6 -4
  17. package/dist/batch/anthropic.d.ts.map +1 -1
  18. package/dist/batch/anthropic.js +83 -145
  19. package/dist/batch/anthropic.js.map +1 -1
  20. package/dist/batch/bedrock.d.ts +8 -30
  21. package/dist/batch/bedrock.d.ts.map +1 -1
  22. package/dist/batch/bedrock.js +155 -338
  23. package/dist/batch/bedrock.js.map +1 -1
  24. package/dist/batch/cloudflare.d.ts +8 -20
  25. package/dist/batch/cloudflare.d.ts.map +1 -1
  26. package/dist/batch/cloudflare.js +68 -189
  27. package/dist/batch/cloudflare.js.map +1 -1
  28. package/dist/batch/google.d.ts +6 -20
  29. package/dist/batch/google.d.ts.map +1 -1
  30. package/dist/batch/google.js +70 -238
  31. package/dist/batch/google.js.map +1 -1
  32. package/dist/batch/index.d.ts +4 -1
  33. package/dist/batch/index.d.ts.map +1 -1
  34. package/dist/batch/index.js +4 -1
  35. package/dist/batch/index.js.map +1 -1
  36. package/dist/batch/memory.d.ts +1 -1
  37. package/dist/batch/memory.d.ts.map +1 -1
  38. package/dist/batch/memory.js +14 -10
  39. package/dist/batch/memory.js.map +1 -1
  40. package/dist/batch/openai.d.ts +11 -14
  41. package/dist/batch/openai.d.ts.map +1 -1
  42. package/dist/batch/openai.js +52 -156
  43. package/dist/batch/openai.js.map +1 -1
  44. package/dist/batch/provider.d.ts +111 -0
  45. package/dist/batch/provider.d.ts.map +1 -0
  46. package/dist/batch/provider.js +233 -0
  47. package/dist/batch/provider.js.map +1 -0
  48. package/dist/batch-map.d.ts.map +1 -1
  49. package/dist/batch-map.js +23 -17
  50. package/dist/batch-map.js.map +1 -1
  51. package/dist/batch-queue.d.ts +65 -0
  52. package/dist/batch-queue.d.ts.map +1 -1
  53. package/dist/batch-queue.js +169 -14
  54. package/dist/batch-queue.js.map +1 -1
  55. package/dist/budget.d.ts +272 -0
  56. package/dist/budget.d.ts.map +1 -0
  57. package/dist/budget.js +513 -0
  58. package/dist/budget.js.map +1 -0
  59. package/dist/cache.d.ts +295 -0
  60. package/dist/cache.d.ts.map +1 -0
  61. package/dist/cache.js +433 -0
  62. package/dist/cache.js.map +1 -0
  63. package/dist/context.d.ts +42 -8
  64. package/dist/context.d.ts.map +1 -1
  65. package/dist/context.js +64 -62
  66. package/dist/context.js.map +1 -1
  67. package/dist/digital-objects-registry.d.ts +229 -0
  68. package/dist/digital-objects-registry.d.ts.map +1 -0
  69. package/dist/digital-objects-registry.js +617 -0
  70. package/dist/digital-objects-registry.js.map +1 -0
  71. package/dist/embeddings.d.ts +2 -2
  72. package/dist/embeddings.d.ts.map +1 -1
  73. package/dist/errors.d.ts +22 -0
  74. package/dist/errors.d.ts.map +1 -0
  75. package/dist/errors.js +35 -0
  76. package/dist/errors.js.map +1 -0
  77. package/dist/eval/runner.d.ts +10 -1
  78. package/dist/eval/runner.d.ts.map +1 -1
  79. package/dist/eval/runner.js +41 -35
  80. package/dist/eval/runner.js.map +1 -1
  81. package/dist/eval-log/in-memory.d.ts +34 -0
  82. package/dist/eval-log/in-memory.d.ts.map +1 -0
  83. package/dist/eval-log/in-memory.js +84 -0
  84. package/dist/eval-log/in-memory.js.map +1 -0
  85. package/dist/eval-log/index.d.ts +29 -0
  86. package/dist/eval-log/index.d.ts.map +1 -0
  87. package/dist/eval-log/index.js +39 -0
  88. package/dist/eval-log/index.js.map +1 -0
  89. package/dist/eval-log/types.d.ts +101 -0
  90. package/dist/eval-log/types.d.ts.map +1 -0
  91. package/dist/eval-log/types.js +16 -0
  92. package/dist/eval-log/types.js.map +1 -0
  93. package/dist/function-registry.d.ts +116 -0
  94. package/dist/function-registry.d.ts.map +1 -0
  95. package/dist/function-registry.js +546 -0
  96. package/dist/function-registry.js.map +1 -0
  97. package/dist/generate.d.ts +9 -3
  98. package/dist/generate.d.ts.map +1 -1
  99. package/dist/generate.js +18 -22
  100. package/dist/generate.js.map +1 -1
  101. package/dist/index.d.ts +35 -20
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +89 -42
  104. package/dist/index.js.map +1 -1
  105. package/dist/logger.d.ts +118 -0
  106. package/dist/logger.d.ts.map +1 -0
  107. package/dist/logger.js +187 -0
  108. package/dist/logger.js.map +1 -0
  109. package/dist/middleware/budget.d.ts +84 -0
  110. package/dist/middleware/budget.d.ts.map +1 -0
  111. package/dist/middleware/budget.js +110 -0
  112. package/dist/middleware/budget.js.map +1 -0
  113. package/dist/middleware/cache.d.ts +103 -0
  114. package/dist/middleware/cache.d.ts.map +1 -0
  115. package/dist/middleware/cache.js +228 -0
  116. package/dist/middleware/cache.js.map +1 -0
  117. package/dist/middleware/embed-cache.d.ts +99 -0
  118. package/dist/middleware/embed-cache.d.ts.map +1 -0
  119. package/dist/middleware/embed-cache.js +128 -0
  120. package/dist/middleware/embed-cache.js.map +1 -0
  121. package/dist/middleware/index.d.ts +11 -0
  122. package/dist/middleware/index.d.ts.map +1 -0
  123. package/dist/middleware/index.js +11 -0
  124. package/dist/middleware/index.js.map +1 -0
  125. package/dist/middleware/trace.d.ts +103 -0
  126. package/dist/middleware/trace.d.ts.map +1 -0
  127. package/dist/middleware/trace.js +176 -0
  128. package/dist/middleware/trace.js.map +1 -0
  129. package/dist/primitives.d.ts +120 -1
  130. package/dist/primitives.d.ts.map +1 -1
  131. package/dist/primitives.js +398 -26
  132. package/dist/primitives.js.map +1 -1
  133. package/dist/retry.d.ts +368 -0
  134. package/dist/retry.d.ts.map +1 -0
  135. package/dist/retry.js +646 -0
  136. package/dist/retry.js.map +1 -0
  137. package/dist/schema.d.ts.map +1 -1
  138. package/dist/schema.js +2 -10
  139. package/dist/schema.js.map +1 -1
  140. package/dist/telemetry.d.ts +128 -0
  141. package/dist/telemetry.d.ts.map +1 -0
  142. package/dist/telemetry.js +285 -0
  143. package/dist/telemetry.js.map +1 -0
  144. package/dist/template.d.ts.map +1 -1
  145. package/dist/template.js +6 -1
  146. package/dist/template.js.map +1 -1
  147. package/dist/tool-orchestration.d.ts +453 -0
  148. package/dist/tool-orchestration.d.ts.map +1 -0
  149. package/dist/tool-orchestration.js +763 -0
  150. package/dist/tool-orchestration.js.map +1 -0
  151. package/dist/type-guards.d.ts +28 -0
  152. package/dist/type-guards.d.ts.map +1 -0
  153. package/dist/type-guards.js +29 -0
  154. package/dist/type-guards.js.map +1 -0
  155. package/dist/types.d.ts +135 -17
  156. package/dist/types.d.ts.map +1 -1
  157. package/dist/types.js +36 -1
  158. package/dist/types.js.map +1 -1
  159. package/dist/wrap-for-v3.d.ts +80 -0
  160. package/dist/wrap-for-v3.d.ts.map +1 -0
  161. package/dist/wrap-for-v3.js +89 -0
  162. package/dist/wrap-for-v3.js.map +1 -0
  163. package/examples/00-quickstart.ts +232 -0
  164. package/examples/01-rag-chatbot.ts +212 -0
  165. package/examples/02-multi-agent-research.ts +290 -0
  166. package/examples/03-email-classification.ts +379 -0
  167. package/examples/04-content-moderation.ts +400 -0
  168. package/examples/05-document-extraction.ts +455 -0
  169. package/examples/06-streaming-chat-nextjs.ts +437 -0
  170. package/examples/07-cloudflare-worker.ts +483 -0
  171. package/examples/08-batch-processing.ts +491 -0
  172. package/examples/09-budget-constrained.ts +527 -0
  173. package/examples/10-tool-orchestration.ts +565 -0
  174. package/examples/11-retry-resilience.ts +403 -0
  175. package/examples/12-caching-strategies.ts +422 -0
  176. package/examples/README.md +145 -0
  177. package/package.json +10 -6
  178. package/src/ai-promise.ts +528 -99
  179. package/src/ai-schemas.ts +122 -0
  180. package/src/ai.ts +69 -1153
  181. package/src/batch/anthropic.ts +96 -161
  182. package/src/batch/bedrock.ts +203 -454
  183. package/src/batch/cloudflare.ts +99 -282
  184. package/src/batch/google.ts +91 -297
  185. package/src/batch/index.ts +4 -1
  186. package/src/batch/memory.ts +15 -10
  187. package/src/batch/openai.ts +65 -193
  188. package/src/batch/provider.ts +336 -0
  189. package/src/batch-map.ts +29 -24
  190. package/src/batch-queue.ts +200 -11
  191. package/src/budget.ts +740 -0
  192. package/src/cache.ts +681 -0
  193. package/src/context.ts +122 -76
  194. package/src/digital-objects-registry.ts +750 -0
  195. package/src/errors.ts +37 -0
  196. package/src/eval/runner.ts +63 -38
  197. package/src/eval-log/in-memory.ts +90 -0
  198. package/src/eval-log/index.ts +46 -0
  199. package/src/eval-log/types.ts +110 -0
  200. package/src/function-registry.ts +671 -0
  201. package/src/generate.ts +33 -33
  202. package/src/index.ts +325 -49
  203. package/src/logger.ts +232 -0
  204. package/src/middleware/budget.ts +171 -0
  205. package/src/middleware/cache.ts +299 -0
  206. package/src/middleware/embed-cache.ts +195 -0
  207. package/src/middleware/index.ts +23 -0
  208. package/src/middleware/trace.ts +248 -0
  209. package/src/primitives.ts +589 -62
  210. package/src/retry.ts +902 -0
  211. package/src/schema.ts +8 -17
  212. package/src/telemetry.ts +403 -0
  213. package/src/template.ts +8 -4
  214. package/src/tool-orchestration.ts +1173 -0
  215. package/src/type-guards.ts +31 -0
  216. package/src/types.ts +164 -25
  217. package/src/wrap-for-v3.ts +105 -0
  218. package/test/ai-promise.test.ts +1080 -0
  219. package/test/ai-proxy.test.ts +1 -1
  220. package/test/backward-compat.test.ts +147 -0
  221. package/test/batch-autosubmit-errors.test.ts +610 -0
  222. package/test/batch-blog-posts.test.ts +87 -129
  223. package/test/budget-tracking.test.ts +800 -0
  224. package/test/cache.test.ts +712 -0
  225. package/test/context-isolation.test.ts +687 -0
  226. package/test/core-functions.test.ts +183 -579
  227. package/test/decide.test.ts +154 -322
  228. package/test/define.test.ts +211 -8
  229. package/test/digital-objects-registry.test.ts +760 -0
  230. package/test/embedding-cache-middleware.test.ts +140 -0
  231. package/test/evals/deterministic.eval.test.ts +376 -0
  232. package/test/generate-core.test.ts +140 -229
  233. package/test/implicit-batch.test.ts +22 -65
  234. package/test/json-parse-error-handling.test.ts +463 -0
  235. package/test/retry-policy-integration.test.ts +117 -0
  236. package/test/retry.test.ts +1016 -0
  237. package/test/schema.test.ts +55 -19
  238. package/test/streaming.test.ts +316 -0
  239. package/test/template.test.ts +1164 -0
  240. package/test/tool-orchestration.test.ts +1040 -0
  241. package/test/wrap-for-v3.test.ts +612 -0
  242. package/vitest.config.js +6 -0
  243. package/vitest.config.ts +20 -0
  244. package/dist/rpc/auth.d.ts +0 -69
  245. package/dist/rpc/auth.d.ts.map +0 -1
  246. package/dist/rpc/auth.js +0 -136
  247. package/dist/rpc/auth.js.map +0 -1
  248. package/dist/rpc/client.d.ts +0 -62
  249. package/dist/rpc/client.d.ts.map +0 -1
  250. package/dist/rpc/client.js +0 -103
  251. package/dist/rpc/client.js.map +0 -1
  252. package/dist/rpc/deferred.d.ts +0 -60
  253. package/dist/rpc/deferred.d.ts.map +0 -1
  254. package/dist/rpc/deferred.js +0 -96
  255. package/dist/rpc/deferred.js.map +0 -1
  256. package/dist/rpc/index.d.ts +0 -22
  257. package/dist/rpc/index.d.ts.map +0 -1
  258. package/dist/rpc/index.js +0 -38
  259. package/dist/rpc/index.js.map +0 -1
  260. package/dist/rpc/local.d.ts +0 -42
  261. package/dist/rpc/local.d.ts.map +0 -1
  262. package/dist/rpc/local.js +0 -50
  263. package/dist/rpc/local.js.map +0 -1
  264. package/dist/rpc/server.d.ts +0 -165
  265. package/dist/rpc/server.d.ts.map +0 -1
  266. package/dist/rpc/server.js +0 -405
  267. package/dist/rpc/server.js.map +0 -1
  268. package/dist/rpc/session.d.ts +0 -32
  269. package/dist/rpc/session.d.ts.map +0 -1
  270. package/dist/rpc/session.js +0 -43
  271. package/dist/rpc/session.js.map +0 -1
  272. package/dist/rpc/transport.d.ts +0 -306
  273. package/dist/rpc/transport.d.ts.map +0 -1
  274. package/dist/rpc/transport.js +0 -731
  275. package/dist/rpc/transport.js.map +0 -1
  276. package/src/batch/anthropic.js +0 -256
  277. package/src/batch/bedrock.js +0 -584
  278. package/src/batch/cloudflare.js +0 -287
  279. package/src/batch/google.js +0 -359
  280. package/src/batch/index.js +0 -30
  281. package/src/batch/memory.js +0 -187
  282. package/src/batch/openai.js +0 -402
  283. package/src/eval/index.js +0 -7
  284. package/src/eval/models.js +0 -119
  285. package/src/eval/runner.js +0 -147
  286. 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
+ })