@electric-sql/client 1.1.0 → 1.1.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.
package/dist/index.d.ts CHANGED
@@ -135,16 +135,38 @@ interface BackoffOptions {
135
135
  initialDelay: number;
136
136
  /**
137
137
  * Maximum retry delay in milliseconds
138
+ * After reaching this, delay stays constant (e.g., retry every 60s)
138
139
  */
139
140
  maxDelay: number;
140
141
  multiplier: number;
141
142
  onFailedAttempt?: () => void;
142
143
  debug?: boolean;
144
+ /**
145
+ * Maximum number of retry attempts before giving up.
146
+ * Set to Infinity (default) for indefinite retries - needed for offline scenarios
147
+ * where clients may go offline and come back later.
148
+ *
149
+ * The retry budget provides protection against retry storms even with infinite retries.
150
+ */
151
+ maxRetries?: number;
152
+ /**
153
+ * Percentage of requests that can be retries (0.1 = 10%)
154
+ *
155
+ * This is the primary load shedding mechanism. It limits the *rate* of retries,
156
+ * not the total count. Even with infinite retries, at most 10% of your traffic
157
+ * will be retries, preventing retry storms from amplifying server load.
158
+ *
159
+ * The budget resets every 60 seconds, so a temporary spike of errors won't
160
+ * permanently exhaust the budget.
161
+ */
162
+ retryBudgetPercent?: number;
143
163
  }
144
164
  declare const BackoffDefaults: {
145
165
  initialDelay: number;
146
166
  maxDelay: number;
147
167
  multiplier: number;
168
+ maxRetries: number;
169
+ retryBudgetPercent: number;
148
170
  };
149
171
 
150
172
  declare const LIVE_CACHE_BUSTER_QUERY_PARAM = "cursor";
@@ -316,8 +316,13 @@ var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
316
316
  var HTTP_RETRY_STATUS_CODES = [429];
317
317
  var BackoffDefaults = {
318
318
  initialDelay: 100,
319
- maxDelay: 1e4,
320
- multiplier: 1.3
319
+ maxDelay: 6e4,
320
+ // Cap at 60s - reasonable for long-lived connections
321
+ multiplier: 1.3,
322
+ maxRetries: Infinity,
323
+ // Retry forever - clients may go offline and come back
324
+ retryBudgetPercent: 0.1
325
+ // 10% retry budget prevents amplification
321
326
  };
322
327
  function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
323
328
  const {
@@ -325,8 +330,29 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
325
330
  maxDelay,
326
331
  multiplier,
327
332
  debug = false,
328
- onFailedAttempt
333
+ onFailedAttempt,
334
+ maxRetries = Infinity,
335
+ retryBudgetPercent = 0.1
329
336
  } = backoffOptions;
337
+ let totalRequests = 0;
338
+ let totalRetries = 0;
339
+ let budgetResetTime = Date.now() + 6e4;
340
+ function checkRetryBudget(percent) {
341
+ const now = Date.now();
342
+ if (now > budgetResetTime) {
343
+ totalRequests = 0;
344
+ totalRetries = 0;
345
+ budgetResetTime = now + 6e4;
346
+ }
347
+ totalRequests++;
348
+ if (totalRequests < 10) return true;
349
+ const currentRetryRate = totalRetries / totalRequests;
350
+ const hasCapacity = currentRetryRate < percent;
351
+ if (hasCapacity) {
352
+ totalRetries++;
353
+ }
354
+ return hasCapacity;
355
+ }
330
356
  return async (...args) => {
331
357
  var _a;
332
358
  const url = args[0];
@@ -336,7 +362,10 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
336
362
  while (true) {
337
363
  try {
338
364
  const result = await fetchClient(...args);
339
- if (result.ok) return result;
365
+ if (result.ok) {
366
+ delay = initialDelay;
367
+ return result;
368
+ }
340
369
  const err = await FetchError.fromResponse(result, url.toString());
341
370
  throw err;
342
371
  } catch (e) {
@@ -346,12 +375,51 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
346
375
  } else if (e instanceof FetchError && !HTTP_RETRY_STATUS_CODES.includes(e.status) && e.status >= 400 && e.status < 500) {
347
376
  throw e;
348
377
  } else {
349
- await new Promise((resolve) => setTimeout(resolve, delay));
350
- delay = Math.min(delay * multiplier, maxDelay);
378
+ attempt++;
379
+ if (attempt >= maxRetries) {
380
+ if (debug) {
381
+ console.log(
382
+ `Max retries reached (${attempt}/${maxRetries}), giving up`
383
+ );
384
+ }
385
+ throw e;
386
+ }
387
+ if (!checkRetryBudget(retryBudgetPercent)) {
388
+ if (debug) {
389
+ console.log(
390
+ `Retry budget exhausted (attempt ${attempt}), backing off`
391
+ );
392
+ }
393
+ await new Promise((resolve) => setTimeout(resolve, maxDelay));
394
+ continue;
395
+ }
396
+ let serverMinimumMs = 0;
397
+ if (e instanceof FetchError && e.headers) {
398
+ const retryAfter = e.headers[`retry-after`];
399
+ if (retryAfter) {
400
+ const retryAfterSec = Number(retryAfter);
401
+ if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
402
+ serverMinimumMs = retryAfterSec * 1e3;
403
+ } else {
404
+ const retryDate = Date.parse(retryAfter);
405
+ if (!isNaN(retryDate)) {
406
+ const deltaMs = retryDate - Date.now();
407
+ serverMinimumMs = Math.max(0, Math.min(deltaMs, 36e5));
408
+ }
409
+ }
410
+ }
411
+ }
412
+ const jitter = Math.random() * delay;
413
+ const clientBackoffMs = Math.min(jitter, maxDelay);
414
+ const waitMs = Math.max(serverMinimumMs, clientBackoffMs);
351
415
  if (debug) {
352
- attempt++;
353
- console.log(`Retry attempt #${attempt} after ${delay}ms`);
416
+ const source = serverMinimumMs > 0 ? `server+client` : `client`;
417
+ console.log(
418
+ `Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`
419
+ );
354
420
  }
421
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
422
+ delay = Math.min(delay * multiplier, maxDelay);
355
423
  }
356
424
  }
357
425
  }