@tinybirdco/sdk 0.0.57 → 0.0.58

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/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
  /**