@electric-sql/client 1.1.1 → 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/README.md CHANGED
@@ -99,26 +99,84 @@ shape.subscribe(({ rows }) => {
99
99
 
100
100
  ### Error Handling
101
101
 
102
- The ShapeStream provides two ways to handle errors:
102
+ The ShapeStream provides robust error handling with automatic retry support:
103
103
 
104
- 1. Using the `onError` handler:
104
+ #### 1. Stream-level error handler with retry control
105
+
106
+ The `onError` handler gives you full control over error recovery:
105
107
 
106
108
  ```typescript
107
109
  const stream = new ShapeStream({
108
110
  url: `${BASE_URL}/v1/shape`,
109
- params: {
110
- table: `foo`,
111
- },
111
+ params: { table: `foo` },
112
112
  onError: (error) => {
113
- // Handle all stream errors here
114
113
  console.error('Stream error:', error)
114
+
115
+ // IMPORTANT: Return an object to keep syncing!
116
+ // Return void/undefined to stop syncing permanently.
117
+
118
+ // Note: 5xx errors and network errors are automatically retried,
119
+ // so onError is mainly for handling client errors (4xx)
120
+
121
+ if (error instanceof FetchError) {
122
+ if (error.status === 401) {
123
+ // Unauthorized - refresh token and retry
124
+ const newToken = getRefreshedToken()
125
+ return {
126
+ headers: {
127
+ Authorization: `Bearer ${newToken}`,
128
+ },
129
+ }
130
+ }
131
+
132
+ if (error.status === 403) {
133
+ // Forbidden - maybe change user context
134
+ return {
135
+ params: {
136
+ table: `foo`,
137
+ where: `user_id = $1`,
138
+ params: [fallbackUserId],
139
+ },
140
+ }
141
+ }
142
+ }
143
+
144
+ // Stop syncing for other errors (return void)
115
145
  },
116
146
  })
117
147
  ```
118
148
 
119
- If no `onError` handler is provided, the ShapeStream will throw errors that occur during streaming.
149
+ **Critical**: The `onError` callback's return value controls whether syncing continues:
120
150
 
121
- 2. Individual subscribers can optionally handle errors specific to their subscription:
151
+ - **Return an object** (even empty `{}`) to retry syncing:
152
+ - `{}` - Retry with same params and headers
153
+ - `{ params }` - Retry with modified params
154
+ - `{ headers }` - Retry with modified headers
155
+ - `{ params, headers }` - Retry with both modified
156
+ - **Return void/undefined** to stop the stream permanently
157
+
158
+ The handler supports async operations:
159
+
160
+ ```typescript
161
+ onError: async (error) => {
162
+ if (error instanceof FetchError && error.status === 401) {
163
+ // Perform async token refresh
164
+ const newToken = await refreshAuthToken()
165
+ return {
166
+ headers: { Authorization: `Bearer ${newToken}` },
167
+ }
168
+ }
169
+ return {} // Retry other errors
170
+ }
171
+ ```
172
+
173
+ **Automatic retries**: The client automatically retries 5xx server errors, network errors, and 429 rate limits with exponential backoff. The `onError` callback is only invoked after these retries are exhausted, or for non-retryable errors like 4xx client errors.
174
+
175
+ **Without `onError`**: If no `onError` handler is provided, non-retryable errors (like 4xx client errors) will be thrown and the stream will stop.
176
+
177
+ #### 2. Subscription-level error callbacks
178
+
179
+ Individual subscribers can handle errors specific to their subscription:
122
180
 
123
181
  ```typescript
124
182
  stream.subscribe(
@@ -132,7 +190,11 @@ stream.subscribe(
132
190
  )
133
191
  ```
134
192
 
135
- Common error types include:
193
+ Note: Subscription error callbacks cannot control retry behavior - use the stream-level `onError` for that.
194
+
195
+ #### Common Error Types
196
+
197
+ Setup errors:
136
198
 
137
199
  - `MissingShapeUrlError`: Missing required URL parameter
138
200
  - `InvalidSignalError`: Invalid AbortSignal instance
@@ -140,12 +202,12 @@ Common error types include:
140
202
 
141
203
  Runtime errors:
142
204
 
143
- - `FetchError`: HTTP errors during shape fetching
205
+ - `FetchError`: HTTP errors during shape fetching (includes `status`, `url`, `headers`)
144
206
  - `FetchBackoffAbortError`: Fetch aborted using AbortSignal
145
207
  - `MissingShapeHandleError`: Missing required shape handle
146
- - `ParserNullValueError`: Parser encountered NULL value in a column that doesn't allow NULL values
208
+ - `ParserNullValueError`: NULL value in a non-nullable column
147
209
 
148
- See the [typescript client docs on the website](https://electric-sql.com/docs/api/clients/typescript#error-handling) for more details on error handling.
210
+ See the [TypeScript client docs](https://electric-sql.com/docs/api/clients/typescript#error-handling) for more details.
149
211
 
150
212
  And in general, see the [docs website](https://electric-sql.com) and [examples](https://electric-sql.com/demos) for more information.
151
213
 
@@ -353,6 +353,7 @@ var SUBSET_PARAM_ORDER_BY = `subset__order_by`;
353
353
  var SUBSET_PARAM_WHERE_PARAMS = `subset__params`;
354
354
  var ELECTRIC_PROTOCOL_QUERY_PARAMS = [
355
355
  LIVE_QUERY_PARAM,
356
+ LIVE_SSE_QUERY_PARAM,
356
357
  SHAPE_HANDLE_QUERY_PARAM,
357
358
  OFFSET_QUERY_PARAM,
358
359
  LIVE_CACHE_BUSTER_QUERY_PARAM,
@@ -372,11 +373,22 @@ var BackoffDefaults = {
372
373
  maxDelay: 6e4,
373
374
  // Cap at 60s - reasonable for long-lived connections
374
375
  multiplier: 1.3,
375
- maxRetries: Infinity,
376
+ maxRetries: Infinity
376
377
  // Retry forever - clients may go offline and come back
377
- retryBudgetPercent: 0.1
378
- // 10% retry budget prevents amplification
379
378
  };
379
+ function parseRetryAfterHeader(retryAfter) {
380
+ if (!retryAfter) return 0;
381
+ const retryAfterSec = Number(retryAfter);
382
+ if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
383
+ return retryAfterSec * 1e3;
384
+ }
385
+ const retryDate = Date.parse(retryAfter);
386
+ if (!isNaN(retryDate)) {
387
+ const deltaMs = retryDate - Date.now();
388
+ return Math.max(0, Math.min(deltaMs, 36e5));
389
+ }
390
+ return 0;
391
+ }
380
392
  function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
381
393
  const {
382
394
  initialDelay,
@@ -384,28 +396,8 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
384
396
  multiplier,
385
397
  debug = false,
386
398
  onFailedAttempt,
387
- maxRetries = Infinity,
388
- retryBudgetPercent = 0.1
399
+ maxRetries = Infinity
389
400
  } = backoffOptions;
390
- let totalRequests = 0;
391
- let totalRetries = 0;
392
- let budgetResetTime = Date.now() + 6e4;
393
- function checkRetryBudget(percent) {
394
- const now = Date.now();
395
- if (now > budgetResetTime) {
396
- totalRequests = 0;
397
- totalRetries = 0;
398
- budgetResetTime = now + 6e4;
399
- }
400
- totalRequests++;
401
- if (totalRequests < 10) return true;
402
- const currentRetryRate = totalRetries / totalRequests;
403
- const hasCapacity = currentRetryRate < percent;
404
- if (hasCapacity) {
405
- totalRetries++;
406
- }
407
- return hasCapacity;
408
- }
409
401
  return (...args) => __async(this, null, function* () {
410
402
  var _a;
411
403
  const url = args[0];
@@ -416,7 +408,6 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
416
408
  try {
417
409
  const result = yield fetchClient(...args);
418
410
  if (result.ok) {
419
- delay = initialDelay;
420
411
  return result;
421
412
  }
422
413
  const err = yield FetchError.fromResponse(result, url.toString());
@@ -429,7 +420,7 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
429
420
  throw e;
430
421
  } else {
431
422
  attempt++;
432
- if (attempt >= maxRetries) {
423
+ if (attempt > maxRetries) {
433
424
  if (debug) {
434
425
  console.log(
435
426
  `Max retries reached (${attempt}/${maxRetries}), giving up`
@@ -437,31 +428,7 @@ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
437
428
  }
438
429
  throw e;
439
430
  }
440
- if (!checkRetryBudget(retryBudgetPercent)) {
441
- if (debug) {
442
- console.log(
443
- `Retry budget exhausted (attempt ${attempt}), backing off`
444
- );
445
- }
446
- yield new Promise((resolve) => setTimeout(resolve, maxDelay));
447
- continue;
448
- }
449
- let serverMinimumMs = 0;
450
- if (e instanceof FetchError && e.headers) {
451
- const retryAfter = e.headers[`retry-after`];
452
- if (retryAfter) {
453
- const retryAfterSec = Number(retryAfter);
454
- if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
455
- serverMinimumMs = retryAfterSec * 1e3;
456
- } else {
457
- const retryDate = Date.parse(retryAfter);
458
- if (!isNaN(retryDate)) {
459
- const deltaMs = retryDate - Date.now();
460
- serverMinimumMs = Math.max(0, Math.min(deltaMs, 36e5));
461
- }
462
- }
463
- }
464
- }
431
+ const serverMinimumMs = e instanceof FetchError && e.headers ? parseRetryAfterHeader(e.headers[`retry-after`]) : 0;
465
432
  const jitter = Math.random() * delay;
466
433
  const clientBackoffMs = Math.min(jitter, maxDelay);
467
434
  const waitMs = Math.max(serverMinimumMs, clientBackoffMs);
@@ -843,8 +810,9 @@ function canonicalShapeKey(url) {
843
810
  cleanUrl.searchParams.sort();
844
811
  return cleanUrl.toString();
845
812
  }
846
- var _error, _fetchClient2, _sseFetchClient, _messageParser, _subscribers, _started, _state, _lastOffset, _liveCacheBuster, _lastSyncedAt, _isUpToDate, _isMidStream, _connected, _shapeHandle, _mode, _schema, _onError, _requestAbortController, _isRefreshing, _tickPromise, _tickPromiseResolver, _tickPromiseRejecter, _messageChain, _snapshotTracker, _activeSnapshotRequests, _midStreamPromise, _midStreamPromiseResolver, _ShapeStream_instances, start_fn, requestShape_fn, constructUrl_fn, createAbortListener_fn, onInitialResponse_fn, onMessages_fn, fetchShape_fn, requestShapeLongPoll_fn, requestShapeSSE_fn, pause_fn, resume_fn, nextTick_fn, waitForStreamEnd_fn, publish_fn, sendErrorToSubscribers_fn, subscribeToVisibilityChanges_fn, reset_fn, fetchSnapshot_fn;
813
+ var _error, _fetchClient2, _sseFetchClient, _messageParser, _subscribers, _started, _state, _lastOffset, _liveCacheBuster, _lastSyncedAt, _isUpToDate, _isMidStream, _connected, _shapeHandle, _mode, _schema, _onError, _requestAbortController, _isRefreshing, _tickPromise, _tickPromiseResolver, _tickPromiseRejecter, _messageChain, _snapshotTracker, _activeSnapshotRequests, _midStreamPromise, _midStreamPromiseResolver, _lastSseConnectionStartTime, _minSseConnectionDuration, _consecutiveShortSseConnections, _maxShortSseConnections, _sseFallbackToLongPolling, _sseBackoffBaseDelay, _sseBackoffMaxDelay, _ShapeStream_instances, start_fn, requestShape_fn, constructUrl_fn, createAbortListener_fn, onInitialResponse_fn, onMessages_fn, fetchShape_fn, requestShapeLongPoll_fn, requestShapeSSE_fn, pause_fn, resume_fn, nextTick_fn, waitForStreamEnd_fn, publish_fn, sendErrorToSubscribers_fn, subscribeToVisibilityChanges_fn, reset_fn, fetchSnapshot_fn;
847
814
  var ShapeStream = class {
815
+ // Maximum delay cap (ms)
848
816
  constructor(options) {
849
817
  __privateAdd(this, _ShapeStream_instances);
850
818
  __privateAdd(this, _error, null);
@@ -878,6 +846,16 @@ var ShapeStream = class {
878
846
  // counter for concurrent snapshot requests
879
847
  __privateAdd(this, _midStreamPromise);
880
848
  __privateAdd(this, _midStreamPromiseResolver);
849
+ __privateAdd(this, _lastSseConnectionStartTime);
850
+ __privateAdd(this, _minSseConnectionDuration, 1e3);
851
+ // Minimum expected SSE connection duration (1 second)
852
+ __privateAdd(this, _consecutiveShortSseConnections, 0);
853
+ __privateAdd(this, _maxShortSseConnections, 3);
854
+ // Fall back to long polling after this many short connections
855
+ __privateAdd(this, _sseFallbackToLongPolling, false);
856
+ __privateAdd(this, _sseBackoffBaseDelay, 100);
857
+ // Base delay for exponential backoff (ms)
858
+ __privateAdd(this, _sseBackoffMaxDelay, 5e3);
881
859
  var _a, _b, _c, _d;
882
860
  this.options = __spreadValues({ subscribe: true }, options);
883
861
  validateOptions(this.options);
@@ -1053,10 +1031,17 @@ _snapshotTracker = new WeakMap();
1053
1031
  _activeSnapshotRequests = new WeakMap();
1054
1032
  _midStreamPromise = new WeakMap();
1055
1033
  _midStreamPromiseResolver = new WeakMap();
1034
+ _lastSseConnectionStartTime = new WeakMap();
1035
+ _minSseConnectionDuration = new WeakMap();
1036
+ _consecutiveShortSseConnections = new WeakMap();
1037
+ _maxShortSseConnections = new WeakMap();
1038
+ _sseFallbackToLongPolling = new WeakMap();
1039
+ _sseBackoffBaseDelay = new WeakMap();
1040
+ _sseBackoffMaxDelay = new WeakMap();
1056
1041
  _ShapeStream_instances = new WeakSet();
1057
1042
  start_fn = function() {
1058
1043
  return __async(this, null, function* () {
1059
- var _a;
1044
+ var _a, _b, _c, _d, _e;
1060
1045
  __privateSet(this, _started, true);
1061
1046
  try {
1062
1047
  yield __privateMethod(this, _ShapeStream_instances, requestShape_fn).call(this);
@@ -1064,24 +1049,34 @@ start_fn = function() {
1064
1049
  __privateSet(this, _error, err);
1065
1050
  if (__privateGet(this, _onError)) {
1066
1051
  const retryOpts = yield __privateGet(this, _onError).call(this, err);
1067
- if (typeof retryOpts === `object`) {
1068
- __privateMethod(this, _ShapeStream_instances, reset_fn).call(this);
1069
- if (`params` in retryOpts) {
1070
- this.options.params = retryOpts.params;
1052
+ if (retryOpts && typeof retryOpts === `object`) {
1053
+ if (retryOpts.params) {
1054
+ this.options.params = __spreadValues(__spreadValues({}, (_a = this.options.params) != null ? _a : {}), retryOpts.params);
1071
1055
  }
1072
- if (`headers` in retryOpts) {
1073
- this.options.headers = retryOpts.headers;
1056
+ if (retryOpts.headers) {
1057
+ this.options.headers = __spreadValues(__spreadValues({}, (_b = this.options.headers) != null ? _b : {}), retryOpts.headers);
1074
1058
  }
1059
+ __privateSet(this, _error, null);
1075
1060
  __privateSet(this, _started, false);
1076
- __privateMethod(this, _ShapeStream_instances, start_fn).call(this);
1061
+ yield __privateMethod(this, _ShapeStream_instances, start_fn).call(this);
1062
+ return;
1063
+ }
1064
+ if (err instanceof Error) {
1065
+ __privateMethod(this, _ShapeStream_instances, sendErrorToSubscribers_fn).call(this, err);
1077
1066
  }
1067
+ __privateSet(this, _connected, false);
1068
+ (_c = __privateGet(this, _tickPromiseRejecter)) == null ? void 0 : _c.call(this);
1078
1069
  return;
1079
1070
  }
1080
- throw err;
1081
- } finally {
1071
+ if (err instanceof Error) {
1072
+ __privateMethod(this, _ShapeStream_instances, sendErrorToSubscribers_fn).call(this, err);
1073
+ }
1082
1074
  __privateSet(this, _connected, false);
1083
- (_a = __privateGet(this, _tickPromiseRejecter)) == null ? void 0 : _a.call(this);
1075
+ (_d = __privateGet(this, _tickPromiseRejecter)) == null ? void 0 : _d.call(this);
1076
+ throw err;
1084
1077
  }
1078
+ __privateSet(this, _connected, false);
1079
+ (_e = __privateGet(this, _tickPromiseRejecter)) == null ? void 0 : _e.call(this);
1085
1080
  });
1086
1081
  };
1087
1082
  requestShape_fn = function() {
@@ -1128,7 +1123,6 @@ requestShape_fn = function() {
1128
1123
  yield __privateMethod(this, _ShapeStream_instances, publish_fn).call(this, Array.isArray(e.json) ? e.json : [e.json]);
1129
1124
  return __privateMethod(this, _ShapeStream_instances, requestShape_fn).call(this);
1130
1125
  } else {
1131
- __privateMethod(this, _ShapeStream_instances, sendErrorToSubscribers_fn).call(this, e);
1132
1126
  throw e;
1133
1127
  }
1134
1128
  } finally {
@@ -1280,7 +1274,7 @@ fetchShape_fn = function(opts) {
1280
1274
  return __async(this, null, function* () {
1281
1275
  var _a;
1282
1276
  const useSse = (_a = this.options.liveSse) != null ? _a : this.options.experimentalLiveSse;
1283
- if (__privateGet(this, _isUpToDate) && useSse && !__privateGet(this, _isRefreshing) && !opts.resumingFromPause) {
1277
+ if (__privateGet(this, _isUpToDate) && useSse && !__privateGet(this, _isRefreshing) && !opts.resumingFromPause && !__privateGet(this, _sseFallbackToLongPolling)) {
1284
1278
  opts.fetchUrl.searchParams.set(EXPERIMENTAL_LIVE_SSE_QUERY_PARAM, `true`);
1285
1279
  opts.fetchUrl.searchParams.set(LIVE_SSE_QUERY_PARAM, `true`);
1286
1280
  return __privateMethod(this, _ShapeStream_instances, requestShapeSSE_fn).call(this, opts);
@@ -1308,6 +1302,7 @@ requestShapeSSE_fn = function(opts) {
1308
1302
  return __async(this, null, function* () {
1309
1303
  const { fetchUrl, requestAbortController, headers } = opts;
1310
1304
  const fetch2 = __privateGet(this, _sseFetchClient);
1305
+ __privateSet(this, _lastSseConnectionStartTime, Date.now());
1311
1306
  try {
1312
1307
  let buffer = [];
1313
1308
  yield (0, import_fetch_event_source.fetchEventSource)(fetchUrl.toString(), {
@@ -1341,6 +1336,27 @@ requestShapeSSE_fn = function(opts) {
1341
1336
  throw new FetchBackoffAbortError();
1342
1337
  }
1343
1338
  throw error;
1339
+ } finally {
1340
+ const connectionDuration = Date.now() - __privateGet(this, _lastSseConnectionStartTime);
1341
+ const wasAborted = requestAbortController.signal.aborted;
1342
+ if (connectionDuration < __privateGet(this, _minSseConnectionDuration) && !wasAborted) {
1343
+ __privateWrapper(this, _consecutiveShortSseConnections)._++;
1344
+ if (__privateGet(this, _consecutiveShortSseConnections) >= __privateGet(this, _maxShortSseConnections)) {
1345
+ __privateSet(this, _sseFallbackToLongPolling, true);
1346
+ console.warn(
1347
+ `[Electric] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). Falling back to long polling. Your proxy must support streaming SSE responses (not buffer the complete response). Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy. Note: Do NOT disable caching entirely - Electric uses cache headers to enable request collapsing for efficiency.`
1348
+ );
1349
+ } else {
1350
+ const maxDelay = Math.min(
1351
+ __privateGet(this, _sseBackoffMaxDelay),
1352
+ __privateGet(this, _sseBackoffBaseDelay) * Math.pow(2, __privateGet(this, _consecutiveShortSseConnections))
1353
+ );
1354
+ const delayMs = Math.floor(Math.random() * maxDelay);
1355
+ yield new Promise((resolve) => setTimeout(resolve, delayMs));
1356
+ }
1357
+ } else if (connectionDuration >= __privateGet(this, _minSseConnectionDuration)) {
1358
+ __privateSet(this, _consecutiveShortSseConnections, 0);
1359
+ }
1344
1360
  }
1345
1361
  });
1346
1362
  };
@@ -1439,6 +1455,8 @@ reset_fn = function(handle) {
1439
1455
  __privateSet(this, _connected, false);
1440
1456
  __privateSet(this, _schema, void 0);
1441
1457
  __privateSet(this, _activeSnapshotRequests, 0);
1458
+ __privateSet(this, _consecutiveShortSseConnections, 0);
1459
+ __privateSet(this, _sseFallbackToLongPolling, false);
1442
1460
  };
1443
1461
  fetchSnapshot_fn = function(url, headers) {
1444
1462
  return __async(this, null, function* () {