@electric-sql/client 1.5.7 → 1.5.9

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@electric-sql/client",
3
3
  "description": "Postgres everywhere - your data, in sync, wherever you need it.",
4
- "version": "1.5.7",
4
+ "version": "1.5.9",
5
5
  "author": "ElectricSQL team and contributors.",
6
6
  "bugs": {
7
7
  "url": "https://github.com/electric-sql/electric/issues"
package/src/client.ts CHANGED
@@ -94,6 +94,8 @@ const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
94
94
  CACHE_BUSTER_QUERY_PARAM,
95
95
  ])
96
96
 
97
+ const TROUBLESHOOTING_URL = `https://electric-sql.com/docs/guides/troubleshooting`
98
+
97
99
  type Replica = `full` | `default`
98
100
  export type LogMode = `changes_only` | `full`
99
101
 
@@ -606,6 +608,15 @@ export class ShapeStream<T extends Row<unknown> = Row>
606
608
  #unsubscribeFromVisibilityChanges?: () => void
607
609
  #unsubscribeFromWakeDetection?: () => void
608
610
  #maxStaleCacheRetries = 3
611
+ // Fast-loop detection: track recent non-live requests to detect tight retry
612
+ // loops caused by proxy/CDN misconfiguration or stale client-side caches
613
+ #recentRequestEntries: Array<{ timestamp: number; offset: string }> = []
614
+ #fastLoopWindowMs = 500
615
+ #fastLoopThreshold = 5
616
+ #fastLoopBackoffBaseMs = 100
617
+ #fastLoopBackoffMaxMs = 5_000
618
+ #fastLoopConsecutiveCount = 0
619
+ #fastLoopMaxCount = 5
609
620
 
610
621
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
611
622
  this.options = { subscribe: true, ...options }
@@ -687,7 +698,6 @@ export class ShapeStream<T extends Row<unknown> = Row>
687
698
  this.#fetchClient = createFetchWithConsumedMessages(this.#sseFetchClient)
688
699
 
689
700
  this.#subscribeToVisibilityChanges()
690
- this.#subscribeToWakeDetection()
691
701
  }
692
702
 
