spiceflow 1.17.11 → 1.18.0

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.
Files changed (57) hide show
  1. package/README.md +168 -3
  2. package/dist/client/errors.d.ts +2 -1
  3. package/dist/client/errors.d.ts.map +1 -1
  4. package/dist/client/errors.js +3 -1
  5. package/dist/client/errors.js.map +1 -1
  6. package/dist/client/fetch.d.ts +86 -0
  7. package/dist/client/fetch.d.ts.map +1 -0
  8. package/dist/client/fetch.js +143 -0
  9. package/dist/client/fetch.js.map +1 -0
  10. package/dist/client/index.d.ts +4 -9
  11. package/dist/client/index.d.ts.map +1 -1
  12. package/dist/client/index.js +39 -151
  13. package/dist/client/index.js.map +1 -1
  14. package/dist/client/shared.d.ts +47 -0
  15. package/dist/client/shared.d.ts.map +1 -0
  16. package/dist/client/shared.js +314 -0
  17. package/dist/client/shared.js.map +1 -0
  18. package/dist/client/types.d.ts +3 -1
  19. package/dist/client/types.d.ts.map +1 -1
  20. package/dist/client.test.js +43 -0
  21. package/dist/client.test.js.map +1 -1
  22. package/dist/fetch-client.test.d.ts +2 -0
  23. package/dist/fetch-client.test.d.ts.map +1 -0
  24. package/dist/fetch-client.test.js +362 -0
  25. package/dist/fetch-client.test.js.map +1 -0
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/mcp-client-transport.d.ts.map +1 -1
  31. package/dist/mcp-client-transport.js +5 -2
  32. package/dist/mcp-client-transport.js.map +1 -1
  33. package/dist/mcp.d.ts +1 -1
  34. package/dist/mcp.d.ts.map +1 -1
  35. package/dist/openapi.d.ts +1 -1
  36. package/dist/openapi.d.ts.map +1 -1
  37. package/dist/spiceflow.d.ts +36 -14
  38. package/dist/spiceflow.d.ts.map +1 -1
  39. package/dist/spiceflow.js +49 -16
  40. package/dist/spiceflow.js.map +1 -1
  41. package/dist/spiceflow.test.js +205 -1
  42. package/dist/spiceflow.test.js.map +1 -1
  43. package/dist/stream.test.js +1 -1
  44. package/dist/stream.test.js.map +1 -1
  45. package/package.json +3 -3
  46. package/src/client/errors.ts +3 -0
  47. package/src/client/fetch.ts +447 -0
  48. package/src/client/index.ts +73 -192
  49. package/src/client/shared.ts +406 -0
  50. package/src/client/types.ts +3 -1
  51. package/src/client.test.ts +52 -0
  52. package/src/fetch-client.test.ts +411 -0
  53. package/src/index.ts +1 -1
  54. package/src/mcp-client-transport.ts +5 -2
  55. package/src/spiceflow.test.ts +315 -1
  56. package/src/spiceflow.ts +106 -32
  57. package/src/stream.test.ts +1 -1
@@ -1,6 +1,4 @@
1
1
  import type { AnySpiceflow, Spiceflow } from '../spiceflow.ts'
2
- import superjson from 'superjson'
3
- import { EventSourceParserStream } from 'eventsource-parser/stream'
4
2
 
5
3
  import type { SpiceflowClient } from './types.ts'
6
4
 
@@ -10,6 +8,25 @@ import { SpiceflowFetchError } from './errors.ts'
10
8
 
11
9
  import { parseStringifiedValue } from './utils.ts'
12
10
 
