@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/README.md +74 -12
- package/dist/cjs/index.cjs +43 -8
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +53 -4
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +53 -4
- package/dist/index.legacy-esm.js +43 -8
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +43 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +45 -4
- package/src/constants.ts +1 -0
- package/src/fetch.ts +75 -9
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
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
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:
|
|
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)
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
//
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
console.log(
|
|
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
|
}
|