693
703
  get shapeHandle() {
@@ -712,6 +722,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
712
722
 
713
723
  async #start(): Promise<void> {
714
724
  this.#started = true
725
+ this.#subscribeToWakeDetection()
715
726
 
716
727
  try {
717
728
  await this.#requestShape()
@@ -750,6 +761,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
750
761
  if (this.#syncState instanceof ErrorState) {
751
762
  this.#syncState = this.#syncState.retry()
752
763
  }
764
+ this.#fastLoopConsecutiveCount = 0
765
+ this.#recentRequestEntries = []
753
766
 
754
767
  // Restart from current offset
755
768
  this.#started = false
@@ -793,6 +806,14 @@ export class ShapeStream<T extends Row<unknown> = Row>
793
806
  return
794
807
  }
795
808
 
809
+ // Only check for fast loops on non-live requests; live polling is expected to be rapid
810
+ if (!this.#syncState.isUpToDate) {
811
+ await this.#checkFastLoop()
812
+ } else {
813
+ this.#fastLoopConsecutiveCount = 0
814
+ this.#recentRequestEntries = []
815
+ }
816
+
796
817
  let resumingFromPause = false
797
818
  if (this.#syncState instanceof PausedState) {
798
819
  resumingFromPause = true
@@ -898,6 +919,88 @@ export class ShapeStream<T extends Row<unknown> = Row>
898
919
  return this.#requestShape()
899
920
  }
900
921
 
922
+ /**
923
+ * Detects tight retry loops (e.g., from stale client-side caches or
924
+ * proxy/CDN misconfiguration) and attempts recovery. On first detection,
925
+ * clears client-side caches (in-memory and localStorage) and resets the
926
+ * stream to fetch from scratch.
927
+ * If the loop persists, applies exponential backoff and eventually throws.
928
+ */
929
+ async #checkFastLoop(): Promise<void> {
930
+ const now = Date.now()
931
+ const currentOffset = this.#syncState.offset
932
+
933
+ this.#recentRequestEntries = this.#recentRequestEntries.filter(
934
+ (e) => now - e.timestamp < this.#fastLoopWindowMs
935
+ )
936
+ this.#recentRequestEntries.push({ timestamp: now, offset: currentOffset })
937
+
938
+ // Only flag as a fast loop if requests are stuck at the same offset.
939
+ // Normal rapid syncing advances the offset with each response.
940
+ const sameOffsetCount = this.#recentRequestEntries.filter(
941
+ (e) => e.offset === currentOffset
942
+ ).length
943
+
944
+ if (sameOffsetCount < this.#fastLoopThreshold) return
945
+
946
+ this.#fastLoopConsecutiveCount++
947
+
948
+ if (this.#fastLoopConsecutiveCount >= this.#fastLoopMaxCount) {
949
+ throw new FetchError(
950
+ 502,
951
+ undefined,
952
+ undefined,
953
+ {},
954
+ this.options.url,
955
+ `Client is stuck in a fast retry loop ` +
956
+ `(${this.#fastLoopThreshold} requests in ${this.#fastLoopWindowMs}ms at the same offset, ` +
957
+ `repeated ${this.#fastLoopMaxCount} times). ` +
958
+ `Client-side caches were cleared automatically on first detection, but the loop persists. ` +
959
+ `This usually indicates a proxy or CDN misconfiguration. ` +
960
+ `Common causes:\n` +
961
+ ` - Proxy is not including query parameters (handle, offset) in its cache key\n` +
962
+ ` - CDN is serving stale 409 responses\n` +
963
+ ` - Proxy is stripping required Electric headers from responses\n` +
964
+ `For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
965
+ )
966
+ }
967
+
968
+ if (this.#fastLoopConsecutiveCount === 1) {
969
+ console.warn(
970
+ `[Electric] Detected fast retry loop ` +
971
+ `(${this.#fastLoopThreshold} requests in ${this.#fastLoopWindowMs}ms at the same offset). ` +
972
+ `Clearing client-side caches and resetting stream to recover. ` +
973
+ `If this persists, check that your proxy includes all query parameters ` +
974
+ `(especially 'handle' and 'offset') in its cache key, ` +
975
+ `and that required Electric headers are forwarded to the client. ` +
976
+ `For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
977
+ )
978
+
979
+ if (this.#currentFetchUrl) {
980
+ const shapeKey = canonicalShapeKey(this.#currentFetchUrl)
981
+ expiredShapesCache.delete(shapeKey)
982
+ upToDateTracker.delete(shapeKey)
983
+ } else {
984
+ expiredShapesCache.clear()
985
+ upToDateTracker.clear()
986
+ }
987
+ this.#reset()
988
+ this.#recentRequestEntries = []
989
+ return
990
+ }
991
+
992
+ // Exponential backoff with full jitter
993
+ const maxDelay = Math.min(
994
+ this.#fastLoopBackoffMaxMs,
995
+ this.#fastLoopBackoffBaseMs * Math.pow(2, this.#fastLoopConsecutiveCount)
996
+ )
997
+ const delayMs = Math.floor(Math.random() * maxDelay)
998
+
999
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
1000
+
1001
+ this.#recentRequestEntries = []
1002
+ }
1003
+
901
1004
  async #constructUrl(
902
1005
  url: string,
903
1006
  resumingFromPause: boolean,
@@ -1115,7 +1218,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1115
1218
  `CDN continues serving stale cached responses after ${this.#maxStaleCacheRetries} retry attempts. ` +
1116
1219
  `This indicates a severe proxy/CDN misconfiguration. ` +
1117
1220
  `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
1118
- `For more information visit the troubleshooting guide: https://electric-sql.com/docs/guides/troubleshooting`
1221
+ `For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
1119
1222
  )
1120
1223
  }
1121
1224
  console.warn(
@@ -1123,7 +1226,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1123
1226
  `This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
1124
1227
  `The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
1125
1228
  `Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
1126
- `For more information visit the troubleshooting guide: https://electric-sql.com/docs/guides/troubleshooting ` +
1229
+ `For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL} ` +
1127
1230
  `Retrying with a random cache buster to bypass the stale cache (attempt ${this.#syncState.staleCacheRetryCount}/${this.#maxStaleCacheRetries}).`
1128
1231
  )
1129
1232
  throw new StaleCacheError(
@@ -1540,6 +1643,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1540
1643
  // Store cleanup function to remove the event listener
1541
1644
  this.#unsubscribeFromVisibilityChanges = () => {
1542
1645
  document.removeEventListener(`visibilitychange`, visibilityHandler)
1646
+ this.#unsubscribeFromVisibilityChanges = undefined
1543
1647
  }
1544
1648
  }
1545
1649
  }
@@ -1558,6 +1662,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1558
1662
  */
1559
1663
  #subscribeToWakeDetection() {
1560
1664
  if (this.#hasBrowserVisibilityAPI()) return
1665
+ if (this.#unsubscribeFromWakeDetection) return
1561
1666
 
1562
1667
  const INTERVAL_MS = 2_000
1563
1668
  const WAKE_THRESHOLD_MS = 4_000
@@ -1592,6 +1697,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1592
1697
 
1593
1698
  this.#unsubscribeFromWakeDetection = () => {
1594
1699
  clearInterval(timer)
1700
+ this.#unsubscribeFromWakeDetection = undefined
1595
1701
  }
1596
1702
  }
1597
1703
 
@@ -66,6 +66,11 @@ export class ExpiredShapesCache {
66
66
  this.data = {}
67
67
  this.save()
68
68
  }
69
+
70
+ delete(shapeUrl: string): void {
71
+ delete this.data[shapeUrl]
72
+ this.save()
73
+ }
69
74
  }
70
75
 
71
76
  // Module-level singleton instance
package/src/fetch.ts CHANGED
@@ -44,9 +44,9 @@ export interface BackoffOptions {
44
44
  }
45
45
 
46
46
  export const BackoffDefaults = {
47
- initialDelay: 100,
48
- maxDelay: 60_000, // Cap at 60s - reasonable for long-lived connections
49
- multiplier: 1.3,
47
+ initialDelay: 1_000,
48
+ maxDelay: 32_000,
49
+ multiplier: 2,
50
50
  maxRetries: Infinity, // Retry forever - clients may go offline and come back
51
51
  }
52
52
 
package/src/helpers.ts CHANGED
@@ -51,7 +51,7 @@ export function isChangeMessage<T extends Row<unknown> = Row>(
51
51
  export function isControlMessage<T extends Row<unknown> = Row>(
52
52
  message: Message<T>
53
53
  ): message is ControlMessage {
54
- return message != null && !isChangeMessage(message)
54
+ return message != null && `headers` in message && `control` in message.headers
55
55
  }
56
56
 
57
57
  export function isUpToDateMessage<T extends Row<unknown> = Row>(
@@ -151,6 +151,11 @@ export class UpToDateTracker {
151
151
  }
152
152
  this.save()
153
153
  }
154
+
155
+ delete(shapeKey: string): void {
156
+ delete this.data[shapeKey]
157
+ this.save()
158
+ }
154
159
  }
155
160
 
156
161
  // Module-level singleton instance