vargai 0.4.0-alpha67 → 0.4.0-alpha69
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/package.json +2 -1
- package/src/ai-sdk/index.ts +4 -0
- package/src/ai-sdk/storage/index.ts +6 -0
- package/src/ai-sdk/storage/r2.ts +10 -7
- package/src/ai-sdk/storage/retry.test.ts +372 -0
- package/src/ai-sdk/storage/retry.ts +111 -0
- package/src/providers/storage.ts +26 -19
- package/src/react/renderers/image.ts +23 -1
- package/src/react/renderers/render.ts +1 -3
- package/src/react/types.ts +1 -1
package/package.json
CHANGED
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"fluent-ffmpeg": "^2.1.3",
|
|
62
62
|
"groq-sdk": "^0.36.0",
|
|
63
63
|
"ink": "^6.5.1",
|
|
64
|
+
"p-limit": "^6.2.0",
|
|
64
65
|
"p-map": "^7.0.4",
|
|
65
66
|
"react": "^19.2.0",
|
|
66
67
|
"react-dom": "^19.2.0",
|
|
@@ -70,7 +71,7 @@
|
|
|
70
71
|
"zod": "^4.2.1"
|
|
71
72
|
},
|
|
72
73
|
"sideEffects": false,
|
|
73
|
-
"version": "0.4.0-
|
|
74
|
+
"version": "0.4.0-alpha69",
|
|
74
75
|
"exports": {
|
|
75
76
|
".": "./src/index.ts",
|
|
76
77
|
"./ai": "./src/ai-sdk/index.ts",
|
package/src/ai-sdk/index.ts
CHANGED
|
@@ -101,8 +101,12 @@ export {
|
|
|
101
101
|
} from "./providers/together";
|
|
102
102
|
export {
|
|
103
103
|
falStorage,
|
|
104
|
+
limitedRetryUpload,
|
|
104
105
|
type R2StorageOptions,
|
|
106
|
+
type RetryOptions,
|
|
105
107
|
r2Storage,
|
|
108
|
+
r2UploadLimiter,
|
|
109
|
+
retryR2Upload,
|
|
106
110
|
type StorageProvider,
|
|
107
111
|
} from "./storage";
|
|
108
112
|
export type {
|
package/src/ai-sdk/storage/r2.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
import { limitedRetryUpload } from "./retry";
|
|
2
3
|
import type { StorageProvider } from "./types";
|
|
3
4
|
|
|
4
5
|
export interface R2StorageOptions {
|
|
@@ -41,13 +42,15 @@ export function r2Storage(options?: R2StorageOptions): StorageProvider {
|
|
|
41
42
|
|
|
42
43
|
return {
|
|
43
44
|
async upload(data: Uint8Array, key: string, mediaType: string) {
|
|
44
|
-
await
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
await limitedRetryUpload(() =>
|
|
46
|
+
client.send(
|
|
47
|
+
new PutObjectCommand({
|
|
48
|
+
Bucket: bucket,
|
|
49
|
+
Key: key,
|
|
50
|
+
Body: data,
|
|
51
|
+
ContentType: mediaType,
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
51
54
|
);
|
|
52
55
|
return getPublicUrl(key);
|
|
53
56
|
},
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { limitedRetryUpload, r2UploadLimiter, retryR2Upload } from "./retry";
|
|
3
|
+
|
|
4
|
+
// Use minimal delays for tests
|
|
5
|
+
const fastOpts = { maxRetries: 3, baseDelay: 1 };
|
|
6
|
+
|
|
7
|
+
// ─── Error factories ─────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/** Simulates an AWS SDK v3 ServiceException with $metadata */
|
|
10
|
+
function awsError(
|
|
11
|
+
name: string,
|
|
12
|
+
message: string,
|
|
13
|
+
httpStatusCode?: number,
|
|
14
|
+
): Error & { $metadata?: { httpStatusCode: number } } {
|
|
15
|
+
const err = new Error(message) as Error & {
|
|
16
|
+
$metadata?: { httpStatusCode: number };
|
|
17
|
+
};
|
|
18
|
+
err.name = name;
|
|
19
|
+
if (httpStatusCode !== undefined) {
|
|
20
|
+
err.$metadata = { httpStatusCode };
|
|
21
|
+
}
|
|
22
|
+
return err;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Error with a `code` property (Node.js-style) */
|
|
26
|
+
function codeError(
|
|
27
|
+
code: string,
|
|
28
|
+
message = "connection failed",
|
|
29
|
+
): Error & { code: string } {
|
|
30
|
+
const err = new Error(message) as Error & { code: string };
|
|
31
|
+
err.code = code;
|
|
32
|
+
return err;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── isRetryable (tested indirectly through retryR2Upload) ───────────────────
|
|
36
|
+
|
|
37
|
+
describe("retryR2Upload", () => {
|
|
38
|
+
describe("retries on retryable errors", () => {
|
|
39
|
+
test("retries on error.message containing 'concurrent request rate'", async () => {
|
|
40
|
+
let calls = 0;
|
|
41
|
+
const result = await retryR2Upload(async () => {
|
|
42
|
+
calls++;
|
|
43
|
+
if (calls === 1)
|
|
44
|
+
throw new Error(
|
|
45
|
+
"Reduce your concurrent request rate for the same object.",
|
|
46
|
+
);
|
|
47
|
+
return "ok";
|
|
48
|
+
}, fastOpts);
|
|
49
|
+
|
|
50
|
+
expect(result).toBe("ok");
|
|
51
|
+
expect(calls).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("retries on error.name = SlowDown (AWS SDK v3 pattern)", async () => {
|
|
55
|
+
let calls = 0;
|
|
56
|
+
const result = await retryR2Upload(async () => {
|
|
57
|
+
calls++;
|
|
58
|
+
if (calls === 1)
|
|
59
|
+
throw awsError("SlowDown", "Please reduce request rate.");
|
|
60
|
+
return "ok";
|
|
61
|
+
}, fastOpts);
|
|
62
|
+
|
|
63
|
+
expect(result).toBe("ok");
|
|
64
|
+
expect(calls).toBe(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("retries on error.name = ServiceUnavailable", async () => {
|
|
68
|
+
let calls = 0;
|
|
69
|
+
const result = await retryR2Upload(async () => {
|
|
70
|
+
calls++;
|
|
71
|
+
if (calls === 1)
|
|
72
|
+
throw awsError(
|
|
73
|
+
"ServiceUnavailable",
|
|
74
|
+
"Service is temporarily unavailable",
|
|
75
|
+
);
|
|
76
|
+
return "ok";
|
|
77
|
+
}, fastOpts);
|
|
78
|
+
|
|
79
|
+
expect(result).toBe("ok");
|
|
80
|
+
expect(calls).toBe(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("retries on $metadata.httpStatusCode = 429", async () => {
|
|
84
|
+
let calls = 0;
|
|
85
|
+
const result = await retryR2Upload(async () => {
|
|
86
|
+
calls++;
|
|
87
|
+
if (calls === 1) throw awsError("UnknownError", "rate limited", 429);
|
|
88
|
+
return "ok";
|
|
89
|
+
}, fastOpts);
|
|
90
|
+
|
|
91
|
+
expect(result).toBe("ok");
|
|
92
|
+
expect(calls).toBe(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("retries on $metadata.httpStatusCode = 500", async () => {
|
|
96
|
+
let calls = 0;
|
|
97
|
+
const result = await retryR2Upload(async () => {
|
|
98
|
+
calls++;
|
|
99
|
+
if (calls === 1) throw awsError("InternalServerError", "oops", 500);
|
|
100
|
+
return "ok";
|
|
101
|
+
}, fastOpts);
|
|
102
|
+
|
|
103
|
+
expect(result).toBe("ok");
|
|
104
|
+
expect(calls).toBe(2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("retries on $metadata.httpStatusCode = 503", async () => {
|
|
108
|
+
let calls = 0;
|
|
109
|
+
const result = await retryR2Upload(async () => {
|
|
110
|
+
calls++;
|
|
111
|
+
if (calls === 1) throw awsError("Unavailable", "try again", 503);
|
|
112
|
+
return "ok";
|
|
113
|
+
}, fastOpts);
|
|
114
|
+
|
|
115
|
+
expect(result).toBe("ok");
|
|
116
|
+
expect(calls).toBe(2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("retries on error.code = ECONNRESET", async () => {
|
|
120
|
+
let calls = 0;
|
|
121
|
+
const result = await retryR2Upload(async () => {
|
|
122
|
+
calls++;
|
|
123
|
+
if (calls === 1) throw codeError("ECONNRESET");
|
|
124
|
+
return "ok";
|
|
125
|
+
}, fastOpts);
|
|
126
|
+
|
|
127
|
+
expect(result).toBe("ok");
|
|
128
|
+
expect(calls).toBe(2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("retries on error.code = ETIMEDOUT", async () => {
|
|
132
|
+
let calls = 0;
|
|
133
|
+
const result = await retryR2Upload(async () => {
|
|
134
|
+
calls++;
|
|
135
|
+
if (calls === 1) throw codeError("ETIMEDOUT");
|
|
136
|
+
return "ok";
|
|
137
|
+
}, fastOpts);
|
|
138
|
+
|
|
139
|
+
expect(result).toBe("ok");
|
|
140
|
+
expect(calls).toBe(2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("retries on 'socket hang up' in message", async () => {
|
|
144
|
+
let calls = 0;
|
|
145
|
+
const result = await retryR2Upload(async () => {
|
|
146
|
+
calls++;
|
|
147
|
+
if (calls === 1) throw new Error("socket hang up");
|
|
148
|
+
return "ok";
|
|
149
|
+
}, fastOpts);
|
|
150
|
+
|
|
151
|
+
expect(result).toBe("ok");
|
|
152
|
+
expect(calls).toBe(2);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("does NOT retry on non-retryable errors", () => {
|
|
157
|
+
test("throws immediately on AccessDenied", async () => {
|
|
158
|
+
let calls = 0;
|
|
159
|
+
await expect(
|
|
160
|
+
retryR2Upload(async () => {
|
|
161
|
+
calls++;
|
|
162
|
+
throw awsError("AccessDenied", "Access Denied", 403);
|
|
163
|
+
}, fastOpts),
|
|
164
|
+
).rejects.toThrow("Access Denied");
|
|
165
|
+
|
|
166
|
+
expect(calls).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("throws immediately on NoSuchBucket", async () => {
|
|
170
|
+
let calls = 0;
|
|
171
|
+
await expect(
|
|
172
|
+
retryR2Upload(async () => {
|
|
173
|
+
calls++;
|
|
174
|
+
throw awsError(
|
|
175
|
+
"NoSuchBucket",
|
|
176
|
+
"The specified bucket does not exist",
|
|
177
|
+
404,
|
|
178
|
+
);
|
|
179
|
+
}, fastOpts),
|
|
180
|
+
).rejects.toThrow("The specified bucket does not exist");
|
|
181
|
+
|
|
182
|
+
expect(calls).toBe(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("throws immediately on generic Error", async () => {
|
|
186
|
+
let calls = 0;
|
|
187
|
+
await expect(
|
|
188
|
+
retryR2Upload(async () => {
|
|
189
|
+
calls++;
|
|
190
|
+
throw new Error("something completely unrelated");
|
|
191
|
+
}, fastOpts),
|
|
192
|
+
).rejects.toThrow("something completely unrelated");
|
|
193
|
+
|
|
194
|
+
expect(calls).toBe(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("does not retry primitive/null errors", async () => {
|
|
198
|
+
let calls = 0;
|
|
199
|
+
await expect(
|
|
200
|
+
retryR2Upload(async () => {
|
|
201
|
+
calls++;
|
|
202
|
+
throw null;
|
|
203
|
+
}, fastOpts),
|
|
204
|
+
).rejects.toBeNull();
|
|
205
|
+
|
|
206
|
+
expect(calls).toBe(1);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("retry limits and exhaustion", () => {
|
|
211
|
+
test("retries up to maxRetries times then throws", async () => {
|
|
212
|
+
let calls = 0;
|
|
213
|
+
await expect(
|
|
214
|
+
retryR2Upload(
|
|
215
|
+
async () => {
|
|
216
|
+
calls++;
|
|
217
|
+
throw new Error("SlowDown");
|
|
218
|
+
},
|
|
219
|
+
{ maxRetries: 2, baseDelay: 1 },
|
|
220
|
+
),
|
|
221
|
+
).rejects.toThrow("SlowDown");
|
|
222
|
+
|
|
223
|
+
// 1 initial + 2 retries = 3 total calls
|
|
224
|
+
expect(calls).toBe(3);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("maxRetries: 0 means no retries (single attempt)", async () => {
|
|
228
|
+
let calls = 0;
|
|
229
|
+
await expect(
|
|
230
|
+
retryR2Upload(
|
|
231
|
+
async () => {
|
|
232
|
+
calls++;
|
|
233
|
+
throw new Error("SlowDown");
|
|
234
|
+
},
|
|
235
|
+
{ maxRetries: 0, baseDelay: 1 },
|
|
236
|
+
),
|
|
237
|
+
).rejects.toThrow("SlowDown");
|
|
238
|
+
|
|
239
|
+
expect(calls).toBe(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("succeeds on the last retry attempt", async () => {
|
|
243
|
+
let calls = 0;
|
|
244
|
+
const result = await retryR2Upload(
|
|
245
|
+
async () => {
|
|
246
|
+
calls++;
|
|
247
|
+
if (calls <= 3) throw new Error("SlowDown");
|
|
248
|
+
return "finally";
|
|
249
|
+
},
|
|
250
|
+
{ maxRetries: 3, baseDelay: 1 },
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(result).toBe("finally");
|
|
254
|
+
expect(calls).toBe(4); // 1 initial + 3 retries
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("returns value on success", () => {
|
|
259
|
+
test("returns value on first attempt", async () => {
|
|
260
|
+
const result = await retryR2Upload(async () => "immediate", fastOpts);
|
|
261
|
+
expect(result).toBe("immediate");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("returns complex objects", async () => {
|
|
265
|
+
const obj = { url: "https://s3.varg.ai/test.mp4", key: "test.mp4" };
|
|
266
|
+
const result = await retryR2Upload(async () => obj, fastOpts);
|
|
267
|
+
expect(result).toEqual(obj);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("input validation", () => {
|
|
272
|
+
test("rejects negative maxRetries", async () => {
|
|
273
|
+
await expect(
|
|
274
|
+
retryR2Upload(async () => "ok", { maxRetries: -1, baseDelay: 1 }),
|
|
275
|
+
).rejects.toThrow("maxRetries");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("rejects NaN maxRetries", async () => {
|
|
279
|
+
await expect(
|
|
280
|
+
retryR2Upload(async () => "ok", { maxRetries: NaN, baseDelay: 1 }),
|
|
281
|
+
).rejects.toThrow("maxRetries");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("rejects float maxRetries", async () => {
|
|
285
|
+
await expect(
|
|
286
|
+
retryR2Upload(async () => "ok", { maxRetries: 2.5, baseDelay: 1 }),
|
|
287
|
+
).rejects.toThrow("maxRetries");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("rejects negative baseDelay", async () => {
|
|
291
|
+
await expect(
|
|
292
|
+
retryR2Upload(async () => "ok", { maxRetries: 1, baseDelay: -100 }),
|
|
293
|
+
).rejects.toThrow("baseDelay");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("rejects Infinity baseDelay", async () => {
|
|
297
|
+
await expect(
|
|
298
|
+
retryR2Upload(async () => "ok", {
|
|
299
|
+
maxRetries: 1,
|
|
300
|
+
baseDelay: Number.POSITIVE_INFINITY,
|
|
301
|
+
}),
|
|
302
|
+
).rejects.toThrow("baseDelay");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("accepts zero baseDelay", async () => {
|
|
306
|
+
const result = await retryR2Upload(async () => "ok", {
|
|
307
|
+
maxRetries: 0,
|
|
308
|
+
baseDelay: 0,
|
|
309
|
+
});
|
|
310
|
+
expect(result).toBe("ok");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ─── limitedRetryUpload ──────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
describe("limitedRetryUpload", () => {
|
|
318
|
+
test("runs upload through concurrency limiter and retry", async () => {
|
|
319
|
+
const result = await limitedRetryUpload(async () => "uploaded");
|
|
320
|
+
expect(result).toBe("uploaded");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("retries within the limiter on retryable errors", async () => {
|
|
324
|
+
let calls = 0;
|
|
325
|
+
const result = await limitedRetryUpload(
|
|
326
|
+
async () => {
|
|
327
|
+
calls++;
|
|
328
|
+
if (calls === 1) throw new Error("SlowDown");
|
|
329
|
+
return "ok";
|
|
330
|
+
},
|
|
331
|
+
{ maxRetries: 2, baseDelay: 1 },
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
expect(result).toBe("ok");
|
|
335
|
+
expect(calls).toBe(2);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("respects concurrency limit", async () => {
|
|
339
|
+
let concurrent = 0;
|
|
340
|
+
let maxConcurrent = 0;
|
|
341
|
+
|
|
342
|
+
const tasks = Array.from({ length: 20 }, () =>
|
|
343
|
+
limitedRetryUpload(
|
|
344
|
+
async () => {
|
|
345
|
+
concurrent++;
|
|
346
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
347
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
348
|
+
concurrent--;
|
|
349
|
+
return "done";
|
|
350
|
+
},
|
|
351
|
+
{ maxRetries: 0, baseDelay: 0 },
|
|
352
|
+
),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
await Promise.all(tasks);
|
|
356
|
+
|
|
357
|
+
// r2UploadLimiter is set to 10
|
|
358
|
+
expect(maxConcurrent).toBeLessThanOrEqual(10);
|
|
359
|
+
expect(maxConcurrent).toBeGreaterThan(1); // should actually parallelize
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ─── r2UploadLimiter ─────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
describe("r2UploadLimiter", () => {
|
|
366
|
+
test("is a p-limit instance with concurrency 10", () => {
|
|
367
|
+
// p-limit exposes activeCount and pendingCount
|
|
368
|
+
expect(typeof r2UploadLimiter).toBe("function");
|
|
369
|
+
expect(r2UploadLimiter.activeCount).toBe(0);
|
|
370
|
+
expect(r2UploadLimiter.pendingCount).toBe(0);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import pLimit from "p-limit";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Global concurrency limiter for R2/S3 uploads.
|
|
5
|
+
* Caps concurrent uploads to avoid Cloudflare R2 rate limits
|
|
6
|
+
* ("Reduce your concurrent request rate for the same object").
|
|
7
|
+
*/
|
|
8
|
+
export const r2UploadLimiter = pLimit(10);
|
|
9
|
+
|
|
10
|
+
/** Errors that indicate R2/S3 rate limiting or transient failures worth retrying */
|
|
11
|
+
const RETRYABLE_PATTERNS = [
|
|
12
|
+
"concurrent request rate",
|
|
13
|
+
"SlowDown",
|
|
14
|
+
"ServiceUnavailable",
|
|
15
|
+
"TooManyRequests",
|
|
16
|
+
"RequestTimeout",
|
|
17
|
+
"InternalError",
|
|
18
|
+
"ECONNRESET",
|
|
19
|
+
"ETIMEDOUT",
|
|
20
|
+
"socket hang up",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** HTTP status codes that indicate a retryable error */
|
|
24
|
+
const RETRYABLE_STATUS_CODES = new Set([429, 500, 503]);
|
|
25
|
+
|
|
26
|
+
function isRetryable(error: unknown): boolean {
|
|
27
|
+
if (typeof error !== "object" || error === null) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const err = error as {
|
|
32
|
+
message?: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
code?: string;
|
|
35
|
+
$metadata?: { httpStatusCode?: number };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Check AWS SDK v3 $metadata.httpStatusCode
|
|
39
|
+
const statusCode = err.$metadata?.httpStatusCode;
|
|
40
|
+
if (statusCode !== undefined && RETRYABLE_STATUS_CODES.has(statusCode)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check error.name, error.code, and error.message against known patterns
|
|
45
|
+
const haystack = [err.message, err.name, err.code].filter(Boolean).join(" ");
|
|
46
|
+
return RETRYABLE_PATTERNS.some((pattern) => haystack.includes(pattern));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RetryOptions {
|
|
50
|
+
/** Maximum number of retries after the initial attempt (default: 5) */
|
|
51
|
+
maxRetries?: number;
|
|
52
|
+
/** Initial backoff delay in ms (default: 500) */
|
|
53
|
+
baseDelay?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Retry an R2/S3 upload with exponential backoff + jitter.
|
|
58
|
+
* Only retries on known transient/rate-limit errors.
|
|
59
|
+
*
|
|
60
|
+
* Backoff schedule (default): 500ms, 1s, 2s, 4s, 8s (+ random jitter up to 30%)
|
|
61
|
+
*/
|
|
62
|
+
export async function retryR2Upload<T>(
|
|
63
|
+
fn: () => Promise<T>,
|
|
64
|
+
opts?: RetryOptions,
|
|
65
|
+
): Promise<T> {
|
|
66
|
+
const maxRetries = opts?.maxRetries ?? 5;
|
|
67
|
+
const baseDelay = opts?.baseDelay ?? 500;
|
|
68
|
+
|
|
69
|
+
if (!Number.isInteger(maxRetries) || maxRetries < 0) {
|
|
70
|
+
throw new Error("retry option `maxRetries` must be a non-negative integer");
|
|
71
|
+
}
|
|
72
|
+
if (!Number.isFinite(baseDelay) || baseDelay < 0) {
|
|
73
|
+
throw new Error("retry option `baseDelay` must be a non-negative number");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let lastError: unknown;
|
|
77
|
+
|
|
78
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
79
|
+
try {
|
|
80
|
+
return await fn();
|
|
81
|
+
} catch (error) {
|
|
82
|
+
lastError = error;
|
|
83
|
+
|
|
84
|
+
if (attempt >= maxRetries || !isRetryable(error)) {
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const delay = baseDelay * 2 ** attempt;
|
|
89
|
+
const jitter = delay * 0.3 * Math.random();
|
|
90
|
+
const totalDelay = Math.round(delay + jitter);
|
|
91
|
+
|
|
92
|
+
console.warn(
|
|
93
|
+
`[r2-upload] attempt ${attempt + 1}/${maxRetries + 1} failed, retrying in ${totalDelay}ms: ${error instanceof Error ? error.message : String(error)}`,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, totalDelay));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw lastError;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Convenience: run an R2 upload through both the concurrency limiter and retry logic.
|
|
105
|
+
*/
|
|
106
|
+
export function limitedRetryUpload<T>(
|
|
107
|
+
fn: () => Promise<T>,
|
|
108
|
+
opts?: RetryOptions,
|
|
109
|
+
): Promise<T> {
|
|
110
|
+
return r2UploadLimiter(() => retryR2Upload(fn, opts));
|
|
111
|
+
}
|
package/src/providers/storage.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
S3Client,
|
|
9
9
|
} from "@aws-sdk/client-s3";
|
|
10
10
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
11
|
+
import { limitedRetryUpload } from "../ai-sdk/storage/retry";
|
|
11
12
|
import type { JobStatusUpdate, ProviderConfig } from "../core/schema/types";
|
|
12
13
|
import { BaseProvider } from "./base";
|
|
13
14
|
|
|
@@ -73,12 +74,14 @@ export class StorageProvider extends BaseProvider {
|
|
|
73
74
|
const file = Bun.file(filePath);
|
|
74
75
|
const buffer = await file.arrayBuffer();
|
|
75
76
|
|
|
76
|
-
await
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
await limitedRetryUpload(() =>
|
|
78
|
+
this.client.send(
|
|
79
|
+
new PutObjectCommand({
|
|
80
|
+
Bucket: this.bucket,
|
|
81
|
+
Key: objectKey,
|
|
82
|
+
Body: new Uint8Array(buffer),
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
82
85
|
);
|
|
83
86
|
|
|
84
87
|
return this.getPublicUrl(objectKey);
|
|
@@ -93,12 +96,14 @@ export class StorageProvider extends BaseProvider {
|
|
|
93
96
|
const response = await fetch(url);
|
|
94
97
|
const buffer = await response.arrayBuffer();
|
|
95
98
|
|
|
96
|
-
await
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
await limitedRetryUpload(() =>
|
|
100
|
+
this.client.send(
|
|
101
|
+
new PutObjectCommand({
|
|
102
|
+
Bucket: this.bucket,
|
|
103
|
+
Key: objectKey,
|
|
104
|
+
Body: new Uint8Array(buffer),
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
102
107
|
);
|
|
103
108
|
|
|
104
109
|
return this.getPublicUrl(objectKey);
|
|
@@ -114,13 +119,15 @@ export class StorageProvider extends BaseProvider {
|
|
|
114
119
|
): Promise<string> {
|
|
115
120
|
console.log(`[storage] uploading buffer to ${objectKey}`);
|
|
116
121
|
|
|
117
|
-
await
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
await limitedRetryUpload(() =>
|
|
123
|
+
this.client.send(
|
|
124
|
+
new PutObjectCommand({
|
|
125
|
+
Bucket: this.bucket,
|
|
126
|
+
Key: objectKey,
|
|
127
|
+
Body: buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer),
|
|
128
|
+
ContentType: contentType,
|
|
129
|
+
}),
|
|
130
|
+
),
|
|
124
131
|
);
|
|
125
132
|
|
|
126
133
|
return this.getPublicUrl(objectKey);
|
|
@@ -22,7 +22,20 @@ async function resolveImageInput(
|
|
|
22
22
|
return new Uint8Array(await response.arrayBuffer());
|
|
23
23
|
}
|
|
24
24
|
const file = await renderImage(input, ctx);
|
|
25
|
-
|
|
25
|
+
const data = await file.arrayBuffer();
|
|
26
|
+
// Debug: ensure we always return a proper Uint8Array
|
|
27
|
+
if (!(data instanceof Uint8Array)) {
|
|
28
|
+
console.error(
|
|
29
|
+
`[resolveImageInput] file.arrayBuffer() returned ${typeof data} (constructor: ${data?.constructor?.name}), converting...`,
|
|
30
|
+
);
|
|
31
|
+
if (data instanceof ArrayBuffer) {
|
|
32
|
+
return new Uint8Array(data);
|
|
33
|
+
}
|
|
34
|
+
throw new Error(
|
|
35
|
+
`resolveImageInput: unexpected data type ${typeof data} from file.arrayBuffer()`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return data;
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
async function resolvePrompt(
|
|
@@ -35,6 +48,15 @@ async function resolvePrompt(
|
|
|
35
48
|
const resolvedImages = await Promise.all(
|
|
36
49
|
prompt.images.map((img) => resolveImageInput(img, ctx)),
|
|
37
50
|
);
|
|
51
|
+
// Debug: verify all images are Uint8Array before passing to generateImage
|
|
52
|
+
for (let i = 0; i < resolvedImages.length; i++) {
|
|
53
|
+
const img = resolvedImages[i];
|
|
54
|
+
if (!(img instanceof Uint8Array)) {
|
|
55
|
+
console.error(
|
|
56
|
+
`[resolvePrompt] images[${i}] is ${typeof img} (constructor: ${(img as any)?.constructor?.name}), not Uint8Array`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
38
60
|
return { text: prompt.text, images: resolvedImages };
|
|
39
61
|
}
|
|
40
62
|
|
|
@@ -290,9 +290,7 @@ export async function renderRoot(
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
const concurrency =
|
|
293
|
-
options.concurrency === undefined
|
|
294
|
-
? Number.POSITIVE_INFINITY
|
|
295
|
-
: options.concurrency;
|
|
293
|
+
options.concurrency === undefined ? 3 : options.concurrency;
|
|
296
294
|
|
|
297
295
|
if (
|
|
298
296
|
concurrency !== Number.POSITIVE_INFINITY &&
|
package/src/react/types.ts
CHANGED
|
@@ -285,7 +285,7 @@ export interface RenderOptions {
|
|
|
285
285
|
defaults?: DefaultModels;
|
|
286
286
|
backend?: FFmpegBackend;
|
|
287
287
|
storage?: StorageProvider;
|
|
288
|
-
/** Max concurrent clip renders. Defaults to
|
|
288
|
+
/** Max concurrent clip renders. Defaults to 3. */
|
|
289
289
|
concurrency?: number;
|
|
290
290
|
}
|
|
291
291
|
|