@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/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: 10_000,
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) return result
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
- // Exponentially backoff on errors.
84
- // Wait for the current delay duration
85
- await new Promise((resolve) => setTimeout(resolve, delay))
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
- // Increase the delay for the next attempt
88
- delay = Math.min(delay * multiplier, maxDelay)
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
- attempt++
92
- console.log(`Retry attempt #${attempt} after ${delay}ms`)
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
  }