@electric-sql/client 1.5.7 → 1.5.8
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/cjs/index.cjs +90 -8
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/index.browser.mjs +7 -3
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.legacy-esm.js +90 -8
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +90 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +105 -2
- package/src/expired-shapes-cache.ts +5 -0
- package/src/fetch.ts +3 -3
- package/src/helpers.ts +1 -1
- package/src/up-to-date-tracker.ts +5 -0
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.
|
|
4
|
+
"version": "1.5.8",
|
|
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 }
|
|
@@ -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:
|
|
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:
|
|
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(
|
package/src/fetch.ts
CHANGED
|
@@ -44,9 +44,9 @@ export interface BackoffOptions {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export const BackoffDefaults = {
|
|
47
|
-
initialDelay:
|
|
48
|
-
maxDelay:
|
|
49
|
-
multiplier:
|
|
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 &&
|
|
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>(
|