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.
- package/README.md +168 -3
- package/dist/client/errors.d.ts +2 -1
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +3 -1
- package/dist/client/errors.js.map +1 -1
- package/dist/client/fetch.d.ts +86 -0
- package/dist/client/fetch.d.ts.map +1 -0
- package/dist/client/fetch.js +143 -0
- package/dist/client/fetch.js.map +1 -0
- package/dist/client/index.d.ts +4 -9
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +39 -151
- package/dist/client/index.js.map +1 -1
- package/dist/client/shared.d.ts +47 -0
- package/dist/client/shared.d.ts.map +1 -0
- package/dist/client/shared.js +314 -0
- package/dist/client/shared.js.map +1 -0
- package/dist/client/types.d.ts +3 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client.test.js +43 -0
- package/dist/client.test.js.map +1 -1
- package/dist/fetch-client.test.d.ts +2 -0
- package/dist/fetch-client.test.d.ts.map +1 -0
- package/dist/fetch-client.test.js +362 -0
- package/dist/fetch-client.test.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-client-transport.d.ts.map +1 -1
- package/dist/mcp-client-transport.js +5 -2
- package/dist/mcp-client-transport.js.map +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/spiceflow.d.ts +36 -14
- package/dist/spiceflow.d.ts.map +1 -1
- package/dist/spiceflow.js +49 -16
- package/dist/spiceflow.js.map +1 -1
- package/dist/spiceflow.test.js +205 -1
- package/dist/spiceflow.test.js.map +1 -1
- package/dist/stream.test.js +1 -1
- package/dist/stream.test.js.map +1 -1
- package/package.json +3 -3
- package/src/client/errors.ts +3 -0
- package/src/client/fetch.ts +447 -0
- package/src/client/index.ts +73 -192
- package/src/client/shared.ts +406 -0
- package/src/client/types.ts +3 -1
- package/src/client.test.ts +52 -0
- package/src/fetch-client.test.ts +411 -0
- package/src/index.ts +1 -1
- package/src/mcp-client-transport.ts +5 -2
- package/src/spiceflow.test.ts +315 -1
- package/src/spiceflow.ts +106 -32
- package/src/stream.test.ts +1 -1
package/src/client/types.ts
CHANGED
|
@@ -28,7 +28,7 @@ type And<A extends boolean, B extends boolean> = A extends true
|
|
|
28
28
|
: false
|
|
29
29
|
: false
|
|
30
30
|
|
|
31
|
-
type ReplaceGeneratorWithAsyncGenerator<
|
|
31
|
+
export type ReplaceGeneratorWithAsyncGenerator<
|
|
32
32
|
in out RecordType extends Record<string, unknown>,
|
|
33
33
|
> = {
|
|
34
34
|
[K in keyof RecordType]: RecordType[K] extends any
|
|
@@ -132,6 +132,7 @@ export namespace SpiceflowClient {
|
|
|
132
132
|
export interface Config {
|
|
133
133
|
// fetch?: Omit<RequestInit, 'headers' | 'method'>
|
|
134
134
|
fetch?: typeof fetch
|
|
135
|
+
state?: Record<string, any>
|
|
135
136
|
headers?: MaybeArray<
|
|
136
137
|
| RequestInit['headers']
|
|
137
138
|
| ((path: string, options: RequestInit) => RequestInit['headers'] | void)
|
|
@@ -140,6 +141,7 @@ export namespace SpiceflowClient {
|
|
|
140
141
|
(path: string, options: RequestInit) => MaybePromise<RequestInit | void>
|
|
141
142
|
>
|
|
142
143
|
onResponse?: MaybeArray<(response: Response) => MaybePromise<unknown>>
|
|
144
|
+
retries?: number
|
|
143
145
|
|
|
144
146
|
// keepDomain?: boolean
|
|
145
147
|
}
|
package/src/client.test.ts
CHANGED
|
@@ -327,3 +327,55 @@ describe('client as promise', () => {
|
|
|
327
327
|
expect(data).toEqual({ test: 'value' })
|
|
328
328
|
}, 200)
|
|
329
329
|
})
|
|
330
|
+
|
|
331
|
+
describe('client retries', () => {
|
|
332
|
+
it('should retry on 500 errors and succeed on third attempt', async () => {
|
|
333
|
+
let attemptCount = 0
|
|
334
|
+
const retryApp = new Spiceflow().get('/retry-success', () => {
|
|
335
|
+
attemptCount++
|
|
336
|
+
if (attemptCount < 3) {
|
|
337
|
+
throw new Response('Server error', { status: 500 })
|
|
338
|
+
}
|
|
339
|
+
return { success: true, attempts: attemptCount }
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const retryClient = createSpiceflowClient(retryApp, { retries: 2 })
|
|
343
|
+
const { data, error } = await retryClient['retry-success'].get()
|
|
344
|
+
|
|
345
|
+
expect(error).toBeNull()
|
|
346
|
+
expect(data).toEqual({ success: true, attempts: 3 })
|
|
347
|
+
expect(attemptCount).toBe(3)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('should fail after all retries are exhausted', async () => {
|
|
351
|
+
let attemptCount = 0
|
|
352
|
+
const retryApp = new Spiceflow().get('/retry-fail', () => {
|
|
353
|
+
attemptCount++
|
|
354
|
+
throw new Response('Server error', { status: 500 })
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
const retryClient = createSpiceflowClient(retryApp, { retries: 2 })
|
|
358
|
+
const { data, error } = await retryClient['retry-fail'].get()
|
|
359
|
+
|
|
360
|
+
expect(data).toBeNull()
|
|
361
|
+
expect(error).toBeDefined()
|
|
362
|
+
expect(error?.status).toBe(500)
|
|
363
|
+
expect(attemptCount).toBe(3)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('should not retry on non-500 errors', async () => {
|
|
367
|
+
let attemptCount = 0
|
|
368
|
+
const retryApp = new Spiceflow().get('/retry-400', () => {
|
|
369
|
+
attemptCount++
|
|
370
|
+
throw new Response('Bad request', { status: 400 })
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
const retryClient = createSpiceflowClient(retryApp, { retries: 2 })
|
|
374
|
+
const { data, error } = await retryClient['retry-400'].get()
|
|
375
|
+
|
|
376
|
+
expect(data).toBeNull()
|
|
377
|
+
expect(error).toBeDefined()
|
|
378
|
+
expect(error?.status).toBe(400)
|
|
379
|
+
expect(attemptCount).toBe(1)
|
|
380
|
+
})
|
|
381
|
+
})
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { createSpiceflowFetch } from './client/fetch.ts'
|
|
3
|
+
import { Spiceflow } from './spiceflow.ts'
|
|
4
|
+
import { SpiceflowFetchError } from './client/errors.ts'
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
|
|
8
|
+
const app = new Spiceflow()
|
|
9
|
+
.state('someState', 1 as number | undefined)
|
|
10
|
+
.get('/', () => 'a')
|
|
11
|
+
.post('/', () => 'a')
|
|
12
|
+
.get('/number', () => 1)
|
|
13
|
+
.get('/someState', ({ state }) => state.someState)
|
|
14
|
+
.get('/true', () => true)
|
|
15
|
+
.get('/false', () => false)
|
|
16
|
+
.post('/array', async ({ request }) => await request.json(), {
|
|
17
|
+
body: z.array(z.string()),
|
|
18
|
+
})
|
|
19
|
+
.route({
|
|
20
|
+
method: 'POST',
|
|
21
|
+
path: '/mirror',
|
|
22
|
+
handler: async ({ request }) => await request.json(),
|
|
23
|
+
})
|
|
24
|
+
.route({
|
|
25
|
+
method: 'POST',
|
|
26
|
+
path: '/body',
|
|
27
|
+
handler: async ({ request }) => await request.text(),
|
|
28
|
+
body: z.string(),
|
|
29
|
+
})
|
|
30
|
+
.route({
|
|
31
|
+
method: 'DELETE',
|
|
32
|
+
path: '/empty',
|
|
33
|
+
handler: async ({ request }) => {
|
|
34
|
+
const body = await request.text()
|
|
35
|
+
return { body: body || null }
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
.route({
|
|
39
|
+
method: 'POST',
|
|
40
|
+
path: '/deep/nested/mirror',
|
|
41
|
+
handler: async ({ request }) => await request.json(),
|
|
42
|
+
body: z.object({
|
|
43
|
+
username: z.string(),
|
|
44
|
+
password: z.string(),
|
|
45
|
+
}),
|
|
46
|
+
})
|
|
47
|
+
.get('/throws', () => {
|
|
48
|
+
throw new Response('Custom error', { status: 400 })
|
|
49
|
+
})
|
|
50
|
+
.get('/throws-307', () => {
|
|
51
|
+
throw new Response('Redirect', {
|
|
52
|
+
status: 307,
|
|
53
|
+
headers: { location: 'http://example.com' },
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
.get('/throws-200', () => {
|
|
57
|
+
throw new Response('this string will not be parsed as json', {
|
|
58
|
+
status: 200,
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
.get('/throws-402-json', () => {
|
|
62
|
+
throw new Response(
|
|
63
|
+
JSON.stringify({ reason: 'Payment required', code: 4021 }),
|
|
64
|
+
{
|
|
65
|
+
status: 402,
|
|
66
|
+
headers: { 'content-type': 'application/json' },
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
.use(
|
|
71
|
+
new Spiceflow({ basePath: '/nested' }).get('/data', ({ params }) => 'hi'),
|
|
72
|
+
)
|
|
73
|
+
.get(
|
|
74
|
+
'/validationError',
|
|
75
|
+
// @ts-expect-error
|
|
76
|
+
() => {
|
|
77
|
+
return 'this errors because validation is wrong'
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
response: {
|
|
81
|
+
200: z.object({
|
|
82
|
+
x: z.string(),
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
.get('/dateObject', () => ({ date: new Date() }))
|
|
88
|
+
.get('/redirect', ({ redirect }) => redirect('http://localhost:8083/true'))
|
|
89
|
+
.post('/redirect', ({ redirect }) => redirect('http://localhost:8083/true'), {
|
|
90
|
+
body: z.object({
|
|
91
|
+
username: z.string(),
|
|
92
|
+
}),
|
|
93
|
+
})
|
|
94
|
+
.get('/stream', function* stream() {
|
|
95
|
+
yield 'a'
|
|
96
|
+
yield 'b'
|
|
97
|
+
yield 'c'
|
|
98
|
+
})
|
|
99
|
+
.get('/stream-async', async function* stream() {
|
|
100
|
+
yield 'a'
|
|
101
|
+
yield 'b'
|
|
102
|
+
yield 'c'
|
|
103
|
+
})
|
|
104
|
+
.get('/stream-return', function* stream() {
|
|
105
|
+
return 'a'
|
|
106
|
+
})
|
|
107
|
+
.get('/stream-return-async', function* stream() {
|
|
108
|
+
return 'a'
|
|
109
|
+
})
|
|
110
|
+
.get('/id/:id', ({ params: { id } }) => id)
|
|
111
|
+
.get('/items/:id/:id2', ({ params }) => params)
|
|
112
|
+
.get(
|
|
113
|
+
'/search',
|
|
114
|
+
({ query }) => query,
|
|
115
|
+
{ query: z.object({ q: z.string(), page: z.coerce.number().optional() }) },
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const f = createSpiceflowFetch(app)
|
|
119
|
+
|
|
120
|
+
describe('fetch client', () => {
|
|
121
|
+
it('get index', async () => {
|
|
122
|
+
const result = await f('/')
|
|
123
|
+
if (result instanceof Error) throw result
|
|
124
|
+
|
|
125
|
+
expect(result).toBe('a')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('post index', async () => {
|
|
129
|
+
const result = await f('/', { method: 'POST' })
|
|
130
|
+
if (result instanceof Error) throw result
|
|
131
|
+
|
|
132
|
+
expect(result).toBe('a')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('parse number', async () => {
|
|
136
|
+
const result = await f('/number')
|
|
137
|
+
if (result instanceof Error) throw result
|
|
138
|
+
|
|
139
|
+
expect(result).toEqual(1)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('parse true', async () => {
|
|
143
|
+
const result = await f('/true')
|
|
144
|
+
if (result instanceof Error) throw result
|
|
145
|
+
|
|
146
|
+
expect(result).toEqual(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('parse false', async () => {
|
|
150
|
+
const result = await f('/false')
|
|
151
|
+
if (result instanceof Error) throw result
|
|
152
|
+
|
|
153
|
+
expect(result).toEqual(false)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('post array', async () => {
|
|
157
|
+
const result = await f('/array', {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
body: ['a', 'b'],
|
|
160
|
+
})
|
|
161
|
+
if (result instanceof Error) throw result
|
|
162
|
+
|
|
163
|
+
expect(result).toEqual(['a', 'b'])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('post body', async () => {
|
|
167
|
+
const result = await f('/body', {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
body: 'a',
|
|
170
|
+
})
|
|
171
|
+
if (result instanceof Error) throw result
|
|
172
|
+
|
|
173
|
+
expect(result).toEqual('a')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('post mirror', async () => {
|
|
177
|
+
const body = { username: 'A', password: 'B' }
|
|
178
|
+
|
|
179
|
+
const result = await f('/mirror', {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
body,
|
|
182
|
+
})
|
|
183
|
+
if (result instanceof Error) throw result
|
|
184
|
+
|
|
185
|
+
expect(result).toEqual(body)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('delete empty', async () => {
|
|
189
|
+
const result = await f('/empty', { method: 'DELETE' })
|
|
190
|
+
if (result instanceof Error) throw result
|
|
191
|
+
|
|
192
|
+
expect(result).toEqual({ body: null })
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('post deep nested mirror', async () => {
|
|
196
|
+
const body = { username: 'A', password: 'B' }
|
|
197
|
+
|
|
198
|
+
const result = await f('/deep/nested/mirror', {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
body,
|
|
201
|
+
})
|
|
202
|
+
if (result instanceof Error) throw result
|
|
203
|
+
|
|
204
|
+
expect(result).toEqual(body)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('get nested data', async () => {
|
|
208
|
+
const result = await f('/nested/data')
|
|
209
|
+
if (result instanceof Error) throw result
|
|
210
|
+
|
|
211
|
+
expect(result).toEqual('hi')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('handles thrown response', async () => {
|
|
215
|
+
const result = await f('/throws')
|
|
216
|
+
|
|
217
|
+
expect(result).toBeInstanceOf(SpiceflowFetchError)
|
|
218
|
+
if (!(result instanceof Error)) throw new Error('Expected error')
|
|
219
|
+
expect(result.status).toBe(400)
|
|
220
|
+
expect(result.message).toBe('Custom error')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('handles thrown response with 307', async () => {
|
|
224
|
+
const result = await f('/throws-307')
|
|
225
|
+
|
|
226
|
+
expect(result).toBeInstanceOf(SpiceflowFetchError)
|
|
227
|
+
if (!(result instanceof Error)) throw new Error('Expected error')
|
|
228
|
+
expect(result.status).toBe(307)
|
|
229
|
+
expect(result.message).toBe('Redirect')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('handles thrown response with 200', async () => {
|
|
233
|
+
const result = await f('/throws-200')
|
|
234
|
+
if (result instanceof Error) throw result
|
|
235
|
+
|
|
236
|
+
expect(result).toMatchInlineSnapshot(
|
|
237
|
+
`"this string will not be parsed as json"`,
|
|
238
|
+
)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('surfaces json payload in error value for 402 responses', async () => {
|
|
242
|
+
const result = await f('/throws-402-json')
|
|
243
|
+
|
|
244
|
+
expect(result).toBeInstanceOf(SpiceflowFetchError)
|
|
245
|
+
if (!(result instanceof SpiceflowFetchError)) throw new Error('Expected SpiceflowFetchError')
|
|
246
|
+
expect(result.status).toBe(402)
|
|
247
|
+
expect(result.value).toEqual({ reason: 'Payment required', code: 4021 })
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('stream', async () => {
|
|
251
|
+
const result = await f('/stream')
|
|
252
|
+
if (result instanceof Error) throw result
|
|
253
|
+
|
|
254
|
+
let all = ''
|
|
255
|
+
for await (const chunk of result) {
|
|
256
|
+
all += chunk + '-'
|
|
257
|
+
}
|
|
258
|
+
expect(all).toEqual('a-b-c-')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('stream async', async () => {
|
|
262
|
+
const result = await f('/stream-async')
|
|
263
|
+
if (result instanceof Error) throw result
|
|
264
|
+
|
|
265
|
+
let all = ''
|
|
266
|
+
for await (const chunk of result) {
|
|
267
|
+
all += chunk + '-'
|
|
268
|
+
}
|
|
269
|
+
expect(all).toEqual('a-b-c-')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('stream return', async () => {
|
|
273
|
+
const result = await f('/stream-return')
|
|
274
|
+
if (result instanceof Error) throw result
|
|
275
|
+
|
|
276
|
+
let all = ''
|
|
277
|
+
for await (const chunk of result) {
|
|
278
|
+
all += chunk
|
|
279
|
+
}
|
|
280
|
+
expect(all).toEqual('a')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('stream return async', async () => {
|
|
284
|
+
const result = await f('/stream-return-async')
|
|
285
|
+
if (result instanceof Error) throw result
|
|
286
|
+
|
|
287
|
+
let all = ''
|
|
288
|
+
for await (const chunk of result) {
|
|
289
|
+
all += chunk
|
|
290
|
+
}
|
|
291
|
+
expect(all).toEqual('a')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('path params', async () => {
|
|
295
|
+
const result = await f('/id/:id', {
|
|
296
|
+
params: { id: '123' },
|
|
297
|
+
})
|
|
298
|
+
if (result instanceof Error) throw result
|
|
299
|
+
|
|
300
|
+
expect(result).toEqual('123')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('query params', async () => {
|
|
304
|
+
const result = await f('/search', {
|
|
305
|
+
query: { q: 'hello', page: 1 },
|
|
306
|
+
})
|
|
307
|
+
if (result instanceof Error) throw result
|
|
308
|
+
|
|
309
|
+
expect(result).toEqual({ q: 'hello', page: 1 })
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('overlapping param names', async () => {
|
|
313
|
+
const result = await f('/items/:id/:id2', {
|
|
314
|
+
params: { id: 'A', id2: 'B' },
|
|
315
|
+
})
|
|
316
|
+
if (result instanceof Error) throw result
|
|
317
|
+
|
|
318
|
+
expect(result).toEqual({ id: 'A', id2: 'B' })
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('untyped URL falls back gracefully', async () => {
|
|
322
|
+
const untypedFetch = createSpiceflowFetch(app)
|
|
323
|
+
const result = await (untypedFetch as any)('/number')
|
|
324
|
+
if (result instanceof Error) throw result
|
|
325
|
+
expect(result).toEqual(1)
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
describe('fetch client type safety', () => {
|
|
330
|
+
it('requires params for parameterized paths', () => {
|
|
331
|
+
// @ts-expect-error - missing required params for /id/:id
|
|
332
|
+
f('/id/:id')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('requires query when route schema demands it', () => {
|
|
336
|
+
// @ts-expect-error - missing required query for /search
|
|
337
|
+
f('/search')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('requires body for POST with typed body schema', () => {
|
|
341
|
+
// @ts-expect-error - missing required body for /deep/nested/mirror POST
|
|
342
|
+
f('/deep/nested/mirror', { method: 'POST' })
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('allows GET without options on routes with no required fields', async () => {
|
|
346
|
+
const result = await f('/number')
|
|
347
|
+
if (result instanceof Error) throw result
|
|
348
|
+
expect(result).toEqual(1)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
describe('fetch client with state', () => {
|
|
353
|
+
it('should return state value', async () => {
|
|
354
|
+
const f = createSpiceflowFetch(app, { state: { someState: 3 } })
|
|
355
|
+
const result = await f('/someState')
|
|
356
|
+
if (result instanceof Error) throw result
|
|
357
|
+
expect(result).toBe(3)
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
describe('fetch client retries', () => {
|
|
362
|
+
it('should retry on 500 errors and succeed on third attempt', async () => {
|
|
363
|
+
let attemptCount = 0
|
|
364
|
+
const retryApp = new Spiceflow().get('/retry-success', () => {
|
|
365
|
+
attemptCount++
|
|
366
|
+
if (attemptCount < 3) {
|
|
367
|
+
throw new Response('Server error', { status: 500 })
|
|
368
|
+
}
|
|
369
|
+
return { success: true, attempts: attemptCount }
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
const retryFetch = createSpiceflowFetch(retryApp, { retries: 2 })
|
|
373
|
+
const result = await retryFetch('/retry-success')
|
|
374
|
+
if (result instanceof Error) throw result
|
|
375
|
+
|
|
376
|
+
expect(result).toEqual({ success: true, attempts: 3 })
|
|
377
|
+
expect(attemptCount).toBe(3)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('should fail after all retries are exhausted', async () => {
|
|
381
|
+
let attemptCount = 0
|
|
382
|
+
const retryApp = new Spiceflow().get('/retry-fail', () => {
|
|
383
|
+
attemptCount++
|
|
384
|
+
throw new Response('Server error', { status: 500 })
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
const retryFetch = createSpiceflowFetch(retryApp, { retries: 2 })
|
|
388
|
+
const result = await retryFetch('/retry-fail')
|
|
389
|
+
|
|
390
|
+
expect(result).toBeInstanceOf(SpiceflowFetchError)
|
|
391
|
+
if (!(result instanceof Error)) throw new Error('Expected error')
|
|
392
|
+
expect(result.status).toBe(500)
|
|
393
|
+
expect(attemptCount).toBe(3)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('should not retry on non-500 errors', async () => {
|
|
397
|
+
let attemptCount = 0
|
|
398
|
+
const retryApp = new Spiceflow().get('/retry-400', () => {
|
|
399
|
+
attemptCount++
|
|
400
|
+
throw new Response('Bad request', { status: 400 })
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const retryFetch = createSpiceflowFetch(retryApp, { retries: 2 })
|
|
404
|
+
const result = await retryFetch('/retry-400')
|
|
405
|
+
|
|
406
|
+
expect(result).toBeInstanceOf(SpiceflowFetchError)
|
|
407
|
+
if (!(result instanceof Error)) throw new Error('Expected error')
|
|
408
|
+
expect(result.status).toBe(400)
|
|
409
|
+
expect(attemptCount).toBe(1)
|
|
410
|
+
})
|
|
411
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SpiceflowRequest, WaitUntil, Method } from './spiceflow.ts'
|
|
2
2
|
|
|
3
|
-
export { Spiceflow } from './spiceflow.ts'
|
|
3
|
+
export { Spiceflow, createSafePath } from './spiceflow.ts'
|
|
4
4
|
export type { AnySpiceflow, WaitUntil } from './spiceflow.ts'
|
|
5
5
|
export { ValidationError } from './error.ts'
|
|
6
6
|
export { preventProcessExitIfBusy } from './prevent-process-exit-if-busy.ts'
|
|
@@ -68,8 +68,11 @@ export class FetchMCPCLientTransport implements Transport {
|
|
|
68
68
|
`SSE connection failed (HTTP ${sseRes.status})\nURL: ${this.sseUrl}\nText: ${text}`,
|
|
69
69
|
)
|
|
70
70
|
}
|
|
71
|
-
for await (const evt of streamSSEResponse(
|
|
72
|
-
|
|
71
|
+
for await (const evt of streamSSEResponse({
|
|
72
|
+
response: sseRes,
|
|
73
|
+
map: (x) => {
|
|
74
|
+
return x
|
|
75
|
+
},
|
|
73
76
|
}) as AsyncGenerator<{
|
|
74
77
|
event: string
|
|
75
78
|
data: any
|