@electric-sql/client 1.1.2 → 1.1.3

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,24 @@ 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
+ maxRetries?: number;
143
150
  }
144
151
  declare const BackoffDefaults: {
145
152
  initialDelay: number;
146
153
  maxDelay: number;
147
154
  multiplier: number;
155
+ maxRetries: number;
148
156
  };
149
157
 
150
158
  declare const LIVE_CACHE_BUSTER_QUERY_PARAM = "cursor";
@@ -296,10 +304,51 @@ interface ShapeStreamOptions<T = never> {
296
304
  transformer?: TransformFunction<T>;
297
305
  /**
298
306
  * A function for handling shapestream errors.
299
- * This is optional, when it is not provided any shapestream errors will be thrown.
300
- * If the function returns an object containing parameters and/or headers
301
- * the shapestream will apply those changes and try syncing again.
302
- * If the function returns void the shapestream is stopped.
307
+ *
308
+ * **Automatic retries**: The client automatically retries 5xx server errors, network
309
+ * errors, and 429 rate limits with exponential backoff. The `onError` callback is
310
+ * only invoked after these automatic retries are exhausted, or for non-retryable
311
+ * errors like 4xx client errors.
312
+ *
313
+ * When not provided, non-retryable errors will be thrown and syncing will stop.
314
+ *
315
+ * **Return value behavior**:
316
+ * - Return an **object** (RetryOpts or empty `{}`) to retry syncing:
317
+ * - `{}` - Retry with the same params and headers
318
+ * - `{ params }` - Retry with modified params
319
+ * - `{ headers }` - Retry with modified headers (e.g., refreshed auth token)
320
+ * - `{ params, headers }` - Retry with both modified
321
+ * - Return **void** or **undefined** to stop the stream permanently
322
+ *
323
+ * **Important**: If you want syncing to continue after an error (e.g., to retry
324
+ * on network failures), you MUST return at least an empty object `{}`. Simply
325
+ * logging the error and returning nothing will stop syncing.
326
+ *
327
+ * Supports async functions that return `Promise<void | RetryOpts>`.
328
+ *
329
+ * @example
330
+ * ```typescript
331
+ * // Retry on network errors, stop on others
332
+ * onError: (error) => {
333
+ * console.error('Stream error:', error)
334
+ * if (error instanceof FetchError && error.status >= 500) {
335
+ * return {} // Retry with same params
336
+ * }
337
+ * // Return void to stop on other errors
338
+ * }
339
+ * ```
340
+ *
341
+ * @example
342
+ * ```typescript
343
+ * // Refresh auth token on 401
344
+ * onError: async (error) => {
345
+ * if (error instanceof FetchError && error.status === 401) {
346
+ * const newToken = await refreshAuthToken()
347
+ * return { headers: { Authorization: `Bearer ${newToken}` } }
348
+ * }
349
+ * return {} // Retry other errors
350
+ * }
351
+ * ```
303
352
  */
304
353
  onError?: ShapeStreamErrorHandler;
305
354
  }
@@ -300,6 +300,7 @@ var SUBSET_PARAM_ORDER_BY = `subset__order_by`;
300
300
  var SUBSET_PARAM_WHERE_PARAMS = `subset__params`;
301
301
  var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
302
302
  LIVE_QUERY_PARAM,
303
+ LIVE_SSE_QUERY_PARAM,
303
304
  SHAPE_HANDLE_QUERY_PARAM,
304
305
  OFFSET_QUERY_PARAM,
305
306
  LIVE_CACHE_BUSTER_QUERY_PARAM,
@@ -316,16 +317,33 @@ var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
316
317
  var HTTP_RETRY_STATUS_CODES = [429];
317
318
  var BackoffDefaults = {
318
319
  initialDelay: 100,
319
- maxDelay: 1e4,
320
- multiplier: 1.3
320
+ maxDelay: 6e4,
321
+ // Cap at 60s - reasonable for long-lived connections
322
+ multiplier: 1.3,
323
+ maxRetries: Infinity
324
+ // Retry forever - clients may go offline and come back
321
325
  };
326
+ function parseRetryAfterHeader(retryAfter) {
327
+ if (!retryAfter) return 0;
328
+ const retryAfterSec = Number(retryAfter);
329
+ if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
330
+ return retryAfterSec * 1e3;
331
+ }
332
+ const retryDate = Date.parse(retryAfter);
333
+ if (!isNaN(retryDate)) {
334
+ const deltaMs = retryDate - Date.now();
335
+ return Math.max(0, Math.min(deltaMs, 36e5));
336
+ }
337
+ return 0;
338
+ }
322
339
  function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
323
340
  const {
324
341
  initialDelay,
325
342
  maxDelay,
326
343
  multiplier,
327
344
  debug = false,
328
- onFailedAttempt
345
+ onFailedAttempt,
346
+ maxRetries = Infinity
329
347
  } = backoffOptions;
330
348
  return async (...args) => {
331
349
  var _a;
@@ -336,7 +354,9 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
336
354
  while (true) {
337
355
  try {
338
356
  const result = await fetchClient(...args);
339
- if (result.ok) return result;
357
+ if (result.ok) {
358
+ return result;
359
+ }
340
360
  const err = await FetchError.fromResponse(result, url.toString());
341
361
  throw err;
342
362
  } catch (e) {
@@ -346,12 +366,27 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
346
366
  } else if (e instanceof FetchError && !HTTP_RETRY_STATUS_CODES.includes(e.status) && e.status >= 400 && e.status < 500) {
347
367
  throw e;
348
368
  } else {
349
- await new Promise((resolve) => setTimeout(resolve, delay));
350
- delay = Math.min(delay * multiplier, maxDelay);
369
+ attempt++;
370
+ if (attempt > maxRetries) {
371
+ if (debug) {
372
+ console.log(
373
+ `Max retries reached (${attempt}/${maxRetries}), giving up`
374
+ );
375
+ }
376
+ throw e;
377
+ }
378
+ const serverMinimumMs = e instanceof FetchError && e.headers ? parseRetryAfterHeader(e.headers[`retry-after`]) : 0;
379
+ const jitter = Math.random() * delay;
380
+ const clientBackoffMs = Math.min(jitter, maxDelay);
381
+ const waitMs = Math.max(serverMinimumMs, clientBackoffMs);
351
382
  if (debug) {
352
- attempt++;
353
- console.log(`Retry attempt #${attempt} after ${delay}ms`);
383
+ const source = serverMinimumMs > 0 ? `server+client` : `client`;
384
+ console.log(
385
+ `Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`
386
+ );
354
387
  }
388
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
389
+ delay = Math.min(delay * multiplier, maxDelay);
355
390
  }
356
391
  }
357
392
  }