@lobehub/chat 1.112.5 → 1.113.1

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 (40) hide show
  1. package/.vscode/settings.json +1 -1
  2. package/CHANGELOG.md +50 -0
  3. package/changelog/v1.json +10 -0
  4. package/package.json +3 -3
  5. package/packages/const/src/image.ts +9 -1
  6. package/packages/model-runtime/src/bfl/createImage.test.ts +846 -0
  7. package/packages/model-runtime/src/bfl/createImage.ts +279 -0
  8. package/packages/model-runtime/src/bfl/index.test.ts +269 -0
  9. package/packages/model-runtime/src/bfl/index.ts +49 -0
  10. package/packages/model-runtime/src/bfl/types.ts +113 -0
  11. package/packages/model-runtime/src/index.ts +1 -0
  12. package/packages/model-runtime/src/qwen/createImage.ts +37 -82
  13. package/packages/model-runtime/src/runtimeMap.ts +2 -0
  14. package/packages/model-runtime/src/utils/asyncifyPolling.test.ts +491 -0
  15. package/packages/model-runtime/src/utils/asyncifyPolling.ts +175 -0
  16. package/src/app/(backend)/api/webhooks/casdoor/route.ts +2 -1
  17. package/src/app/(backend)/api/webhooks/clerk/route.ts +2 -1
  18. package/src/app/(backend)/api/webhooks/logto/route.ts +2 -1
  19. package/src/app/(backend)/webapi/user/avatar/[id]/[image]/route.ts +2 -1
  20. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/index.tsx +1 -1
  21. package/src/config/aiModels/bfl.ts +145 -0
  22. package/src/config/aiModels/index.ts +3 -0
  23. package/src/config/llm.ts +6 -1
  24. package/src/config/modelProviders/bfl.ts +21 -0
  25. package/src/config/modelProviders/index.ts +3 -0
  26. package/src/database/server/models/ragEval/dataset.ts +9 -7
  27. package/src/database/server/models/ragEval/datasetRecord.ts +12 -10
  28. package/src/database/server/models/ragEval/evaluation.ts +10 -8
  29. package/src/database/server/models/ragEval/evaluationRecord.ts +11 -9
  30. package/src/server/routers/async/file.ts +1 -1
  31. package/src/server/routers/async/ragEval.ts +4 -4
  32. package/src/server/routers/lambda/chunk.ts +1 -1
  33. package/src/server/routers/lambda/ragEval.ts +4 -4
  34. package/src/server/routers/lambda/user.ts +1 -1
  35. package/src/server/services/chunk/index.ts +2 -2
  36. package/src/server/services/nextAuthUser/index.test.ts +1 -1
  37. package/src/server/services/nextAuthUser/index.ts +6 -4
  38. package/src/server/services/user/index.test.ts +3 -1
  39. package/src/server/services/user/index.ts +14 -8
  40. package/src/store/image/slices/generationConfig/hooks.ts +6 -10
@@ -3,6 +3,7 @@ export { LobeAzureAI } from './azureai';
3
3
  export { LobeAzureOpenAI } from './azureOpenai';
4
4
  export * from './BaseAI';
5
5
  export { LobeBedrockAI } from './bedrock';
6
+ export { LobeBflAI } from './bfl';
6
7
  export { LobeDeepSeekAI } from './deepseek';
7
8
  export * from './error';
8
9
  export { LobeGoogleAI } from './google';
@@ -1,6 +1,7 @@
1
1
  import createDebug from 'debug';
2
2
 
3
3
  import { CreateImagePayload, CreateImageResponse } from '../types/image';
4
+ import { type TaskResult, asyncifyPolling } from '../utils/asyncifyPolling';
4
5
  import { AgentRuntimeError } from '../utils/createError';
5
6
  import { CreateImageOptions } from '../utils/openaiCompatibleFactory';
6
7
 