11
+ import {
12
+ isServer,
13
+ isFile,
14
+ hasFile,
15
+ createNewFile,
16
+ processHeaders,
17
+ streamSSEResponse,
18
+ tryParsingSSEJson,
19
+ superjsonDeserialize,
20
+ TextDecoderStream,
21
+ isAbortError,
22
+ type SSEEvent,
23
+ } from './shared.ts'
24
+
25
+ export { streamSSEResponse, TextDecoderStream }
26
+
27
+ export { createSpiceflowFetch } from './fetch.ts'
28
+ export type { SpiceflowFetch } from './fetch.ts'
29
+
13
30
  const method = [
14
31
  'get',
15
32
  'post',
@@ -22,177 +39,6 @@ const method = [
22
39
  'subscribe',
23
40
  ] as const
24
41
 
25
- const isServer = typeof FileList === 'undefined'
26
-
27
- const isFile = (v: any) => {
28
- if (isServer) return v instanceof Blob
29
-
30
- return v instanceof FileList || v instanceof File
31
- }
32
-
33
- // FormData is 1 level deep
34
- const hasFile = (obj: Record<string, any>) => {
35
- if (!obj) return false
36
-
37
- for (const key in obj) {
38
- if (isFile(obj[key])) return true
39
-
40
- if (Array.isArray(obj[key]) && (obj[key] as unknown[]).find(isFile))
41
- return true
42
- }
43
-
44
- return false
45
- }
46
-
47
- const createNewFile = (v: File) =>
48
- isServer
49
- ? v
50
- : new Promise<File>((resolve) => {
51
- const reader = new FileReader()
52
-
53
- reader.onload = () => {
54
- const file = new File([reader.result!], v.name, {
55
- lastModified: v.lastModified,
56
- type: v.type,
57
- })
58
- resolve(file)
59
- }
60
-
61
- reader.readAsArrayBuffer(v)
62
- })
63
-
64
- const processHeaders = (
65
- h: SpiceflowClient.Config['headers'],
66
- path: string,
67
- options: RequestInit = {},
68
- headers: Record<string, string> = {},
69
- ): Record<string, string> => {
70
- if (Array.isArray(h)) {
71
- for (const value of h)
72
- if (!Array.isArray(value))
73
- headers = processHeaders(value, path, options, headers)
74
- else {
75
- const key = value[0]
76
- if (typeof key === 'string')
77
- headers[key.toLowerCase()] = value[1] as string
78
- else
79
- for (const [k, value] of key)
80
- headers[k.toLowerCase()] = value as string
81
- }
82
-
83
- return headers
84
- }
85
-
86
- if (!h) return headers
87
-
88
- switch (typeof h) {
89
- case 'function':
90
- if (typeof Headers !== 'undefined' && h instanceof Headers)
91
- return processHeaders(h, path, options, headers)
92
-
93
- const v = h(path, options)
94
- if (v) return processHeaders(v, path, options, headers)
95
- return headers
96
-
97
- case 'object':
98
- if (typeof Headers !== 'undefined' && h instanceof Headers) {
99
- h.forEach((value, key) => {
100
- headers[key.toLowerCase()] = value
101
- })
102
- return headers
103
- }
104
-
105
- for (const [key, value] of Object.entries(h))
106
- headers[key.toLowerCase()] = value as string
107
-
108
- return headers
109
-
110
- default:
111
- return headers
112
- }
113
- }
114
-
115
- interface SSEEvent {
116
- event?: string
117
- data: any
118
- id?: string
119
- }
120
-
121
- export class TextDecoderStream extends TransformStream<Uint8Array, string> {
122
- constructor() {
123
- const decoder = new TextDecoder('utf-8', {
124
- fatal: true,
125
- ignoreBOM: true,
126
- })
127
- super({
128
- transform(
129
- chunk: Uint8Array,
130
- controller: TransformStreamDefaultController<string>,
131
- ) {
132
- const decoded = decoder.decode(chunk, { stream: true })
133
- if (decoded.length > 0) {
134
- controller.enqueue(decoded)
135
- }
136
- },
137
- flush(controller: TransformStreamDefaultController<string>) {
138
- const output = decoder.decode()
139
- if (output.length > 0) {
140
- controller.enqueue(output)
141
- }
142
- },
143
- })
144
- }
145
- }
146
-
147
- function isAbortError(error: unknown): error is Error {
148
- return (
149
- (error instanceof Error || error instanceof DOMException) &&
150
- (error.name === 'AbortError' ||
151
- error.name === 'ResponseAborted' || // Next.js
152
- error.name === 'TimeoutError')
153
- )
154
- }
155
-
156
- export async function* streamSSEResponse(
157
- response: Response,
158
- map: (x: SSEEvent) => any,
159
- ): AsyncGenerator<SSEEvent> {
160
- const body = response.body
161
- if (!body) return
162
-
163
- const eventStream = response.body
164
- .pipeThrough(new TextDecoderStream())
165
- .pipeThrough(new EventSourceParserStream())
166
-
167
- let reader = eventStream.getReader()
168
- try {
169
- while (true) {
170
- const { done, value: event } = await reader.read()
171
- if (done) break
172
- if (event?.event === 'error') {
173
- throw new SpiceflowFetchError(500, superjsonDeserialize(event.data))
174
- }
175
- if (event) {
176
- yield map({ ...event, data: event.data })
177
- }
178
- }
179
- } catch (error) {
180
- if (isAbortError(error)) {
181
- return
182
- }
183
-
184
- throw error
185
- }
186
- }
187
-
188
- function tryParsingSSEJson(data: string): any {
189
- try {
190
- return superjsonDeserialize(JSON.parse(data))
191
- } catch (error) {
192
- return data
193
- }
194
- }
195
-
196
42
  const createProxy = (
197
43
  domain: string,
198
44
  config: SpiceflowClient.Config & { state?: any },
@@ -226,7 +72,13 @@ const createProxy = (
226
72
  const method = methodPaths.pop()
227
73
  const path = '/' + methodPaths.join('/')
228
74
 
229
- let { fetch: fetcher = fetch, headers, onRequest, onResponse } = config
75
+ let {
76
+ fetch: fetcher = fetch,
77
+ headers,
78
+ onRequest,
79
+ onResponse,
80
+ retries = 0,
81
+ } = config
230
82
 
231
83
  const isGetOrHead =
232
84
  method === 'get' || method === 'head' || method === 'subscribe'
@@ -394,11 +246,46 @@ const createProxy = (
394
246
  }
395
247
 
396
248
  const url = domain + path + q
397
- // console.log({ url, fetchInit })
398
- const response = await (instance?.handle(
399
- new Request(url, fetchInit),
400
- { state: config.state },
401
- ) ?? fetcher!(url, fetchInit))
249
+
250
+ const executeRequest = async (): Promise<Response> => {
251
+ let attempt = 0
252
+ let response: Response
253
+ let lastError: Error | null = null
254
+
255
+ while (attempt <= retries) {
256
+ try {
257
+ response = await (instance?.handle(
258
+ new Request(url, fetchInit),
259
+ { state: config.state },
260
+ ) ?? fetcher!(url, fetchInit))
261
+
262
+ if (response.status < 500 || attempt === retries) {
263
+ break
264
+ }
265
+
266
+ lastError = new Error(
267
+ `Server error: ${response.status} ${response.statusText}`,
268
+ )
269
+ } catch (err) {
270
+ lastError = err as Error
271
+ if (attempt === retries) {
272
+ throw err
273
+ }
274
+ }
275
+
276
+ attempt++
277
+ const backoffMs = Math.min(1000 * 2 ** (attempt - 1), 10000)
278
+ await new Promise((resolve) => setTimeout(resolve, backoffMs))
279
+ }
280
+
281
+ if (!response!) {
282
+ throw lastError || new Error('Failed to fetch after retries')
283
+ }
284
+
285
+ return response
286
+ }
287
+
288
+ const response = await executeRequest()
402
289
 
403
290
  let data = null as any
404
291
  let error = null as any
@@ -429,8 +316,13 @@ const createProxy = (
429
316
 
430
317
  switch (response.headers.get('Content-Type')?.split(';')[0]) {
431
318
  case 'text/event-stream':
432
- data = streamSSEResponse(response, (x) => {
433
- return tryParsingSSEJson(x.data)
319
+ data = streamSSEResponse({
320
+ response,
321
+ map: (x) => {
322
+ return tryParsingSSEJson(x.data)
323
+ },
324
+ executeRequest,
325
+ maxRetries: retries,
434
326
  })
435
327
 
436
328
  break
@@ -512,14 +404,3 @@ export const createSpiceflowClient = <const App extends AnySpiceflow>(
512
404
 
513
405
  return createProxy('http://e.ly', config || {}, [], domain)
514
406
  }
515
-
516
- function superjsonDeserialize(data: any) {
517
- if (data?.__superjsonMeta) {
518
- const { __superjsonMeta, ...rest } = data
519
- return superjson.deserialize({
520
- json: rest,
521
- meta: __superjsonMeta,
522
- })
523
- }
524
- return data
525
- }
@@ -0,0 +1,406 @@
1
+ // Shared utilities used by both the proxy client and the fetch client
2
+ import superjson from 'superjson'
3
+ import { EventSourceParserStream } from 'eventsource-parser/stream'
4
+
5
+ import type { SpiceflowClient } from './types.ts'
6
+ import { SpiceflowFetchError } from './errors.ts'
7
+ import { parseStringifiedValue } from './utils.ts'
8
+ import type { AnySpiceflow } from '../spiceflow.ts'
9
+
10
+ export const isServer = typeof FileList === 'undefined'
11
+
12
+ export const isFile = (v: any) => {
13
+ if (isServer) return v instanceof Blob
14
+ return v instanceof FileList || v instanceof File
15
+ }
16
+
17
+ export const hasFile = (obj: Record<string, any>) => {
18
+ if (!obj) return false
19
+ for (const key in obj) {
20
+ if (isFile(obj[key])) return true
21
+ if (Array.isArray(obj[key]) && (obj[key] as unknown[]).find(isFile))
22
+ return true
23
+ }
24
+ return false
25
+ }
26
+
27
+ export const createNewFile = (v: File) =>
28
+ isServer
29
+ ? v
30
+ : new Promise<File>((resolve) => {
31
+ const reader = new FileReader()
32
+ reader.onload = () => {
33
+ const file = new File([reader.result!], v.name, {
34
+ lastModified: v.lastModified,
35
+ type: v.type,
36
+ })
37
+ resolve(file)
38
+ }
39
+ reader.readAsArrayBuffer(v)
40
+ })
41
+
42
+ export const processHeaders = (
43
+ h: SpiceflowClient.Config['headers'],
44
+ path: string,
45
+ options: RequestInit = {},
46
+ headers: Record<string, string> = {},
47
+ ): Record<string, string> => {
48
+ if (Array.isArray(h)) {
49
+ for (const value of h)
50
+ if (!Array.isArray(value))
51
+ headers = processHeaders(value, path, options, headers)
52
+ else {
53
+ const key = value[0]
54
+ if (typeof key === 'string')
55
+ headers[key.toLowerCase()] = value[1] as string
56
+ else
57
+ for (const [k, value] of key)
58
+ headers[k.toLowerCase()] = value as string
59
+ }
60
+ return headers
61
+ }
62
+
63
+ if (!h) return headers
64
+
65
+ switch (typeof h) {
66
+ case 'function':
67
+ if (typeof Headers !== 'undefined' && h instanceof Headers)
68
+ return processHeaders(h, path, options, headers)
69
+ const v = h(path, options)
70
+ if (v) return processHeaders(v, path, options, headers)
71
+ return headers
72
+
73
+ case 'object':
74
+ if (typeof Headers !== 'undefined' && h instanceof Headers) {
75
+ h.forEach((value, key) => {
76
+ headers[key.toLowerCase()] = value
77
+ })
78
+ return headers
79
+ }
80
+ for (const [key, value] of Object.entries(h))
81
+ headers[key.toLowerCase()] = value as string
82
+ return headers
83
+
84
+ default:
85
+ return headers
86
+ }
87
+ }
88
+
89
+ export interface SSEEvent {
90
+ event?: string
91
+ data: any
92
+ id?: string
93
+ }
94
+
95
+ export class TextDecoderStream extends TransformStream<Uint8Array, string> {
96
+ constructor() {
97
+ const decoder = new TextDecoder('utf-8', {
98
+ fatal: true,
99
+ ignoreBOM: true,
100
+ })
101
+ super({
102
+ transform(
103
+ chunk: Uint8Array,
104
+ controller: TransformStreamDefaultController<string>,
105
+ ) {
106
+ const decoded = decoder.decode(chunk, { stream: true })
107
+ if (decoded.length > 0) {
108
+ controller.enqueue(decoded)
109
+ }
110
+ },
111
+ flush(controller: TransformStreamDefaultController<string>) {
112
+ const output = decoder.decode()
113
+ if (output.length > 0) {
114
+ controller.enqueue(output)
115
+ }
116
+ },
117
+ })
118
+ }
119
+ }
120
+
121
+ export function isAbortError(error: unknown): error is Error {
122
+ return (
123
+ (error instanceof Error || error instanceof DOMException) &&
124
+ (error.name === 'AbortError' ||
125
+ error.name === 'ResponseAborted' || // Next.js
126
+ error.name === 'TimeoutError')
127
+ )
128
+ }
129
+
130
+ export async function* streamSSEResponse({
131
+ response,
132
+ map,
133
+ executeRequest,
134
+ maxRetries = 0,
135
+ }: {
136
+ response: Response
137
+ map: (x: SSEEvent) => any
138
+ executeRequest?: () => Promise<Response>
139
+ maxRetries?: number
140
+ }): AsyncGenerator<SSEEvent> {
141
+ let currentResponse = response
142
+ let retriesLeft = maxRetries
143
+
144
+ while (true) {
145
+ const body = currentResponse.body
146
+ if (!body) return
147
+
148
+ const eventStream = body
149
+ .pipeThrough(new TextDecoderStream())
150
+ .pipeThrough(new EventSourceParserStream())
151
+
152
+ let reader = eventStream.getReader()
153
+
154
+ try {
155
+ while (true) {
156
+ const { done, value: event } = await reader.read()
157
+ if (done) return
158
+
159
+ if (event?.event === 'error') {
160
+ const error = superjsonDeserialize(event.data)
161
+ throw new SpiceflowFetchError(500, error)
162
+ }
163
+
164
+ if (event) {
165
+ yield map({ ...event, data: event.data })
166
+ }
167
+ }
168
+ } catch (error) {
169
+ if (isAbortError(error)) {
170
+ return
171
+ }
172
+
173
+ if (
174
+ executeRequest &&
175
+ error instanceof SpiceflowFetchError &&
176
+ error.status >= 500 &&
177
+ retriesLeft > 0
178
+ ) {
179
+ retriesLeft--
180
+ const backoffMs = Math.min(
181
+ 1000 * 2 ** (maxRetries - retriesLeft - 1),
182
+ 10000,
183
+ )
184
+ await new Promise((resolve) => setTimeout(resolve, backoffMs))
185
+
186
+ try {
187
+ currentResponse = await executeRequest()
188
+ if (currentResponse.status >= 500) {
189
+ if (retriesLeft === 0) {
190
+ throw error
191
+ }
192
+ continue
193
+ }
194
+ } catch (retryError) {
195
+ if (retriesLeft === 0) {
196
+ throw retryError
197
+ }
198
+ continue
199
+ }
200
+ } else {
201
+ throw error
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ export function tryParsingSSEJson(data: string): any {
208
+ try {
209
+ return superjsonDeserialize(JSON.parse(data))
210
+ } catch (error) {
211
+ return data
212
+ }
213
+ }
214
+
215
+ export function superjsonDeserialize(data: any) {
216
+ if (data?.__superjsonMeta) {
217
+ const { __superjsonMeta, ...rest } = data
218
+ return superjson.deserialize({
219
+ json: rest,
220
+ meta: __superjsonMeta,
221
+ })
222
+ }
223
+ return data
224
+ }
225
+
226
+ export function buildQueryString(
227
+ query: Record<string, string | string[] | number | boolean | object | undefined | null> | undefined,
228
+ ): string {
229
+ if (!query) return ''
230
+ let q = ''
231
+ const append = (key: string, value: string) => {
232
+ q +=
233
+ (q ? '&' : '?') +
234
+ `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
235
+ }
236
+ for (const [key, value] of Object.entries(query)) {
237
+ if (value === undefined || value === null) continue
238
+ if (Array.isArray(value)) {
239
+ for (const v of value) append(key, v)
240
+ continue
241
+ }
242
+ if (typeof value === 'object') {
243
+ append(key, JSON.stringify(value))
244
+ continue
245
+ }
246
+ append(key, `${value}`)
247
+ }
248
+ return q
249
+ }
250
+
251
+ export async function serializeBody({
252
+ body,
253
+ fetchInit,
254
+ isGetOrHead,
255
+ }: {
256
+ body: any
257
+ fetchInit: RequestInit
258
+ isGetOrHead: boolean
259
+ }): Promise<void> {
260
+ if (isGetOrHead) {
261
+ delete fetchInit.body
262
+ return
263
+ }
264
+
265
+ if (hasFile(body)) {
266
+ const formData = new FormData()
267
+ for (const [key, field] of Object.entries(body)) {
268
+ if (isServer) {
269
+ formData.append(key, field as any)
270
+ continue
271
+ }
272
+ if (field instanceof File) {
273
+ formData.append(key, await createNewFile(field as any))
274
+ continue
275
+ }
276
+ if (field instanceof FileList) {
277
+ for (let i = 0; i < field.length; i++)
278
+ formData.append(key as any, await createNewFile((field as any)[i]))
279
+ continue
280
+ }
281
+ if (Array.isArray(field)) {
282
+ for (let i = 0; i < field.length; i++) {
283
+ const value = (field as any)[i]
284
+ formData.append(
285
+ key as any,
286
+ value instanceof File ? await createNewFile(value) : value,
287
+ )
288
+ }
289
+ continue
290
+ }
291
+ formData.append(key, field as string)
292
+ }
293
+ fetchInit.body = formData
294
+ } else if (typeof body === 'object' && body !== null) {
295
+ ;(fetchInit.headers as Record<string, string>)['content-type'] =
296
+ 'application/json'
297
+ fetchInit.body = JSON.stringify(body)
298
+ } else if (body !== undefined && body !== null) {
299
+ ;(fetchInit.headers as Record<string, string>)['content-type'] =
300
+ 'text/plain'
301
+ fetchInit.body = body
302
+ }
303
+ }
304
+
305
+ export async function parseResponseData({
306
+ response,
307
+ executeRequest,
308
+ retries,
309
+ }: {
310
+ response: Response
311
+ executeRequest: () => Promise<Response>
312
+ retries: number
313
+ }): Promise<{ data: any; error: any }> {
314
+ let data = null as any
315
+ let error = null as any
316
+
317
+ switch (response.headers.get('Content-Type')?.split(';')[0]) {
318
+ case 'text/event-stream':
319
+ data = streamSSEResponse({
320
+ response,
321
+ map: (x) => tryParsingSSEJson(x.data),
322
+ executeRequest,
323
+ maxRetries: retries,
324
+ })
325
+ break
326
+
327
+ case 'application/json':
328
+ data = await response.json()
329
+ data = superjsonDeserialize(data)
330
+ break
331
+
332
+ case 'application/octet-stream':
333
+ data = await response.arrayBuffer()
334
+ break
335
+
336
+ case 'multipart/form-data': {
337
+ const temp = await response.formData()
338
+ data = {}
339
+ temp.forEach((value, key) => {
340
+ data[key] = value
341
+ })
342
+ break
343
+ }
344
+
345
+ default:
346
+ data = await response.text().then(parseStringifiedValue)
347
+ }
348
+
349
+ if (response.status >= 300 || response.status < 200) {
350
+ error = new SpiceflowFetchError(response.status, data || 'Unknown error')
351
+ data = null
352
+ }
353
+
354
+ return { data, error }
355
+ }
356
+
357
+ export async function executeWithRetries({
358
+ url,
359
+ fetchInit,
360
+ fetcher,
361
+ instance,
362
+ state,
363
+ retries,
364
+ }: {
365
+ url: string
366
+ fetchInit: RequestInit
367
+ fetcher: typeof fetch
368
+ instance?: AnySpiceflow
369
+ state?: any
370
+ retries: number
371
+ }): Promise<Response> {
372
+ let attempt = 0
373
+ let response!: Response
374
+ let lastError: Error | null = null
375
+
376
+ while (attempt <= retries) {
377
+ try {
378
+ response = await (instance?.handle(new Request(url, fetchInit), {
379
+ state,
380
+ }) ?? fetcher(url, fetchInit))
381
+
382
+ if (response.status < 500 || attempt === retries) {
383
+ break
384
+ }
385
+
386
+ lastError = new Error(
387
+ `Server error: ${response.status} ${response.statusText}`,
388
+ )
389
+ } catch (err) {
390
+ lastError = err as Error
391
+ if (attempt === retries) {
392
+ throw err
393
+ }
394
+ }
395
+
396
+ attempt++
397
+ const backoffMs = Math.min(1000 * 2 ** (attempt - 1), 10000)
398
+ await new Promise((resolve) => setTimeout(resolve, backoffMs))
399
+ }
400
+
401
+ if (!response) {
402
+ throw lastError || new Error('Failed to fetch after retries')
403
+ }
404
+
405
+ return response
406
+ }