@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.
@@ -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
- const response = await this.request(url.toString(), {
284
- method: "POST",
285
- token: options.token,
286
- headers: {
287
- "Content-Type": "application/x-ndjson",
288
- },
289
- body: ndjson,
290
- signal: this.createAbortSignal(options.timeout, options.signal),
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> {
@@ -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);
@@ -272,7 +272,9 @@ export function getColumnJsonPath(column: AnyTypeValidator | ColumnDefinition):
272
272
  return getModifiers(column).jsonPath;
273
273
  }
274
274
 
275
- if (column.jsonPath !== undefined) {
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