@electric-sql/client 1.1.2 → 1.1.3

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
  }
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
  }