@@ -139,93 +140,47 @@ export async function createQwenImage(
139
140
  // 1. Create image generation task
140
141
  const taskId = await createImageTask(payload, apiKey);
141
142
 
142
- // 2. Poll task status until completion
143
- let taskStatus: QwenImageTaskResponse | null = null;
144
- let retries = 0;
145
- let consecutiveFailures = 0;
146
- const maxConsecutiveFailures = 3; // Allow up to 3 consecutive query failures
147
- // Using Infinity for maxRetries is safe because:
148
- // 1. Vercel runtime has execution time limits
149
- // 2. Qwen's API will eventually return FAILED status for timed-out tasks
150
- // 3. Our exponential backoff ensures reasonable retry intervals
151
- const maxRetries = Infinity;
152
- const initialRetryInterval = 500; // 500ms initial interval
153
- const maxRetryInterval = 5000; // 5 seconds max interval
154
- const backoffMultiplier = 1.5; // exponential backoff multiplier
155
-
156
- while (retries < maxRetries) {
157
- try {
158
- taskStatus = await queryTaskStatus(taskId, apiKey);
159
- consecutiveFailures = 0; // Reset consecutive failures on success
160
- } catch (error) {
161
- consecutiveFailures++;
162
- log(
163
- 'Failed to query task status (attempt %d/%d, consecutive failures: %d/%d): %O',
164
- retries + 1,
165
- maxRetries,
166
- consecutiveFailures,
167
- maxConsecutiveFailures,
168
- error,
169
- );
170
-
171
- // If we've failed too many times in a row, give up
172
- if (consecutiveFailures >= maxConsecutiveFailures) {
173
- throw new Error(
174
- `Failed to query task status after ${consecutiveFailures} consecutive attempts: ${error}`,
175
- );
143
+ // 2. Poll task status until completion using asyncifyPolling
144
+ const result = await asyncifyPolling<QwenImageTaskResponse, CreateImageResponse>({
145
+ checkStatus: (taskStatus: QwenImageTaskResponse): TaskResult<CreateImageResponse> => {
146
+ log('Task %s status: %s', taskId, taskStatus.output.task_status);
147
+
148
+ if (taskStatus.output.task_status === 'SUCCEEDED') {
149
+ if (!taskStatus.output.results || taskStatus.output.results.length === 0) {
150
+ return {
151
+ error: new Error('Task succeeded but no images generated'),
152
+ status: 'failed',
153
+ };
154
+ }
155
+
156
+ const imageUrl = taskStatus.output.results[0].url;
157
+ log('Image generated successfully: %s', imageUrl);
158
+
159
+ return {
160
+ data: { imageUrl },
161
+ status: 'success',
162
+ };
176
163
  }
177
164
 
178
- // Wait before retrying
179
- const currentRetryInterval = Math.min(
180
- initialRetryInterval * Math.pow(backoffMultiplier, retries),
181
- maxRetryInterval,
182
- );
183
- await new Promise((resolve) => {
184
- setTimeout(resolve, currentRetryInterval);
185
- });
186
- retries++;
187
- continue; // Skip the rest of the loop and retry
188
- }
189
-
190
- // At this point, taskStatus should not be null since we just got it successfully
191
- log(
192
- 'Task %s status: %s (attempt %d/%d)',
193
- taskId,
194
- taskStatus!.output.task_status,
195
- retries + 1,
196
- maxRetries,
197
- );
198
-
199
- if (taskStatus!.output.task_status === 'SUCCEEDED') {
200
- if (!taskStatus!.output.results || taskStatus!.output.results.length === 0) {
201
- throw new Error('Task succeeded but no images generated');
165
+ if (taskStatus.output.task_status === 'FAILED') {
166
+ const errorMessage = taskStatus.output.error_message || 'Image generation task failed';
167
+ return {
168
+ error: new Error(`Qwen image generation failed: ${errorMessage}`),
169
+ status: 'failed',
170
+ };
202
171
  }
203
172
 
204
- // Return the first generated image
205
- const imageUrl = taskStatus!.output.results[0].url;
206
- log('Image generated successfully: %s', imageUrl);
207
-
208
- return { imageUrl };
209
- } else if (taskStatus!.output.task_status === 'FAILED') {
210
- throw new Error(taskStatus!.output.error_message || 'Image generation task failed');
211
- }
212
-
213
- // Calculate dynamic retry interval with exponential backoff
214
- const currentRetryInterval = Math.min(
215
- initialRetryInterval * Math.pow(backoffMultiplier, retries),
216
- maxRetryInterval,
217
- );
218
-
219
- log('Waiting %dms before next retry', currentRetryInterval);
220
-
221
- // Wait before retrying
222
- await new Promise((resolve) => {
223
- setTimeout(resolve, currentRetryInterval);
224
- });
225
- retries++;
226
- }
173
+ // Continue polling for pending/running status or other unknown statuses
174
+ return { status: 'pending' };
175
+ },
176
+ logger: {
177
+ debug: (message: any, ...args: any[]) => log(message, ...args),
178
+ error: (message: any, ...args: any[]) => log(message, ...args),
179
+ },
180
+ pollingQuery: () => queryTaskStatus(taskId, apiKey),
181
+ });
227
182
 
228
- throw new Error(`Image generation timeout after ${maxRetries} attempts`);
183
+ return result;
229
184
  } catch (error) {
230
185
  log('Error in createQwenImage: %O', error);
231
186
 
@@ -7,6 +7,7 @@ import { LobeAzureOpenAI } from './azureOpenai';
7
7
  import { LobeAzureAI } from './azureai';
8
8
  import { LobeBaichuanAI } from './baichuan';
9
9
  import { LobeBedrockAI } from './bedrock';
10
+ import { LobeBflAI } from './bfl';
10
11
  import { LobeCloudflareAI } from './cloudflare';
11
12
  import { LobeCohereAI } from './cohere';
12
13
  import { LobeDeepSeekAI } from './deepseek';
@@ -65,6 +66,7 @@ export const providerRuntimeMap = {
65
66
  azureai: LobeAzureAI,
66
67
  baichuan: LobeBaichuanAI,
67
68
  bedrock: LobeBedrockAI,
69
+ bfl: LobeBflAI,
68
70
  cloudflare: LobeCloudflareAI,
69
71
  cohere: LobeCohereAI,
70
72
  deepseek: LobeDeepSeekAI,
@@ -0,0 +1,491 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { type TaskResult, asyncifyPolling } from './asyncifyPolling';
4
+
5
+ describe('asyncifyPolling', () => {
6
+ describe('basic functionality', () => {
7
+ it('should return data when task succeeds immediately', async () => {
8
+ const mockTask = vi.fn().mockResolvedValue({ status: 'completed', data: 'result' });
9
+ const mockCheckStatus = vi.fn().mockReturnValue({
10
+ status: 'success',
11
+ data: 'result',
12
+ } as TaskResult<string>);
13
+
14
+ const result = await asyncifyPolling({
15
+ pollingQuery: mockTask,
16
+ checkStatus: mockCheckStatus,
17
+ });
18
+
19
+ expect(result).toBe('result');
20
+ expect(mockTask).toHaveBeenCalledTimes(1);
21
+ expect(mockCheckStatus).toHaveBeenCalledTimes(1);
22
+ });
23
+
24
+ it('should poll multiple times until success', async () => {
25
+ const mockTask = vi
26
+ .fn()
27
+ .mockResolvedValueOnce({ status: 'pending' })
28
+ .mockResolvedValueOnce({ status: 'pending' })
29
+ .mockResolvedValueOnce({ status: 'completed', data: 'final-result' });
30
+
31
+ const mockCheckStatus = vi
32
+ .fn()
33
+ .mockReturnValueOnce({ status: 'pending' })
34
+ .mockReturnValueOnce({ status: 'pending' })
35
+ .mockReturnValueOnce({ status: 'success', data: 'final-result' });
36
+
37
+ const result = await asyncifyPolling({
38
+ pollingQuery: mockTask,
39
+ checkStatus: mockCheckStatus,
40
+ initialInterval: 10, // fast test
41
+ });
42
+
43
+ expect(result).toBe('final-result');
44
+ expect(mockTask).toHaveBeenCalledTimes(3);
45
+ expect(mockCheckStatus).toHaveBeenCalledTimes(3);
46
+ });
47
+
48
+ it('should throw error when task fails', async () => {
49
+ const mockTask = vi.fn().mockResolvedValue({ status: 'failed', error: 'Task failed' });
50
+ const mockCheckStatus = vi.fn().mockReturnValue({
51
+ status: 'failed',
52
+ error: new Error('Task failed'),
53
+ });
54
+
55
+ await expect(
56
+ asyncifyPolling({
57
+ pollingQuery: mockTask,
58
+ checkStatus: mockCheckStatus,
59
+ }),
60
+ ).rejects.toThrow('Task failed');
61
+
62
+ expect(mockTask).toHaveBeenCalledTimes(1);
63
+ });
64
+
65
+ it('should handle pending status correctly', async () => {
66
+ const mockTask = vi
67
+ .fn()
68
+ .mockResolvedValueOnce({ status: 'processing' })
69
+ .mockResolvedValueOnce({ status: 'done' });
70
+
71
+ const mockCheckStatus = vi
72
+ .fn()
73
+ .mockReturnValueOnce({ status: 'pending' })
74
+ .mockReturnValueOnce({ status: 'success', data: 'completed' });
75
+
76
+ const result = await asyncifyPolling({
77
+ pollingQuery: mockTask,
78
+ checkStatus: mockCheckStatus,
79
+ initialInterval: 10,
80
+ });
81
+
82
+ expect(result).toBe('completed');
83
+ expect(mockTask).toHaveBeenCalledTimes(2);
84
+ });
85
+ });
86
+
87
+ describe('retry mechanism', () => {
88
+ it('should retry with exponential backoff', async () => {
89
+ const startTime = Date.now();
90
+ const mockTask = vi
91
+ .fn()
92
+ .mockResolvedValueOnce({ status: 'pending' })
93
+ .mockResolvedValueOnce({ status: 'pending' })
94
+ .mockResolvedValueOnce({ status: 'success' });
95
+
96
+ const mockCheckStatus = vi
97
+ .fn()
98
+ .mockReturnValueOnce({ status: 'pending' })
99
+ .mockReturnValueOnce({ status: 'pending' })
100
+ .mockReturnValueOnce({ status: 'success', data: 'done' });
101
+
102
+ await asyncifyPolling({
103
+ pollingQuery: mockTask,
104
+ checkStatus: mockCheckStatus,
105
+ initialInterval: 50,
106
+ backoffMultiplier: 2,
107
+ maxInterval: 200,
108
+ });
109
+
110
+ const elapsed = Date.now() - startTime;
111
+ // Should wait at least 50ms + 100ms = 150ms
112
+ expect(elapsed).toBeGreaterThan(140);
113
+ });
114
+
115
+ it('should respect maxInterval limit', async () => {
116
+ const intervals: number[] = [];
117
+ const originalSetTimeout = global.setTimeout;
118
+
119
+ global.setTimeout = vi.fn((callback, delay) => {
120
+ intervals.push(delay as number);
121
+ return originalSetTimeout(callback, 1); // fast execution
122
+ }) as any;
123
+
124
+ const mockTask = vi
125
+ .fn()
126
+ .mockResolvedValueOnce({ status: 'pending' })
127
+ .mockResolvedValueOnce({ status: 'pending' })
128
+ .mockResolvedValueOnce({ status: 'pending' })
129
+ .mockResolvedValueOnce({ status: 'success' });
130
+
131
+ const mockCheckStatus = vi
132
+ .fn()
133
+ .mockReturnValueOnce({ status: 'pending' })
134
+ .mockReturnValueOnce({ status: 'pending' })
135
+ .mockReturnValueOnce({ status: 'pending' })
136
+ .mockReturnValueOnce({ status: 'success', data: 'done' });
137
+
138
+ await asyncifyPolling({
139
+ pollingQuery: mockTask,
140
+ checkStatus: mockCheckStatus,
141
+ initialInterval: 100,
142
+ backoffMultiplier: 3,
143
+ maxInterval: 200,
144
+ });
145
+
146
+ // Intervals should be: 100, 200 (capped), 200 (capped)
147
+ expect(intervals).toEqual([100, 200, 200]);
148
+
149
+ global.setTimeout = originalSetTimeout;
150
+ });
151
+
152
+ it('should stop after maxRetries', async () => {
153
+ const mockTask = vi.fn().mockResolvedValue({ status: 'pending' });
154
+ const mockCheckStatus = vi.fn().mockReturnValue({ status: 'pending' });
155
+
156
+ await expect(
157
+ asyncifyPolling({
158
+ pollingQuery: mockTask,
159
+ checkStatus: mockCheckStatus,
160
+ maxRetries: 3,
161
+ initialInterval: 1,
162
+ }),
163
+ ).rejects.toThrow(/timeout after 3 attempts/);
164
+
165
+ expect(mockTask).toHaveBeenCalledTimes(3);
166
+ });
167
+ });
168
+
169
+ describe('error handling', () => {
170
+ it('should handle consecutive failures', async () => {
171
+ const mockTask = vi
172
+ .fn()
173
+ .mockRejectedValueOnce(new Error('Network error 1'))
174
+ .mockRejectedValueOnce(new Error('Network error 2'))
175
+ .mockResolvedValueOnce({ status: 'success' });
176
+
177
+ const mockCheckStatus = vi.fn().mockReturnValue({ status: 'success', data: 'recovered' });
178
+
179
+ const result = await asyncifyPolling({
180
+ pollingQuery: mockTask,
181
+ checkStatus: mockCheckStatus,
182
+ maxConsecutiveFailures: 3,
183
+ initialInterval: 1,
184
+ });
185
+
186
+ expect(result).toBe('recovered');
187
+ expect(mockTask).toHaveBeenCalledTimes(3);
188
+ });
189
+
190
+ it('should throw after maxConsecutiveFailures', async () => {
191
+ const mockTask = vi
192
+ .fn()
193
+ .mockRejectedValueOnce(new Error('Network error 1'))
194
+ .mockRejectedValueOnce(new Error('Network error 2'))
195
+ .mockRejectedValueOnce(new Error('Network error 3'));
196
+
197
+ const mockCheckStatus = vi.fn();
198
+
199
+ await expect(
200
+ asyncifyPolling({
201
+ pollingQuery: mockTask,
202
+ checkStatus: mockCheckStatus,
203
+ maxConsecutiveFailures: 2, // 允许最多2次连续失败
204
+ initialInterval: 1,
205
+ }),
206
+ ).rejects.toThrow(/consecutive attempts/);
207
+
208
+ expect(mockTask).toHaveBeenCalledTimes(2); // 第1次失败,第2次失败,然后抛出错误
209
+ expect(mockCheckStatus).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it('should reset consecutive failures on success', async () => {
213
+ const mockTask = vi
214
+ .fn()
215
+ .mockRejectedValueOnce(new Error('Network error 1')) // Failure 1 (consecutiveFailures=1)
216
+ .mockResolvedValueOnce({ status: 'pending' }) // Success 1 (reset to 0)
217
+ .mockRejectedValueOnce(new Error('Network error 2')) // Failure 2 (consecutiveFailures=1)
218
+ .mockRejectedValueOnce(new Error('Network error 3')) // Failure 3 (consecutiveFailures=2)
219
+ .mockResolvedValueOnce({ status: 'success' }); // Success 2 (return result)
220
+
221
+ const mockCheckStatus = vi
222
+ .fn()
223
+ .mockReturnValueOnce({ status: 'pending' }) // For success 1
224
+ .mockReturnValueOnce({ status: 'success', data: 'final' }); // For success 2
225
+
226
+ const result = await asyncifyPolling({
227
+ pollingQuery: mockTask,
228
+ checkStatus: mockCheckStatus,
229
+ maxConsecutiveFailures: 3, // Allow up to 3 consecutive failures (since there are 2 consecutive failures)
230
+ initialInterval: 1,
231
+ });
232
+
233
+ expect(result).toBe('final');
234
+ expect(mockTask).toHaveBeenCalledTimes(5); // Total 5 calls
235
+ });
236
+ });
237
+
238
+ describe('configuration', () => {
239
+ it('should use custom intervals and multipliers', async () => {
240
+ const intervals: number[] = [];
241
+ const originalSetTimeout = global.setTimeout;
242
+
243
+ global.setTimeout = vi.fn((callback, delay) => {
244
+ intervals.push(delay as number);
245
+ return originalSetTimeout(callback, 1);
246
+ }) as any;
247
+
248
+ const mockTask = vi
249
+ .fn()
250
+ .mockResolvedValueOnce({ status: 'pending' })
251
+ .mockResolvedValueOnce({ status: 'success' });
252
+
253
+ const mockCheckStatus = vi
254
+ .fn()
255
+ .mockReturnValueOnce({ status: 'pending' })
256
+ .mockReturnValueOnce({ status: 'success', data: 'done' });
257
+
258
+ await asyncifyPolling({
259
+ pollingQuery: mockTask,
260
+ checkStatus: mockCheckStatus,
261
+ initialInterval: 200,
262
+ backoffMultiplier: 1.2,
263
+ });
264
+
265
+ expect(intervals[0]).toBe(200);
266
+
267
+ global.setTimeout = originalSetTimeout;
268
+ });
269
+
270
+ it('should accept custom logger function', async () => {
271
+ const mockLogger = {
272
+ debug: vi.fn(),
273
+ error: vi.fn(),
274
+ };
275
+
276
+ const mockTask = vi
277
+ .fn()
278
+ .mockRejectedValueOnce(new Error('Test error'))
279
+ .mockResolvedValueOnce({ status: 'success' });
280
+
281
+ const mockCheckStatus = vi.fn().mockReturnValue({ status: 'success', data: 'done' });
282
+
283
+ await asyncifyPolling({
284
+ pollingQuery: mockTask,
285
+ checkStatus: mockCheckStatus,
286
+ logger: mockLogger,
287
+ maxConsecutiveFailures: 3,
288
+ initialInterval: 1,
289
+ });
290
+
291
+ expect(mockLogger.debug).toHaveBeenCalled();
292
+ expect(mockLogger.error).toHaveBeenCalled();
293
+ });
294
+ });
295
+
296
+ describe('edge cases', () => {
297
+ it('should handle immediate failure', async () => {
298
+ const mockTask = vi.fn().mockResolvedValue({ error: 'immediate failure' });
299
+ const mockCheckStatus = vi.fn().mockReturnValue({
300
+ status: 'failed',
301
+ error: new Error('immediate failure'),
302
+ });
303
+
304
+ await expect(
305
+ asyncifyPolling({
306
+ pollingQuery: mockTask,
307
+ checkStatus: mockCheckStatus,
308
+ }),
309
+ ).rejects.toThrow('immediate failure');
310
+
311
+ expect(mockTask).toHaveBeenCalledTimes(1);
312
+ });
313
+
314
+ it('should handle task throwing exceptions', async () => {
315
+ const mockTask = vi.fn().mockRejectedValue(new Error('Task exception'));
316
+ const mockCheckStatus = vi.fn();
317
+
318
+ await expect(
319
+ asyncifyPolling({
320
+ pollingQuery: mockTask,
321
+ checkStatus: mockCheckStatus,
322
+ maxConsecutiveFailures: 1,
323
+ initialInterval: 1,
324
+ }),
325
+ ).rejects.toThrow(/consecutive attempts/);
326
+ });
327
+
328
+ it('should timeout correctly with maxRetries = 1', async () => {
329
+ const mockTask = vi.fn().mockResolvedValue({ status: 'pending' });
330
+ const mockCheckStatus = vi.fn().mockReturnValue({ status: 'pending' });
331
+
332
+ await expect(
333
+ asyncifyPolling({
334
+ pollingQuery: mockTask,
335
+ checkStatus: mockCheckStatus,
336
+ maxRetries: 1,
337
+ initialInterval: 1,
338
+ }),
339
+ ).rejects.toThrow(/timeout after 1 attempts/);
340
+
341
+ expect(mockTask).toHaveBeenCalledTimes(1);
342
+ });
343
+ });
344
+
345
+ describe('custom error handling', () => {
346
+ it('should allow continuing polling via onPollingError', async () => {
347
+ const mockTask = vi
348
+ .fn()
349
+ .mockRejectedValueOnce(new Error('Network error'))
350
+ .mockRejectedValueOnce(new Error('Another error'))
351
+ .mockResolvedValueOnce({ status: 'success' });
352
+
353
+ const mockCheckStatus = vi.fn().mockReturnValue({ status: 'success', data: 'final-result' });
354
+
355
+ const onPollingError = vi.fn().mockReturnValue({
356
+ isContinuePolling: true,
357
+ });
358
+
359
+ const result = await asyncifyPolling({
360
+ pollingQuery: mockTask,
361
+ checkStatus: mockCheckStatus,
362
+ onPollingError,
363
+ initialInterval: 1,
364
+ });
365
+
366
+ expect(result).toBe('final-result');
367
+ expect(mockTask).toHaveBeenCalledTimes(3);
368
+ expect(onPollingError).toHaveBeenCalledTimes(2);
369
+
370
+ // Check that error context was passed correctly
371
+ expect(onPollingError).toHaveBeenCalledWith({
372
+ error: expect.any(Error),
373
+ retries: expect.any(Number),
374
+ consecutiveFailures: expect.any(Number),
375
+ });
376
+ });
377
+
378
+ it('should stop polling when onPollingError returns false', async () => {
379
+ const mockTask = vi.fn().mockRejectedValue(new Error('Fatal error'));
380
+ const mockCheckStatus = vi.fn();
381
+
382
+ const onPollingError = vi.fn().mockReturnValue({
383
+ isContinuePolling: false,
384
+ });
385
+
386
+ await expect(
387
+ asyncifyPolling({
388
+ pollingQuery: mockTask,
389
+ checkStatus: mockCheckStatus,
390
+ onPollingError,
391
+ initialInterval: 1,
392
+ }),
393
+ ).rejects.toThrow('Fatal error');
394
+
395
+ expect(mockTask).toHaveBeenCalledTimes(1);
396
+ expect(onPollingError).toHaveBeenCalledTimes(1);
397
+ expect(mockCheckStatus).not.toHaveBeenCalled();
398
+ });
399
+
400
+ it('should throw custom error when provided by onPollingError', async () => {
401
+ const mockTask = vi.fn().mockRejectedValue(new Error('Original error'));
402
+ const mockCheckStatus = vi.fn();
403
+
404
+ const customError = new Error('Custom error message');
405
+ const onPollingError = vi.fn().mockReturnValue({
406
+ isContinuePolling: false,
407
+ error: customError,
408
+ });
409
+
410
+ await expect(
411
+ asyncifyPolling({
412
+ pollingQuery: mockTask,
413
+ checkStatus: mockCheckStatus,
414
+ onPollingError,
415
+ initialInterval: 1,
416
+ }),
417
+ ).rejects.toThrow('Custom error message');
418
+
419
+ expect(onPollingError).toHaveBeenCalledWith({
420
+ error: expect.objectContaining({ message: 'Original error' }),
421
+ retries: 0,
422
+ consecutiveFailures: 1,
423
+ });
424
+ });
425
+
426
+ it('should provide correct context information to onPollingError', async () => {
427
+ const mockTask = vi
428
+ .fn()
429
+ .mockRejectedValueOnce(new Error('Error 1'))
430
+ .mockRejectedValueOnce(new Error('Error 2'))
431
+ .mockRejectedValueOnce(new Error('Error 3'));
432
+
433
+ const mockCheckStatus = vi.fn();
434
+
435
+ const onPollingError = vi
436
+ .fn()
437
+ .mockReturnValueOnce({ isContinuePolling: true })
438
+ .mockReturnValueOnce({ isContinuePolling: true })
439
+ .mockReturnValueOnce({ isContinuePolling: false });
440
+
441
+ await expect(
442
+ asyncifyPolling({
443
+ pollingQuery: mockTask,
444
+ checkStatus: mockCheckStatus,
445
+ onPollingError,
446
+ initialInterval: 1,
447
+ }),
448
+ ).rejects.toThrow('Error 3');
449
+
450
+ // Verify context progression
451
+ expect(onPollingError).toHaveBeenNthCalledWith(1, {
452
+ error: expect.objectContaining({ message: 'Error 1' }),
453
+ retries: 0,
454
+ consecutiveFailures: 1,
455
+ });
456
+
457
+ expect(onPollingError).toHaveBeenNthCalledWith(2, {
458
+ error: expect.objectContaining({ message: 'Error 2' }),
459
+ retries: 1,
460
+ consecutiveFailures: 2,
461
+ });
462
+
463
+ expect(onPollingError).toHaveBeenNthCalledWith(3, {
464
+ error: expect.objectContaining({ message: 'Error 3' }),
465
+ retries: 2,
466
+ consecutiveFailures: 3,
467
+ });
468
+ });
469
+
470
+ it('should fall back to default behavior when onPollingError is not provided', async () => {
471
+ const mockTask = vi
472
+ .fn()
473
+ .mockRejectedValueOnce(new Error('Error 1'))
474
+ .mockRejectedValueOnce(new Error('Error 2'))
475
+ .mockRejectedValueOnce(new Error('Error 3'));
476
+
477
+ const mockCheckStatus = vi.fn();
478
+
479
+ await expect(
480
+ asyncifyPolling({
481
+ pollingQuery: mockTask,
482
+ checkStatus: mockCheckStatus,
483
+ maxConsecutiveFailures: 2,
484
+ initialInterval: 1,
485
+ }),
486
+ ).rejects.toThrow(/consecutive attempts/);
487
+
488
+ expect(mockTask).toHaveBeenCalledTimes(2);
489
+ });
490
+ });
491
+ });