@electric-sql/client 1.1.1 → 1.1.2
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 +73 -90
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +0 -22
- package/dist/index.browser.mjs +3 -3
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +0 -22
- package/dist/index.legacy-esm.js +73 -90
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +73 -90
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +97 -17
- package/src/fetch.ts +9 -126
package/src/client.ts
CHANGED
|
@@ -441,6 +441,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
441
441
|
#activeSnapshotRequests = 0 // counter for concurrent snapshot requests
|
|
442
442
|
#midStreamPromise?: Promise<void>
|
|
443
443
|
#midStreamPromiseResolver?: () => void
|
|
444
|
+
#lastSseConnectionStartTime?: number
|
|
445
|
+
#minSseConnectionDuration = 1000 // Minimum expected SSE connection duration (1 second)
|
|
446
|
+
#consecutiveShortSseConnections = 0
|
|
447
|
+
#maxShortSseConnections = 3 // Fall back to long polling after this many short connections
|
|
448
|
+
#sseFallbackToLongPolling = false
|
|
449
|
+
#sseBackoffBaseDelay = 100 // Base delay for exponential backoff (ms)
|
|
450
|
+
#sseBackoffMaxDelay = 5000 // Maximum delay cap (ms)
|
|
444
451
|
|
|
445
452
|
constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
|
|
446
453
|
this.options = { subscribe: true, ...options }
|
|
@@ -507,32 +514,61 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
507
514
|
await this.#requestShape()
|
|
508
515
|
} catch (err) {
|
|
509
516
|
this.#error = err
|
|
517
|
+
|
|
518
|
+
// Check if onError handler wants to retry
|
|
510
519
|
if (this.#onError) {
|
|
511
520
|
const retryOpts = await this.#onError(err as Error)
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
521
|
+
// Guard against null (typeof null === "object" in JavaScript)
|
|
522
|
+
if (retryOpts && typeof retryOpts === `object`) {
|
|
523
|
+
// Update params/headers but don't reset offset
|
|
524
|
+
// We want to continue from where we left off, not refetch everything
|
|
525
|
+
if (retryOpts.params) {
|
|
526
|
+
// Merge new params with existing params to preserve other parameters
|
|
527
|
+
this.options.params = {
|
|
528
|
+
...(this.options.params ?? {}),
|
|
529
|
+
...retryOpts.params,
|
|
530
|
+
}
|
|
517
531
|
}
|
|
518
532
|
|
|
519
|
-
if (
|
|
520
|
-
|
|
533
|
+
if (retryOpts.headers) {
|
|
534
|
+
// Merge new headers with existing headers to preserve other headers
|
|
535
|
+
this.options.headers = {
|
|
536
|
+
...(this.options.headers ?? {}),
|
|
537
|
+
...retryOpts.headers,
|
|
538
|
+
}
|
|
521
539
|
}
|
|
522
540
|
|
|
523
|
-
//
|
|
541
|
+
// Clear the error since we're retrying
|
|
542
|
+
this.#error = null
|
|
543
|
+
|
|
544
|
+
// Restart from current offset
|
|
524
545
|
this.#started = false
|
|
525
|
-
this.#start()
|
|
546
|
+
await this.#start()
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
// onError returned void, meaning it doesn't want to retry
|
|
550
|
+
// This is an unrecoverable error, notify subscribers
|
|
551
|
+
if (err instanceof Error) {
|
|
552
|
+
this.#sendErrorToSubscribers(err)
|
|
526
553
|
}
|
|
554
|
+
this.#connected = false
|
|
555
|
+
this.#tickPromiseRejecter?.()
|
|
527
556
|
return
|
|
528
557
|
}
|
|
529
558
|
|
|
530
|
-
//
|
|
531
|
-
throw
|
|
532
|
-
|
|
559
|
+
// No onError handler provided, this is an unrecoverable error
|
|
560
|
+
// Notify subscribers and throw
|
|
561
|
+
if (err instanceof Error) {
|
|
562
|
+
this.#sendErrorToSubscribers(err)
|
|
563
|
+
}
|
|
533
564
|
this.#connected = false
|
|
534
565
|
this.#tickPromiseRejecter?.()
|
|
566
|
+
throw err
|
|
535
567
|
}
|
|
568
|
+
|
|
569
|
+
// Normal completion, clean up
|
|
570
|
+
this.#connected = false
|
|
571
|
+
this.#tickPromiseRejecter?.()
|
|
536
572
|
}
|
|
537
573
|
|
|
538
574
|
async #requestShape(): Promise<void> {
|
|
@@ -613,12 +649,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
613
649
|
)
|
|
614
650
|
return this.#requestShape()
|
|
615
651
|
} else {
|
|
616
|
-
// Notify subscribers
|
|
617
|
-
this.#sendErrorToSubscribers(e)
|
|
618
|
-
|
|
619
652
|
// errors that have reached this point are not actionable without
|
|
620
653
|
// additional user input, such as 400s or failures to read the
|
|
621
|
-
// body of a response, so we exit the loop
|
|
654
|
+
// body of a response, so we exit the loop and let #start handle it
|
|
655
|
+
// Note: We don't notify subscribers here because onError might recover
|
|
622
656
|
throw e
|
|
623
657
|
}
|
|
624
658
|
} finally {
|
|
@@ -832,7 +866,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
832
866
|
this.#isUpToDate &&
|
|
833
867
|
useSse &&
|
|
834
868
|
!this.#isRefreshing &&
|
|
835
|
-
!opts.resumingFromPause
|
|
869
|
+
!opts.resumingFromPause &&
|
|
870
|
+
!this.#sseFallbackToLongPolling
|
|
836
871
|
) {
|
|
837
872
|
opts.fetchUrl.searchParams.set(EXPERIMENTAL_LIVE_SSE_QUERY_PARAM, `true`)
|
|
838
873
|
opts.fetchUrl.searchParams.set(LIVE_SSE_QUERY_PARAM, `true`)
|
|
@@ -871,6 +906,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
871
906
|
}): Promise<void> {
|
|
872
907
|
const { fetchUrl, requestAbortController, headers } = opts
|
|
873
908
|
const fetch = this.#sseFetchClient
|
|
909
|
+
|
|
910
|
+
// Track when the SSE connection starts
|
|
911
|
+
this.#lastSseConnectionStartTime = Date.now()
|
|
912
|
+
|
|
874
913
|
try {
|
|
875
914
|
let buffer: Array<Message<T>> = []
|
|
876
915
|
await fetchEventSource(fetchUrl.toString(), {
|
|
@@ -916,6 +955,44 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
916
955
|
throw new FetchBackoffAbortError()
|
|
917
956
|
}
|
|
918
957
|
throw error
|
|
958
|
+
} finally {
|
|
959
|
+
// Check if the SSE connection closed too quickly
|
|
960
|
+
// This can happen when responses are cached or when the proxy/server
|
|
961
|
+
// is misconfigured for SSE and closes the connection immediately
|
|
962
|
+
const connectionDuration = Date.now() - this.#lastSseConnectionStartTime!
|
|
963
|
+
const wasAborted = requestAbortController.signal.aborted
|
|
964
|
+
|
|
965
|
+
if (connectionDuration < this.#minSseConnectionDuration && !wasAborted) {
|
|
966
|
+
// Connection was too short - likely a cached response or misconfiguration
|
|
967
|
+
this.#consecutiveShortSseConnections++
|
|
968
|
+
|
|
969
|
+
if (
|
|
970
|
+
this.#consecutiveShortSseConnections >= this.#maxShortSseConnections
|
|
971
|
+
) {
|
|
972
|
+
// Too many short connections - fall back to long polling
|
|
973
|
+
this.#sseFallbackToLongPolling = true
|
|
974
|
+
console.warn(
|
|
975
|
+
`[Electric] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). ` +
|
|
976
|
+
`Falling back to long polling. ` +
|
|
977
|
+
`Your proxy must support streaming SSE responses (not buffer the complete response). ` +
|
|
978
|
+
`Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy. ` +
|
|
979
|
+
`Note: Do NOT disable caching entirely - Electric uses cache headers to enable request collapsing for efficiency.`
|
|
980
|
+
)
|
|
981
|
+
} else {
|
|
982
|
+
// Add exponential backoff with full jitter to prevent tight infinite loop
|
|
983
|
+
// Formula: random(0, min(cap, base * 2^attempt))
|
|
984
|
+
const maxDelay = Math.min(
|
|
985
|
+
this.#sseBackoffMaxDelay,
|
|
986
|
+
this.#sseBackoffBaseDelay *
|
|
987
|
+
Math.pow(2, this.#consecutiveShortSseConnections)
|
|
988
|
+
)
|
|
989
|
+
const delayMs = Math.floor(Math.random() * maxDelay)
|
|
990
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
991
|
+
}
|
|
992
|
+
} else if (connectionDuration >= this.#minSseConnectionDuration) {
|
|
993
|
+
// Connection was healthy - reset counter
|
|
994
|
+
this.#consecutiveShortSseConnections = 0
|
|
995
|
+
}
|
|
919
996
|
}
|
|
920
997
|
}
|
|
921
998
|
|
|
@@ -1090,6 +1167,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1090
1167
|
this.#connected = false
|
|
1091
1168
|
this.#schema = undefined
|
|
1092
1169
|
this.#activeSnapshotRequests = 0
|
|
1170
|
+
// Reset SSE fallback state to try SSE again after reset
|
|
1171
|
+
this.#consecutiveShortSseConnections = 0
|
|
1172
|
+
this.#sseFallbackToLongPolling = false
|
|
1093
1173
|
}
|
|
1094
1174
|
|
|
1095
1175
|
/**
|
package/src/fetch.ts
CHANGED
|
@@ -28,39 +28,17 @@ export interface BackoffOptions {
|
|
|
28
28
|
initialDelay: number
|
|
29
29
|
/**
|
|
30
30
|
* Maximum retry delay in milliseconds
|
|
31
|
-
* After reaching this, delay stays constant (e.g., retry every 60s)
|
|
32
31
|
*/
|
|
33
32
|
maxDelay: number
|
|
34
33
|
multiplier: number
|
|
35
34
|
onFailedAttempt?: () => void
|
|
36
35
|
debug?: boolean
|
|
37
|
-
/**
|
|
38
|
-
* Maximum number of retry attempts before giving up.
|
|
39
|
-
* Set to Infinity (default) for indefinite retries - needed for offline scenarios
|
|
40
|
-
* where clients may go offline and come back later.
|
|
41
|
-
*
|
|
42
|
-
* The retry budget provides protection against retry storms even with infinite retries.
|
|
43
|
-
*/
|
|
44
|
-
maxRetries?: number
|
|
45
|
-
/**
|
|
46
|
-
* Percentage of requests that can be retries (0.1 = 10%)
|
|
47
|
-
*
|
|
48
|
-
* This is the primary load shedding mechanism. It limits the *rate* of retries,
|
|
49
|
-
* not the total count. Even with infinite retries, at most 10% of your traffic
|
|
50
|
-
* will be retries, preventing retry storms from amplifying server load.
|
|
51
|
-
*
|
|
52
|
-
* The budget resets every 60 seconds, so a temporary spike of errors won't
|
|
53
|
-
* permanently exhaust the budget.
|
|
54
|
-
*/
|
|
55
|
-
retryBudgetPercent?: number
|
|
56
36
|
}
|
|
57
37
|
|
|
58
38
|
export const BackoffDefaults = {
|
|
59
39
|
initialDelay: 100,
|
|
60
|
-
maxDelay:
|
|
40
|
+
maxDelay: 10_000,
|
|
61
41
|
multiplier: 1.3,
|
|
62
|
-
maxRetries: Infinity, // Retry forever - clients may go offline and come back
|
|
63
|
-
retryBudgetPercent: 0.1, // 10% retry budget prevents amplification
|
|
64
42
|
}
|
|
65
43
|
|
|
66
44
|
export function createFetchWithBackoff(
|
|
@@ -73,38 +51,7 @@ export function createFetchWithBackoff(
|
|
|
73
51
|
multiplier,
|
|
74
52
|
debug = false,
|
|
75
53
|
onFailedAttempt,
|
|
76
|
-
maxRetries = Infinity,
|
|
77
|
-
retryBudgetPercent = 0.1,
|
|
78
54
|
} = backoffOptions
|
|
79
|
-
|
|
80
|
-
// Retry budget tracking (closure-scoped)
|
|
81
|
-
// Resets every minute to prevent retry storms
|
|
82
|
-
let totalRequests = 0
|
|
83
|
-
let totalRetries = 0
|
|
84
|
-
let budgetResetTime = Date.now() + 60_000
|
|
85
|
-
|
|
86
|
-
function checkRetryBudget(percent: number): boolean {
|
|
87
|
-
const now = Date.now()
|
|
88
|
-
if (now > budgetResetTime) {
|
|
89
|
-
totalRequests = 0
|
|
90
|
-
totalRetries = 0
|
|
91
|
-
budgetResetTime = now + 60_000
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
totalRequests++
|
|
95
|
-
|
|
96
|
-
// Allow retries for first 10 requests to avoid cold start issues
|
|
97
|
-
if (totalRequests < 10) return true
|
|
98
|
-
|
|
99
|
-
const currentRetryRate = totalRetries / totalRequests
|
|
100
|
-
const hasCapacity = currentRetryRate < percent
|
|
101
|
-
|
|
102
|
-
if (hasCapacity) {
|
|
103
|
-
totalRetries++
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return hasCapacity
|
|
107
|
-
}
|
|
108
55
|
return async (...args: Parameters<typeof fetch>): Promise<Response> => {
|
|
109
56
|
const url = args[0]
|
|
110
57
|
const options = args[1]
|
|
@@ -115,11 +62,7 @@ export function createFetchWithBackoff(
|
|
|
115
62
|
while (true) {
|
|
116
63
|
try {
|
|
117
64
|
const result = await fetchClient(...args)
|
|
118
|
-
if (result.ok)
|
|
119
|
-
// Reset backoff on successful request
|
|
120
|
-
delay = initialDelay
|
|
121
|
-
return result
|
|
122
|
-
}
|
|
65
|
+
if (result.ok) return result
|
|
123
66
|
|
|
124
67
|
const err = await FetchError.fromResponse(result, url.toString())
|
|
125
68
|
|
|
@@ -137,77 +80,17 @@ export function createFetchWithBackoff(
|
|
|
137
80
|
// Any client errors cannot be backed off on, leave it to the caller to handle.
|
|
138
81
|
throw e
|
|
139
82
|
} else {
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (debug) {
|
|
144
|
-
console.log(
|
|
145
|
-
`Max retries reached (${attempt}/${maxRetries}), giving up`
|
|
146
|
-
)
|
|
147
|
-
}
|
|
148
|
-
throw e
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Check retry budget - this is our primary load shedding mechanism
|
|
152
|
-
// It limits the *rate* of retries (10% of traffic) not the count
|
|
153
|
-
// This prevents retry storms even with infinite retries
|
|
154
|
-
if (!checkRetryBudget(retryBudgetPercent)) {
|
|
155
|
-
if (debug) {
|
|
156
|
-
console.log(
|
|
157
|
-
`Retry budget exhausted (attempt ${attempt}), backing off`
|
|
158
|
-
)
|
|
159
|
-
}
|
|
160
|
-
// Wait for maxDelay before checking budget again
|
|
161
|
-
// This prevents tight retry loops when budget is exhausted
|
|
162
|
-
await new Promise((resolve) => setTimeout(resolve, maxDelay))
|
|
163
|
-
// Don't throw - continue retrying after the wait
|
|
164
|
-
// This allows offline clients to eventually reconnect
|
|
165
|
-
continue
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Calculate wait time honoring server-driven backoff as a floor
|
|
169
|
-
// Precedence: max(serverMinimum, min(clientMaxDelay, backoffWithJitter))
|
|
170
|
-
|
|
171
|
-
// 1. Parse server-provided Retry-After (if present)
|
|
172
|
-
let serverMinimumMs = 0
|
|
173
|
-
if (e instanceof FetchError && e.headers) {
|
|
174
|
-
const retryAfter = e.headers[`retry-after`]
|
|
175
|
-
if (retryAfter) {
|
|
176
|
-
const retryAfterSec = Number(retryAfter)
|
|
177
|
-
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
|
|
178
|
-
// Retry-After in seconds
|
|
179
|
-
serverMinimumMs = retryAfterSec * 1000
|
|
180
|
-
} else {
|
|
181
|
-
// Retry-After as HTTP date
|
|
182
|
-
const retryDate = Date.parse(retryAfter)
|
|
183
|
-
if (!isNaN(retryDate)) {
|
|
184
|
-
// Handle clock skew: clamp to non-negative, cap at reasonable max
|
|
185
|
-
const deltaMs = retryDate - Date.now()
|
|
186
|
-
serverMinimumMs = Math.max(0, Math.min(deltaMs, 3600_000)) // Cap at 1 hour
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// 2. Calculate client backoff with full jitter
|
|
193
|
-
const jitter = Math.random() * delay
|
|
194
|
-
const clientBackoffMs = Math.min(jitter, maxDelay)
|
|
83
|
+
// Exponentially backoff on errors.
|
|
84
|
+
// Wait for the current delay duration
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
195
86
|
|
|
196
|
-
//
|
|
197
|
-
|
|
87
|
+
// Increase the delay for the next attempt
|
|
88
|
+
delay = Math.min(delay * multiplier, maxDelay)
|
|
198
89
|
|
|
199
90
|
if (debug) {
|
|
200
|
-
|
|
201
|
-
console.log(
|
|
202
|
-
`Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`
|
|
203
|
-
)
|
|
91
|
+
attempt++
|
|
92
|
+
console.log(`Retry attempt #${attempt} after ${delay}ms`)
|
|
204
93
|
}
|
|
205
|
-
|
|
206
|
-
// Wait for the calculated duration
|
|
207
|
-
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
|
208
|
-
|
|
209
|
-
// Increase the delay for the next attempt (capped at maxDelay)
|
|
210
|
-
delay = Math.min(delay * multiplier, maxDelay)
|
|
211
94
|
}
|
|
212
95
|
}
|
|
213
96
|
}
|