@tinybirdco/sdk 0.0.57 → 0.0.59
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/README.md +15 -0
- package/dist/api/api.d.ts +9 -0
- package/dist/api/api.d.ts.map +1 -1
- package/dist/api/api.js +155 -11
- package/dist/api/api.js.map +1 -1
- package/dist/api/api.test.js +254 -0
- package/dist/api/api.test.js.map +1 -1
- package/dist/client/types.d.ts +5 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/schema/datasource.d.ts.map +1 -1
- package/dist/schema/datasource.js +3 -1
- package/dist/schema/datasource.js.map +1 -1
- package/dist/schema/datasource.test.js +9 -0
- package/dist/schema/datasource.test.js.map +1 -1
- package/package.json +1 -1
- package/src/api/api.test.ts +389 -0
- package/src/api/api.ts +199 -12
- package/src/client/types.ts +5 -0
- package/src/schema/datasource.test.ts +15 -1
- package/src/schema/datasource.ts +3 -1
package/src/api/api.test.ts
CHANGED
|
@@ -207,6 +207,395 @@ describe("TinybirdApi", () => {
|
|
|
207
207
|
).rejects.toThrow("Date values are not supported in ingest payloads");
|
|
208
208
|
});
|
|
209
209
|
|
|
210
|
+
it("does not retry ingest on 503 when retry is disabled", async () => {
|
|
211
|
+
let attempts = 0;
|
|
212
|
+
|
|
213
|
+
server.use(
|
|
214
|
+
http.post(`${BASE_URL}/v0/events`, () => {
|
|
215
|
+
attempts += 1;
|
|
216
|
+
return new HttpResponse("Service unavailable", { status: 503 });
|
|
217
|
+
})
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const api = createTinybirdApi({
|
|
221
|
+
baseUrl: BASE_URL,
|
|
222
|
+
token: "p.default-token",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await expect(
|
|
226
|
+
api.ingest("events", { timestamp: "2024-01-01 00:00:00" })
|
|
227
|
+
).rejects.toMatchObject({
|
|
228
|
+
name: "TinybirdApiError",
|
|
229
|
+
statusCode: 503,
|
|
230
|
+
});
|
|
231
|
+
expect(attempts).toBe(1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("retries ingest on 503 with exponential backoff", async () => {
|
|
235
|
+
let attempts = 0;
|
|
236
|
+
|
|
237
|
+
server.use(
|
|
238
|
+
http.post(`${BASE_URL}/v0/events`, () => {
|
|
239
|
+
attempts += 1;
|
|
240
|
+
if (attempts === 1) {
|
|
241
|
+
return new HttpResponse("Service unavailable", { status: 503 });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return HttpResponse.json({
|
|
245
|
+
successful_rows: 1,
|
|
246
|
+
quarantined_rows: 0,
|
|
247
|
+
});
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const api = createTinybirdApi({
|
|
252
|
+
baseUrl: BASE_URL,
|
|
253
|
+
token: "p.default-token",
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const result = await api.ingest(
|
|
257
|
+
"events",
|
|
258
|
+
{ timestamp: "2024-01-01 00:00:00" },
|
|
259
|
+
{
|
|
260
|
+
maxRetries: 1,
|
|
261
|
+
}
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
expect(result).toEqual({ successful_rows: 1, quarantined_rows: 0 });
|
|
265
|
+
expect(attempts).toBe(2);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("retries ingest on 429 with retry-after header and succeeds", async () => {
|
|
269
|
+
let attempts = 0;
|
|
270
|
+
|
|
271
|
+
server.use(
|
|
272
|
+
http.post(`${BASE_URL}/v0/events`, () => {
|
|
273
|
+
attempts += 1;
|
|
274
|
+
if (attempts === 1) {
|
|
275
|
+
return new HttpResponse("Rate limited", {
|
|
276
|
+
status: 429,
|
|
277
|
+
headers: {
|
|
278
|
+
"Retry-After": "0",
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return HttpResponse.json({
|
|
284
|
+
successful_rows: 1,
|
|
285
|
+
quarantined_rows: 0,
|
|
286
|
+
});
|
|
287
|
+
})
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const api = createTinybirdApi({
|
|
291
|
+
baseUrl: BASE_URL,
|
|
292
|
+
token: "p.default-token",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const result = await api.ingest(
|
|
296
|
+
"events",
|
|
297
|
+
{ timestamp: "2024-01-01 00:00:00" },
|
|
298
|
+
{
|
|
299
|
+
maxRetries: 1,
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(result).toEqual({ successful_rows: 1, quarantined_rows: 0 });
|
|
304
|
+
expect(attempts).toBe(2);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("drains retryable 429 response body before retrying", async () => {
|
|
308
|
+
let attempts = 0;
|
|
309
|
+
let firstResponse: Response | undefined;
|
|
310
|
+
|
|
311
|
+
const customFetch: typeof fetch = async () => {
|
|
312
|
+
attempts += 1;
|
|
313
|
+
if (attempts === 1) {
|
|
314
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
315
|
+
start(controller) {
|
|
316
|
+
controller.enqueue(new TextEncoder().encode("rate limited"));
|
|
317
|
+
controller.close();
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
firstResponse = new Response(stream, {
|
|
322
|
+
status: 429,
|
|
323
|
+
headers: {
|
|
324
|
+
"Retry-After": "0",
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
return firstResponse;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return new Response(
|
|
331
|
+
JSON.stringify({
|
|
332
|
+
successful_rows: 1,
|
|
333
|
+
quarantined_rows: 0,
|
|
334
|
+
}),
|
|
335
|
+
{
|
|
336
|
+
status: 200,
|
|
337
|
+
headers: {
|
|
338
|
+
"Content-Type": "application/json",
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const api = createTinybirdApi({
|
|
345
|
+
baseUrl: BASE_URL,
|
|
346
|
+
token: "p.default-token",
|
|
347
|
+
fetch: customFetch,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const result = await api.ingest(
|
|
351
|
+
"events",
|
|
352
|
+
{ timestamp: "2024-01-01 00:00:00" },
|
|
353
|
+
{
|
|
354
|
+
maxRetries: 1,
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
expect(result).toEqual({ successful_rows: 1, quarantined_rows: 0 });
|
|
359
|
+
expect(attempts).toBe(2);
|
|
360
|
+
expect(firstResponse?.bodyUsed).toBe(true);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("does not retry 429 when rate-limit delay headers are missing", async () => {
|
|
364
|
+
let attempts = 0;
|
|
365
|
+
|
|
366
|
+
server.use(
|
|
367
|
+
http.post(`${BASE_URL}/v0/events`, () => {
|
|
368
|
+
attempts += 1;
|
|
369
|
+
return new HttpResponse("Rate limited", { status: 429 });
|
|
370
|
+
})
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const api = createTinybirdApi({
|
|
374
|
+
baseUrl: BASE_URL,
|
|
375
|
+
token: "p.default-token",
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
await expect(
|
|
379
|
+
api.ingest(
|
|
380
|
+
"events",
|
|
381
|
+
{ timestamp: "2024-01-01 00:00:00" },
|
|
382
|
+
{
|
|
383
|
+
maxRetries: 3,
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
).rejects.toMatchObject({
|
|
387
|
+
name: "TinybirdApiError",
|
|
388
|
+
statusCode: 429,
|
|
389
|
+
});
|
|
390
|
+
expect(attempts).toBe(1);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("does not retry ingest on non-retryable status by default", async () => {
|
|
394
|
+
let attempts = 0;
|
|
395
|
+
|
|
396
|
+
server.use(
|
|
397
|
+
http.post(`${BASE_URL}/v0/events`, () => {
|
|
398
|
+
attempts += 1;
|
|
399
|
+
return HttpResponse.json({ error: "Invalid payload" }, { status: 400 });
|
|
400
|
+
})
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const api = createTinybirdApi({
|
|
404
|
+
baseUrl: BASE_URL,
|
|
405
|
+
token: "p.default-token",
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
await expect(
|
|
409
|
+
api.ingest(
|
|
410
|
+
"events",
|
|
411
|
+
{ timestamp: "2024-01-01 00:00:00" },
|
|
412
|
+
{
|
|
413
|
+
maxRetries: 3,
|
|
414
|
+
}
|
|
415
|
+
)
|
|
416
|
+
).rejects.toMatchObject({
|
|
417
|
+
name: "TinybirdApiError",
|
|
418
|
+
statusCode: 400,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
expect(attempts).toBe(1);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("stops retrying ingest after maxRetries on 429", async () => {
|
|
425
|
+
let attempts = 0;
|
|
426
|
+
|
|
427
|
+
server.use(
|
|
428
|
+
http.post(`${BASE_URL}/v0/events`, () => {
|
|
429
|
+
attempts += 1;
|
|
430
|
+
return new HttpResponse("Rate limited", {
|
|
431
|
+
status: 429,
|
|
432
|
+
headers: {
|
|
433
|
+
"Retry-After": "0",
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
})
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
const api = createTinybirdApi({
|
|
440
|
+
baseUrl: BASE_URL,
|
|
441
|
+
token: "p.default-token",
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
await expect(
|
|
445
|
+
api.ingest(
|
|
446
|
+
"events",
|
|
447
|
+
{ timestamp: "2024-01-01 00:00:00" },
|
|
448
|
+
{
|
|
449
|
+
maxRetries: 2,
|
|
450
|
+
}
|
|
451
|
+
)
|
|
452
|
+
).rejects.toMatchObject({
|
|
453
|
+
name: "TinybirdApiError",
|
|
454
|
+
statusCode: 429,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
expect(attempts).toBe(3);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("stops retrying ingest after maxRetries on 503", async () => {
|
|
461
|
+
let attempts = 0;
|
|
462
|
+
|
|
463
|
+
server.use(
|
|
464
|
+
http.post(`${BASE_URL}/v0/events`, () => {
|
|
465
|
+
attempts += 1;
|
|
466
|
+
return new HttpResponse("Service unavailable", { status: 503 });
|
|
467
|
+
})
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
const api = createTinybirdApi({
|
|
471
|
+
baseUrl: BASE_URL,
|
|
472
|
+
token: "p.default-token",
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
await expect(
|
|
476
|
+
api.ingest(
|
|
477
|
+
"events",
|
|
478
|
+
{ timestamp: "2024-01-01 00:00:00" },
|
|
479
|
+
{
|
|
480
|
+
maxRetries: 2,
|
|
481
|
+
}
|
|
482
|
+
)
|
|
483
|
+
).rejects.toMatchObject({
|
|
484
|
+
name: "TinybirdApiError",
|
|
485
|
+
statusCode: 503,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
expect(attempts).toBe(3);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("retries ingest on 503 when wait is false", async () => {
|
|
492
|
+
let attempts = 0;
|
|
493
|
+
|
|
494
|
+
server.use(
|
|
495
|
+
http.post(`${BASE_URL}/v0/events`, () => {
|
|
496
|
+
attempts += 1;
|
|
497
|
+
if (attempts === 1) {
|
|
498
|
+
return new HttpResponse("Service unavailable", { status: 503 });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return HttpResponse.json({
|
|
502
|
+
successful_rows: 1,
|
|
503
|
+
quarantined_rows: 0,
|
|
504
|
+
});
|
|
505
|
+
})
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
const api = createTinybirdApi({
|
|
509
|
+
baseUrl: BASE_URL,
|
|
510
|
+
token: "p.default-token",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const result = await api.ingest(
|
|
514
|
+
"events",
|
|
515
|
+
{ timestamp: "2024-01-01 00:00:00" },
|
|
516
|
+
{
|
|
517
|
+
wait: false,
|
|
518
|
+
maxRetries: 1,
|
|
519
|
+
}
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
expect(result).toEqual({
|
|
523
|
+
successful_rows: 1,
|
|
524
|
+
quarantined_rows: 0,
|
|
525
|
+
});
|
|
526
|
+
expect(attempts).toBe(2);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("does not retry 500 even when wait is false", async () => {
|
|
530
|
+
let attempts = 0;
|
|
531
|
+
|
|
532
|
+
server.use(
|
|
533
|
+
http.post(`${BASE_URL}/v0/events`, () => {
|
|
534
|
+
attempts += 1;
|
|
535
|
+
return new HttpResponse("Internal error", { status: 500 });
|
|
536
|
+
})
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
const api = createTinybirdApi({
|
|
540
|
+
baseUrl: BASE_URL,
|
|
541
|
+
token: "p.default-token",
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
await expect(
|
|
545
|
+
api.ingest(
|
|
546
|
+
"events",
|
|
547
|
+
{ timestamp: "2024-01-01 00:00:00" },
|
|
548
|
+
{
|
|
549
|
+
wait: false,
|
|
550
|
+
maxRetries: 3,
|
|
551
|
+
}
|
|
552
|
+
)
|
|
553
|
+
).rejects.toMatchObject({
|
|
554
|
+
name: "TinybirdApiError",
|
|
555
|
+
statusCode: 500,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
expect(attempts).toBe(1);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("does not retry ingest on transient network errors", async () => {
|
|
562
|
+
let fetchAttempts = 0;
|
|
563
|
+
|
|
564
|
+
server.use(
|
|
565
|
+
http.post(`${BASE_URL}/v0/events`, () => {
|
|
566
|
+
return HttpResponse.json({
|
|
567
|
+
successful_rows: 1,
|
|
568
|
+
quarantined_rows: 0,
|
|
569
|
+
});
|
|
570
|
+
})
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const flakyFetch: typeof fetch = async (input, init) => {
|
|
574
|
+
fetchAttempts += 1;
|
|
575
|
+
if (fetchAttempts === 1) {
|
|
576
|
+
throw new TypeError("fetch failed");
|
|
577
|
+
}
|
|
578
|
+
return fetch(input, init);
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const api = createTinybirdApi({
|
|
582
|
+
baseUrl: BASE_URL,
|
|
583
|
+
token: "p.default-token",
|
|
584
|
+
fetch: flakyFetch,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
await expect(
|
|
588
|
+
api.ingest(
|
|
589
|
+
"events",
|
|
590
|
+
{ timestamp: "2024-01-01 00:00:00" },
|
|
591
|
+
{
|
|
592
|
+
maxRetries: 1,
|
|
593
|
+
}
|
|
594
|
+
)
|
|
595
|
+
).rejects.toThrow("fetch failed");
|
|
596
|
+
expect(fetchAttempts).toBe(1);
|
|
597
|
+
});
|
|
598
|
+
|
|
210
599
|
it("executes raw SQL via tinybirdApi.sql", async () => {
|
|
211
600
|
let rawSql: string | null = null;
|
|
212
601
|
let contentType: string | null = null;
|
package/src/api/api.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type {
|
|
|
14
14
|
} from "../client/types.js";
|
|
15
15
|
|
|
16
16
|
const DEFAULT_TIMEOUT = 30000;
|
|
17
|
+
const DEFAULT_INGEST_RETRY_503_BASE_DELAY_MS = 200;
|
|
18
|
+
const DEFAULT_INGEST_RETRY_503_MAX_DELAY_MS = 3000;
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Public, decoupled Tinybird API wrapper configuration
|
|
@@ -279,22 +281,49 @@ export class TinybirdApi {
|
|
|
279
281
|
const ndjson = events
|
|
280
282
|
.map((event) => JSON.stringify(this.serializeEvent(event)))
|
|
281
283
|
.join("\n");
|
|
284
|
+
const signal = this.createAbortSignal(options.timeout, options.signal);
|
|
285
|
+
const maxRetries = this.resolveIngestMaxRetries(options.maxRetries);
|
|
286
|
+
let retryCount = 0;
|
|
287
|
+
|
|
288
|
+
while (true) {
|
|
289
|
+
let response: Response;
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
response = await this.request(url.toString(), {
|
|
293
|
+
method: "POST",
|
|
294
|
+
token: options.token,
|
|
295
|
+
headers: {
|
|
296
|
+
"Content-Type": "application/x-ndjson",
|
|
297
|
+
},
|
|
298
|
+
body: ndjson,
|
|
299
|
+
signal,
|
|
300
|
+
});
|
|
301
|
+
} catch (error) {
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
282
304
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
305
|
+
if (response.ok) {
|
|
306
|
+
return (await response.json()) as IngestResult;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const retry429Delay = this.resolveRetry429Delay(response, maxRetries, retryCount);
|
|
310
|
+
if (retry429Delay !== undefined) {
|
|
311
|
+
await this.discardResponseBody(response);
|
|
312
|
+
await this.sleep(retry429Delay, signal);
|
|
313
|
+
retryCount += 1;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const retry503Delay = this.resolveRetry503Delay(response, maxRetries, retryCount);
|
|
318
|
+
if (retry503Delay !== undefined) {
|
|
319
|
+
await this.discardResponseBody(response);
|
|
320
|
+
await this.sleep(retry503Delay, signal);
|
|
321
|
+
retryCount += 1;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
292
324
|
|
|
293
|
-
if (!response.ok) {
|
|
294
325
|
await this.handleErrorResponse(response);
|
|
295
326
|
}
|
|
296
|
-
|
|
297
|
-
return (await response.json()) as IngestResult;
|
|
298
327
|
}
|
|
299
328
|
|
|
300
329
|
/**
|
|
@@ -575,6 +604,164 @@ export class TinybirdApi {
|
|
|
575
604
|
return AbortSignal.any([timeoutSignal, existingSignal]);
|
|
576
605
|
}
|
|
577
606
|
|
|
607
|
+
private resolveIngestMaxRetries(
|
|
608
|
+
maxRetries: TinybirdApiIngestOptions["maxRetries"]
|
|
609
|
+
): number | undefined {
|
|
610
|
+
if (maxRetries === undefined) {
|
|
611
|
+
return undefined;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (!Number.isFinite(maxRetries)) {
|
|
615
|
+
throw new Error("'maxRetries' must be a finite number");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return Math.max(0, Math.floor(maxRetries));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private resolveRetry429Delay(
|
|
622
|
+
response: Response,
|
|
623
|
+
maxRetries: number | undefined,
|
|
624
|
+
retryCount: number
|
|
625
|
+
): number | undefined {
|
|
626
|
+
if (maxRetries === undefined) {
|
|
627
|
+
return undefined;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (response.status !== 429) {
|
|
631
|
+
return undefined;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (retryCount >= maxRetries) {
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return this.resolveRetryDelayFromHeaders(response);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private resolveRetry503Delay(
|
|
642
|
+
response: Response,
|
|
643
|
+
maxRetries: number | undefined,
|
|
644
|
+
retryCount: number
|
|
645
|
+
): number | undefined {
|
|
646
|
+
if (maxRetries === undefined) {
|
|
647
|
+
return undefined;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (response.status !== 503) {
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (retryCount >= maxRetries) {
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return this.calculateRetry503DelayMs(retryCount);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private resolveRetryDelayFromHeaders(response: Response): number | undefined {
|
|
662
|
+
const retryAfter = response.headers.get("retry-after");
|
|
663
|
+
const retryAfterDelay = this.parseRetryAfterDelayMs(retryAfter);
|
|
664
|
+
if (retryAfterDelay !== undefined) {
|
|
665
|
+
return retryAfterDelay;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const rateLimitReset = response.headers.get("x-ratelimit-reset");
|
|
669
|
+
const rateLimitResetDelay = this.parseRateLimitResetDelayMs(rateLimitReset);
|
|
670
|
+
if (rateLimitResetDelay !== undefined) {
|
|
671
|
+
return rateLimitResetDelay;
|
|
672
|
+
}
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private parseRetryAfterDelayMs(value: string | null): number | undefined {
|
|
677
|
+
if (!value) {
|
|
678
|
+
return undefined;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const trimmed = value.trim();
|
|
682
|
+
const seconds = Number(trimmed);
|
|
683
|
+
if (Number.isFinite(seconds)) {
|
|
684
|
+
return Math.max(0, Math.floor(seconds * 1000));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const retryDateMs = Date.parse(trimmed);
|
|
688
|
+
if (Number.isNaN(retryDateMs)) {
|
|
689
|
+
return undefined;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return Math.max(0, retryDateMs - Date.now());
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private parseRateLimitResetDelayMs(value: string | null): number | undefined {
|
|
696
|
+
if (!value) {
|
|
697
|
+
return undefined;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const numericValue = Number(value.trim());
|
|
701
|
+
if (!Number.isFinite(numericValue)) {
|
|
702
|
+
return undefined;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return Math.max(0, Math.floor(numericValue * 1000));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private calculateRetry503DelayMs(retryCount: number): number {
|
|
709
|
+
return Math.min(
|
|
710
|
+
DEFAULT_INGEST_RETRY_503_MAX_DELAY_MS,
|
|
711
|
+
DEFAULT_INGEST_RETRY_503_BASE_DELAY_MS * 2 ** retryCount
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private async discardResponseBody(response: Response): Promise<void> {
|
|
716
|
+
if (response.bodyUsed || !response.body) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
try {
|
|
721
|
+
await response.arrayBuffer();
|
|
722
|
+
} catch {
|
|
723
|
+
try {
|
|
724
|
+
await response.body.cancel();
|
|
725
|
+
} catch {
|
|
726
|
+
// Best effort cleanup only; never mask retry/error flow.
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
private async sleep(delayMs: number, signal?: AbortSignal): Promise<void> {
|
|
732
|
+
if (delayMs <= 0) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
await new Promise<void>((resolve, reject) => {
|
|
737
|
+
const timer = setTimeout(() => {
|
|
738
|
+
cleanup();
|
|
739
|
+
resolve();
|
|
740
|
+
}, delayMs);
|
|
741
|
+
|
|
742
|
+
const onAbort = () => {
|
|
743
|
+
cleanup();
|
|
744
|
+
reject(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const cleanup = () => {
|
|
748
|
+
clearTimeout(timer);
|
|
749
|
+
signal?.removeEventListener("abort", onAbort);
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
if (!signal) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (signal.aborted) {
|
|
757
|
+
onAbort();
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
578
765
|
private serializeEvent(
|
|
579
766
|
event: Record<string, unknown>
|
|
580
767
|
): Record<string, unknown> {
|
package/src/client/types.ts
CHANGED
|
@@ -152,6 +152,11 @@ export interface IngestOptions {
|
|
|
152
152
|
signal?: AbortSignal;
|
|
153
153
|
/** Wait for the ingestion to complete before returning */
|
|
154
154
|
wait?: boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Number of retry attempts after the first request.
|
|
157
|
+
* Retries are disabled by default when undefined.
|
|
158
|
+
*/
|
|
159
|
+
maxRetries?: number;
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
/**
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
getColumnNames,
|
|
8
8
|
column,
|
|
9
9
|
} from "./datasource.js";
|
|
10
|
-
import { t } from "./types.js";
|
|
10
|
+
import { t, type AnyTypeValidator } from "./types.js";
|
|
11
11
|
import { engine } from "./engines.js";
|
|
12
12
|
import { defineKafkaConnection, defineS3Connection, defineGCSConnection } from "./connection.js";
|
|
13
13
|
|
|
@@ -259,6 +259,20 @@ describe("Datasource Schema", () => {
|
|
|
259
259
|
expect(result).toBeUndefined();
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
+
it("never returns a function when validator branding is missing", () => {
|
|
263
|
+
const validator = t.string();
|
|
264
|
+
// Simulate a validator-like object where isTypeValidator() fails by
|
|
265
|
+
// removing symbol keys (including the validator brand).
|
|
266
|
+
const unbrandedValidator = Object.fromEntries(
|
|
267
|
+
Object.entries(validator as unknown as Record<string, unknown>)
|
|
268
|
+
) as unknown as AnyTypeValidator;
|
|
269
|
+
|
|
270
|
+
const result = getColumnJsonPath(unbrandedValidator);
|
|
271
|
+
|
|
272
|
+
expect(result).toBeUndefined();
|
|
273
|
+
expect(typeof result).not.toBe("function");
|
|
274
|
+
});
|
|
275
|
+
|
|
262
276
|
it("returns jsonPath from validator modifier", () => {
|
|
263
277
|
const validator = t.string().jsonPath("$.user.id");
|
|
264
278
|
const result = getColumnJsonPath(validator);
|
package/src/schema/datasource.ts
CHANGED
|
@@ -272,7 +272,9 @@ export function getColumnJsonPath(column: AnyTypeValidator | ColumnDefinition):
|
|
|
272
272
|
return getModifiers(column).jsonPath;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
|
|
275
|
+
// Check typeof to avoid returning the jsonPath method from validators
|
|
276
|
+
// if isTypeValidator incorrectly returns false (e.g., cross-module Symbol issues)
|
|
277
|
+
if (typeof column.jsonPath === "string") {
|
|
276
278
|
return column.jsonPath;
|
|
277
279
|
}
|
|
278
280
|
|