@electric-sql/client 1.1.2 → 1.1.4

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
@@ -296,10 +296,51 @@ export interface ShapeStreamOptions<T = never> {
296
296
 
297
297
  /**
298
298
  * A function for handling shapestream errors.
299
- * This is optional, when it is not provided any shapestream errors will be thrown.
300
- * If the function returns an object containing parameters and/or headers
301
- * the shapestream will apply those changes and try syncing again.
302
- * If the function returns void the shapestream is stopped.
299
+ *
300
+ * **Automatic retries**: The client automatically retries 5xx server errors, network
301
+ * errors, and 429 rate limits with exponential backoff. The `onError` callback is
302
+ * only invoked after these automatic retries are exhausted, or for non-retryable
303
+ * errors like 4xx client errors.
304
+ *
305
+ * When not provided, non-retryable errors will be thrown and syncing will stop.
306
+ *
307
+ * **Return value behavior**:
308
+ * - Return an **object** (RetryOpts or empty `{}`) to retry syncing:
309
+ * - `{}` - Retry with the same params and headers
310
+ * - `{ params }` - Retry with modified params
311
+ * - `{ headers }` - Retry with modified headers (e.g., refreshed auth token)
312
+ * - `{ params, headers }` - Retry with both modified
313
+ * - Return **void** or **undefined** to stop the stream permanently
314
+ *
315
+ * **Important**: If you want syncing to continue after an error (e.g., to retry
316
+ * on network failures), you MUST return at least an empty object `{}`. Simply
317
+ * logging the error and returning nothing will stop syncing.
318
+ *
319
+ * Supports async functions that return `Promise<void | RetryOpts>`.
320
+ *
321
+ * @example
322
+ * ```typescript
323
+ * // Retry on network errors, stop on others
324
+ * onError: (error) => {
325
+ * console.error('Stream error:', error)
326
+ * if (error instanceof FetchError && error.status >= 500) {
327
+ * return {} // Retry with same params
328
+ * }
329
+ * // Return void to stop on other errors
330
+ * }
331
+ * ```
332
+ *
333
+ * @example
334
+ * ```typescript
335
+ * // Refresh auth token on 401
336
+ * onError: async (error) => {
337
+ * if (error instanceof FetchError && error.status === 401) {
338
+ * const newToken = await refreshAuthToken()
339
+ * return { headers: { Authorization: `Bearer ${newToken}` } }
340
+ * }
341
+ * return {} // Retry other errors
342
+ * }
343
+ * ```
303
344
  */
304
345
  onError?: ShapeStreamErrorHandler
305
346
  }
@@ -448,6 +489,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
448
489
  #sseFallbackToLongPolling = false
449
490
  #sseBackoffBaseDelay = 100 // Base delay for exponential backoff (ms)
450
491
  #sseBackoffMaxDelay = 5000 // Maximum delay cap (ms)
492
+ #unsubscribeFromVisibilityChanges?: () => void
451
493
 
452
494
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
453
495
  this.options = { subscribe: true, ...options }
@@ -615,9 +657,17 @@ export class ShapeStream<T extends Row<unknown> = Row>
615
657
  }
616
658
 
617
659
  if (e instanceof FetchBackoffAbortError) {
660
+ // Check current state - it may have changed due to concurrent pause/resume calls
661
+ // from the visibility change handler during the async fetch operation.
662
+ // TypeScript's flow analysis doesn't account for concurrent state changes.
663
+ const currentState = this.#state as
664
+ | `active`
665
+ | `pause-requested`
666
+ | `paused`
618
667
  if (
619
668
  requestAbortController.signal.aborted &&
620
- requestAbortController.signal.reason === PAUSE_STREAM
669
+ requestAbortController.signal.reason === PAUSE_STREAM &&
670
+ currentState === `pause-requested`
621
671
  ) {
622
672
  this.#state = `paused`
623
673
  }
@@ -1004,7 +1054,15 @@ export class ShapeStream<T extends Row<unknown> = Row>
1004
1054
  }
1005
1055
 
1006
1056
  #resume() {
1007
- if (this.#started && this.#state === `paused`) {
1057
+ if (
1058
+ this.#started &&
1059
+ (this.#state === `paused` || this.#state === `pause-requested`)
1060
+ ) {
1061
+ // If we're resuming from pause-requested state, we need to set state back to active
1062
+ // to prevent the pause from completing
1063
+ if (this.#state === `pause-requested`) {
1064
+ this.#state = `active`
1065
+ }
1008
1066
  this.#start()
1009
1067
  }
1010
1068
  }
@@ -1025,6 +1083,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1025
1083
 
1026
1084
  unsubscribeAll(): void {
1027
1085
  this.#subscribers.clear()
1086
+ this.#unsubscribeFromVisibilityChanges?.()
1028
1087
  }
1029
1088
 
1030
1089
  /** Unix time at which we last synced. Undefined when `isLoading` is true. */
@@ -1151,6 +1210,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
1151
1210
  }
1152
1211
 
1153
1212
  document.addEventListener(`visibilitychange`, visibilityHandler)
1213
+
1214
+ // Store cleanup function to remove the event listener
1215
+ this.#unsubscribeFromVisibilityChanges = () => {
1216
+ document.removeEventListener(`visibilitychange`, visibilityHandler)
1217
+ }
1154
1218
  }
1155
1219
  }
