@electric-sql/client 1.1.0 → 1.1.1
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 +76 -8
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +22 -0
- package/dist/index.browser.mjs +3 -3
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +22 -0
- package/dist/index.legacy-esm.js +76 -8
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +76 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/fetch.ts +126 -9
package/src/fetch.ts
CHANGED
|
@@ -28,17 +28,39 @@ 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
|
+
* 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
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
export const BackoffDefaults = {
|
|
39
59
|
initialDelay: 100,
|
|
40
|
-
maxDelay:
|
|
60
|
+
maxDelay: 60_000, // Cap at 60s - reasonable for long-lived connections
|
|
41
61
|
multiplier: 1.3,
|
|
62
|
+
maxRetries: Infinity, // Retry forever - clients may go offline and come back
|
|
63
|
+
retryBudgetPercent: 0.1, // 10% retry budget prevents amplification
|
|
42
64
|
}
|
|
43
65
|
|
|
44
66
|
export function createFetchWithBackoff(
|
|
@@ -51,7 +73,38 @@ export function createFetchWithBackoff(
|
|
|
51
73
|
multiplier,
|
|
52
74
|
debug = false,
|
|
53
75
|
onFailedAttempt,
|
|
76
|
+
maxRetries = Infinity,
|
|
77
|
+
retryBudgetPercent = 0.1,
|
|
54
78
|
} = 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
|
+
}
|
|
55
108
|
return async (...args: Parameters<typeof fetch>): Promise<Response> => {
|
|
56
109
|
const url = args[0]
|
|
57
110
|
const options = args[1]
|
|
@@ -62,7 +115,11 @@ export function createFetchWithBackoff(
|
|
|
62
115
|
while (true) {
|
|
63
116
|
try {
|
|
64
117
|
const result = await fetchClient(...args)
|
|
65
|
-
if (result.ok)
|
|
118
|
+
if (result.ok) {
|
|
119
|
+
// Reset backoff on successful request
|
|
120
|
+
delay = initialDelay
|
|
121
|
+
return result
|
|
122
|
+
}
|
|
66
123
|
|
|
67
124
|
const err = await FetchError.fromResponse(result, url.toString())
|
|
68
125
|
|
|
@@ -80,17 +137,77 @@ export function createFetchWithBackoff(
|
|
|
80
137
|
// Any client errors cannot be backed off on, leave it to the caller to handle.
|
|
81
138
|
throw e
|
|
82
139
|
} else {
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
}
|
|
86
150
|
|
|
87
|
-
//
|
|
88
|
-
|
|
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)
|
|
195
|
+
|
|
196
|
+
// 3. Server minimum is the floor, client cap is the ceiling
|
|
197
|
+
const waitMs = Math.max(serverMinimumMs, clientBackoffMs)
|
|
89
198
|
|
|
90
199
|
if (debug) {
|
|
91
|
-
|
|
92
|
-
console.log(
|
|
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
|
+
)
|
|
93
204
|
}
|
|
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)
|
|
94
211
|
}
|
|
95
212
|
}
|
|
96
213
|
}
|