@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/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
- if (typeof retryOpts === `object`) {
513
- this.#reset()
514
-
515
- if (`params` in retryOpts) {
516
- this.options.params = retryOpts.params
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 (`headers` in retryOpts) {
520
- this.options.headers = retryOpts.headers
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
- // Restart
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
- // If no handler is provided for errors just throw so the error still bubbles up.
531
- throw err
532
- } finally {
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: 60_000, // Cap at 60s - reasonable for long-lived connections
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
- // Check retry budget and max retries
141
- attempt++
142
- if (attempt >= maxRetries) {
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
- // 3. Server minimum is the floor, client cap is the ceiling
197
- const waitMs = Math.max(serverMinimumMs, clientBackoffMs)
87
+ // Increase the delay for the next attempt
88
+ delay = Math.min(delay * multiplier, maxDelay)
198
89
 
199
90
  if (debug) {
200
- const source = serverMinimumMs > 0 ? `server+client` : `client`
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
  }