1156
1220
 
package/src/constants.ts CHANGED
@@ -30,6 +30,7 @@ export const SUBSET_PARAM_WHERE_PARAMS = `subset__params`
30
30
  // Query parameters that should be passed through when proxying Electric requests
31
31
  export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
32
32
  LIVE_QUERY_PARAM,
33
+ LIVE_SSE_QUERY_PARAM,
33
34
  SHAPE_HANDLE_QUERY_PARAM,
34
35
  OFFSET_QUERY_PARAM,
35
36
  LIVE_CACHE_BUSTER_QUERY_PARAM,
package/src/fetch.ts CHANGED
@@ -28,17 +28,50 @@ 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)
31
32
  */
32
33
  maxDelay: number
33
34
  multiplier: number
34
35
  onFailedAttempt?: () => void
35
36
  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
+ maxRetries?: number
36
43
  }
37
44
 
38
45
  export const BackoffDefaults = {
39
46
  initialDelay: 100,
40
- maxDelay: 10_000,
47
+ maxDelay: 60_000, // Cap at 60s - reasonable for long-lived connections
41
48
  multiplier: 1.3,
49
+ maxRetries: Infinity, // Retry forever - clients may go offline and come back
50
+ }
51
+
52
+ /**
53
+ * Parse Retry-After header value and return delay in milliseconds
54
+ * Supports both delta-seconds format and HTTP-date format
55
+ * Returns 0 if header is not present or invalid
56
+ */
57
+ export function parseRetryAfterHeader(retryAfter: string | undefined): number {
58
+ if (!retryAfter) return 0
59
+
60
+ // Try parsing as seconds (delta-seconds format)
61
+ const retryAfterSec = Number(retryAfter)
62
+ if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
63
+ return retryAfterSec * 1000
64
+ }
65
+
66
+ // Try parsing as HTTP-date
67
+ const retryDate = Date.parse(retryAfter)
68
+ if (!isNaN(retryDate)) {
69
+ // Handle clock skew: clamp to non-negative, cap at reasonable max
70
+ const deltaMs = retryDate - Date.now()
71
+ return Math.max(0, Math.min(deltaMs, 3600_000)) // Cap at 1 hour
72
+ }
73
+
74
+ return 0
42
75
  }
43
76
 
44
77
  export function createFetchWithBackoff(
@@ -51,6 +84,7 @@ export function createFetchWithBackoff(
51
84
  multiplier,
52
85
  debug = false,
53
86
  onFailedAttempt,
87
+ maxRetries = Infinity,
54
88
  } = backoffOptions
55
89
  return async (...args: Parameters<typeof fetch>): Promise<Response> => {
56
90
  const url = args[0]
@@ -62,7 +96,9 @@ export function createFetchWithBackoff(
62
96
  while (true) {
63
97
  try {
64
98
  const result = await fetchClient(...args)
65
- if (result.ok) return result
99
+ if (result.ok) {
100
+ return result
101
+ }
66
102
 
67
103
  const err = await FetchError.fromResponse(result, url.toString())
68
104
 
@@ -80,17 +116,47 @@ export function createFetchWithBackoff(
80
116
  // Any client errors cannot be backed off on, leave it to the caller to handle.
81
117
  throw e
82
118
  } else {
83
- // Exponentially backoff on errors.
84
- // Wait for the current delay duration
85
- await new Promise((resolve) => setTimeout(resolve, delay))
119
+ // Check max retries
120
+ attempt++
121
+ if (attempt > maxRetries) {
122
+ if (debug) {
123
+ console.log(
124
+ `Max retries reached (${attempt}/${maxRetries}), giving up`
125
+ )
126
+ }
127
+ throw e
128
+ }
86
129
 
87
- // Increase the delay for the next attempt
88
- delay = Math.min(delay * multiplier, maxDelay)
130
+ // Calculate wait time honoring server-driven backoff as a floor
131
+ // Precedence: max(serverMinimum, min(clientMaxDelay, backoffWithJitter))
132
+
133
+ // 1. Parse server-provided Retry-After (if present)
134
+ const serverMinimumMs =
135
+ e instanceof FetchError && e.headers
136
+ ? parseRetryAfterHeader(e.headers[`retry-after`])
137
+ : 0
138
+
139
+ // 2. Calculate client backoff with full jitter strategy
140
+ // Full jitter: random_between(0, min(cap, exponential_backoff))
141
+ // See: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
142
+ const jitter = Math.random() * delay // random value between 0 and current delay
143
+ const clientBackoffMs = Math.min(jitter, maxDelay) // cap at maxDelay
144
+
145
+ // 3. Server minimum is the floor, client cap is the ceiling
146
+ const waitMs = Math.max(serverMinimumMs, clientBackoffMs)
89
147
 
90
148
  if (debug) {
91
- attempt++
92
- console.log(`Retry attempt #${attempt} after ${delay}ms`)
149
+ const source = serverMinimumMs > 0 ? `server+client` : `client`
150
+ console.log(
151
+ `Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`
152
+ )
93
153
  }
154
+
155
+ // Wait for the calculated duration
156
+ await new Promise((resolve) => setTimeout(resolve, waitMs))
157
+
158
+ // Increase the delay for the next attempt (capped at maxDelay)
159
+ delay = Math.min(delay * multiplier, maxDelay)
94
160
  }
95
161
  }
96
162
  }