@cloudflare/sandbox 0.5.4 → 0.6.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/Dockerfile +54 -59
- package/README.md +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/package.json +13 -8
- package/.turbo/turbo-build.log +0 -23
- package/CHANGELOG.md +0 -441
- package/src/clients/base-client.ts +0 -356
- package/src/clients/command-client.ts +0 -133
- package/src/clients/file-client.ts +0 -300
- package/src/clients/git-client.ts +0 -98
- package/src/clients/index.ts +0 -64
- package/src/clients/interpreter-client.ts +0 -333
- package/src/clients/port-client.ts +0 -105
- package/src/clients/process-client.ts +0 -198
- package/src/clients/sandbox-client.ts +0 -39
- package/src/clients/types.ts +0 -88
- package/src/clients/utility-client.ts +0 -156
- package/src/errors/adapter.ts +0 -238
- package/src/errors/classes.ts +0 -594
- package/src/errors/index.ts +0 -109
- package/src/file-stream.ts +0 -169
- package/src/index.ts +0 -121
- package/src/interpreter.ts +0 -168
- package/src/openai/index.ts +0 -465
- package/src/request-handler.ts +0 -184
- package/src/sandbox.ts +0 -1937
- package/src/security.ts +0 -119
- package/src/sse-parser.ts +0 -144
- package/src/storage-mount/credential-detection.ts +0 -41
- package/src/storage-mount/errors.ts +0 -51
- package/src/storage-mount/index.ts +0 -17
- package/src/storage-mount/provider-detection.ts +0 -93
- package/src/storage-mount/types.ts +0 -17
- package/src/version.ts +0 -6
- package/tests/base-client.test.ts +0 -582
- package/tests/command-client.test.ts +0 -444
- package/tests/file-client.test.ts +0 -831
- package/tests/file-stream.test.ts +0 -310
- package/tests/get-sandbox.test.ts +0 -172
- package/tests/git-client.test.ts +0 -455
- package/tests/openai-shell-editor.test.ts +0 -434
- package/tests/port-client.test.ts +0 -283
- package/tests/process-client.test.ts +0 -649
- package/tests/request-handler.test.ts +0 -292
- package/tests/sandbox.test.ts +0 -890
- package/tests/sse-parser.test.ts +0 -291
- package/tests/storage-mount/credential-detection.test.ts +0 -119
- package/tests/storage-mount/provider-detection.test.ts +0 -77
- package/tests/utility-client.test.ts +0 -339
- package/tests/version.test.ts +0 -16
- package/tests/wrangler.jsonc +0 -35
- package/tsconfig.json +0 -11
- package/tsdown.config.ts +0 -13
- package/vitest.config.ts +0 -31
|
@@ -1,582 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import type { BaseApiResponse, HttpClientOptions } from '../src/clients';
|
|
3
|
-
import { BaseHttpClient } from '../src/clients/base-client';
|
|
4
|
-
import type { ErrorResponse } from '../src/errors';
|
|
5
|
-
import {
|
|
6
|
-
CommandError,
|
|
7
|
-
FileNotFoundError,
|
|
8
|
-
FileSystemError,
|
|
9
|
-
PermissionDeniedError,
|
|
10
|
-
SandboxError
|
|
11
|
-
} from '../src/errors';
|
|
12
|
-
|
|
13
|
-
interface TestDataResponse extends BaseApiResponse {
|
|
14
|
-
data: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface TestResourceResponse extends BaseApiResponse {
|
|
18
|
-
id: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface TestSourceResponse extends BaseApiResponse {
|
|
22
|
-
source: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface TestStatusResponse extends BaseApiResponse {
|
|
26
|
-
status: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
class TestHttpClient extends BaseHttpClient {
|
|
30
|
-
constructor(options: HttpClientOptions = {}) {
|
|
31
|
-
super({
|
|
32
|
-
baseUrl: 'http://test.com',
|
|
33
|
-
port: 3000,
|
|
34
|
-
...options
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
public async testRequest<T = BaseApiResponse>(
|
|
39
|
-
endpoint: string,
|
|
40
|
-
data?: Record<string, unknown>
|
|
41
|
-
): Promise<T> {
|
|
42
|
-
if (data) {
|
|
43
|
-
return this.post<T>(endpoint, data);
|
|
44
|
-
}
|
|
45
|
-
return this.get<T>(endpoint);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
public async testStreamRequest(endpoint: string): Promise<ReadableStream> {
|
|
49
|
-
const response = await this.doFetch(endpoint);
|
|
50
|
-
return this.handleStreamResponse(response);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
public async testErrorHandling(errorResponse: ErrorResponse) {
|
|
54
|
-
const response = new Response(JSON.stringify(errorResponse), {
|
|
55
|
-
status: errorResponse.httpStatus || 400
|
|
56
|
-
});
|
|
57
|
-
return this.handleErrorResponse(response);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
describe('BaseHttpClient', () => {
|
|
62
|
-
let client: TestHttpClient;
|
|
63
|
-
let mockFetch: ReturnType<typeof vi.fn>;
|
|
64
|
-
let onError: ReturnType<typeof vi.fn>;
|
|
65
|
-
|
|
66
|
-
beforeEach(() => {
|
|
67
|
-
vi.clearAllMocks();
|
|
68
|
-
|
|
69
|
-
mockFetch = vi.fn();
|
|
70
|
-
global.fetch = mockFetch as unknown as typeof fetch;
|
|
71
|
-
onError = vi.fn();
|
|
72
|
-
|
|
73
|
-
client = new TestHttpClient({
|
|
74
|
-
baseUrl: 'http://test.com',
|
|
75
|
-
port: 3000,
|
|
76
|
-
onError
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
afterEach(() => {
|
|
81
|
-
vi.restoreAllMocks();
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe('core request functionality', () => {
|
|
85
|
-
it('should handle successful API requests', async () => {
|
|
86
|
-
mockFetch.mockResolvedValue(
|
|
87
|
-
new Response(
|
|
88
|
-
JSON.stringify({ success: true, data: 'operation completed' }),
|
|
89
|
-
{ status: 200 }
|
|
90
|
-
)
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
const result = await client.testRequest<TestDataResponse>('/api/test');
|
|
94
|
-
|
|
95
|
-
expect(result.success).toBe(true);
|
|
96
|
-
expect(result.data).toBe('operation completed');
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('should handle POST requests with data', async () => {
|
|
100
|
-
const requestData = { action: 'create', name: 'test-resource' };
|
|
101
|
-
mockFetch.mockResolvedValue(
|
|
102
|
-
new Response(JSON.stringify({ success: true, id: 'resource-123' }), {
|
|
103
|
-
status: 201
|
|
104
|
-
})
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
const result = await client.testRequest<TestResourceResponse>(
|
|
108
|
-
'/api/create',
|
|
109
|
-
requestData
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
expect(result.success).toBe(true);
|
|
113
|
-
expect(result.id).toBe('resource-123');
|
|
114
|
-
|
|
115
|
-
const [url, options] = mockFetch.mock.calls[0];
|
|
116
|
-
expect(url).toBe('http://test.com/api/create');
|
|
117
|
-
expect(options.method).toBe('POST');
|
|
118
|
-
expect(options.headers['Content-Type']).toBe('application/json');
|
|
119
|
-
expect(JSON.parse(options.body)).toEqual(requestData);
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
describe('error handling and mapping', () => {
|
|
124
|
-
it('should map container errors to client errors', async () => {
|
|
125
|
-
const errorMappingTests = [
|
|
126
|
-
{
|
|
127
|
-
containerError: {
|
|
128
|
-
code: 'FILE_NOT_FOUND',
|
|
129
|
-
message: 'File not found: /test.txt',
|
|
130
|
-
context: { path: '/test.txt' },
|
|
131
|
-
httpStatus: 404,
|
|
132
|
-
timestamp: new Date().toISOString()
|
|
133
|
-
},
|
|
134
|
-
expectedError: FileNotFoundError
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
containerError: {
|
|
138
|
-
code: 'PERMISSION_DENIED',
|
|
139
|
-
message: 'Permission denied',
|
|
140
|
-
context: { path: '/secure.txt' },
|
|
141
|
-
httpStatus: 403,
|
|
142
|
-
timestamp: new Date().toISOString()
|
|
143
|
-
},
|
|
144
|
-
expectedError: PermissionDeniedError
|
|
145
|
-
},
|
|
146
|
-
{
|
|
147
|
-
containerError: {
|
|
148
|
-
code: 'COMMAND_EXECUTION_ERROR',
|
|
149
|
-
message: 'Command failed: badcmd',
|
|
150
|
-
context: { command: 'badcmd' },
|
|
151
|
-
httpStatus: 400,
|
|
152
|
-
timestamp: new Date().toISOString()
|
|
153
|
-
},
|
|
154
|
-
expectedError: CommandError
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
containerError: {
|
|
158
|
-
code: 'FILESYSTEM_ERROR',
|
|
159
|
-
message: 'Filesystem error',
|
|
160
|
-
context: { path: '/test' },
|
|
161
|
-
httpStatus: 500,
|
|
162
|
-
timestamp: new Date().toISOString()
|
|
163
|
-
},
|
|
164
|
-
expectedError: FileSystemError
|
|
165
|
-
},
|
|
166
|
-
{
|
|
167
|
-
containerError: {
|
|
168
|
-
code: 'UNKNOWN_ERROR',
|
|
169
|
-
message: 'Unknown error',
|
|
170
|
-
context: {},
|
|
171
|
-
httpStatus: 500,
|
|
172
|
-
timestamp: new Date().toISOString()
|
|
173
|
-
},
|
|
174
|
-
expectedError: SandboxError
|
|
175
|
-
}
|
|
176
|
-
];
|
|
177
|
-
|
|
178
|
-
for (const test of errorMappingTests) {
|
|
179
|
-
await expect(
|
|
180
|
-
client.testErrorHandling(test.containerError as ErrorResponse)
|
|
181
|
-
).rejects.toThrow(test.expectedError);
|
|
182
|
-
|
|
183
|
-
expect(onError).toHaveBeenCalledWith(
|
|
184
|
-
test.containerError.message,
|
|
185
|
-
undefined
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('should handle malformed error responses', async () => {
|
|
191
|
-
mockFetch.mockResolvedValue(
|
|
192
|
-
new Response('invalid json {', { status: 500 })
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
await expect(client.testRequest('/api/test')).rejects.toThrow(
|
|
196
|
-
SandboxError
|
|
197
|
-
);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('should handle network failures', async () => {
|
|
201
|
-
mockFetch.mockRejectedValue(new Error('Network connection timeout'));
|
|
202
|
-
|
|
203
|
-
await expect(client.testRequest('/api/test')).rejects.toThrow(
|
|
204
|
-
'Network connection timeout'
|
|
205
|
-
);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('should handle server unavailable scenarios', async () => {
|
|
209
|
-
mockFetch.mockResolvedValue(
|
|
210
|
-
new Response('Service Unavailable', { status: 503 })
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
await expect(client.testRequest('/api/test')).rejects.toThrow(
|
|
214
|
-
SandboxError
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
expect(onError).toHaveBeenCalledWith(
|
|
218
|
-
'HTTP error! status: 503',
|
|
219
|
-
undefined
|
|
220
|
-
);
|
|
221
|
-
});
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
describe('streaming functionality', () => {
|
|
225
|
-
it('should handle streaming responses', async () => {
|
|
226
|
-
const streamData = 'data: {"type":"output","content":"stream data"}\n\n';
|
|
227
|
-
const mockStream = new ReadableStream({
|
|
228
|
-
start(controller) {
|
|
229
|
-
controller.enqueue(new TextEncoder().encode(streamData));
|
|
230
|
-
controller.close();
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
mockFetch.mockResolvedValue(
|
|
235
|
-
new Response(mockStream, {
|
|
236
|
-
status: 200,
|
|
237
|
-
headers: { 'Content-Type': 'text/event-stream' }
|
|
238
|
-
})
|
|
239
|
-
);
|
|
240
|
-
|
|
241
|
-
const stream = await client.testStreamRequest('/api/stream');
|
|
242
|
-
|
|
243
|
-
expect(stream).toBeInstanceOf(ReadableStream);
|
|
244
|
-
|
|
245
|
-
const reader = stream.getReader();
|
|
246
|
-
const { done, value } = await reader.read();
|
|
247
|
-
const content = new TextDecoder().decode(value);
|
|
248
|
-
|
|
249
|
-
expect(done).toBe(false);
|
|
250
|
-
expect(content).toContain('stream data');
|
|
251
|
-
|
|
252
|
-
reader.releaseLock();
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it('should handle streaming errors', async () => {
|
|
256
|
-
mockFetch.mockResolvedValue(
|
|
257
|
-
new Response(
|
|
258
|
-
JSON.stringify({
|
|
259
|
-
error: 'Stream initialization failed',
|
|
260
|
-
code: 'STREAM_ERROR'
|
|
261
|
-
}),
|
|
262
|
-
{ status: 400 }
|
|
263
|
-
)
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
await expect(client.testStreamRequest('/api/bad-stream')).rejects.toThrow(
|
|
267
|
-
SandboxError
|
|
268
|
-
);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it('should handle missing stream body', async () => {
|
|
272
|
-
mockFetch.mockResolvedValue(
|
|
273
|
-
new Response(null, {
|
|
274
|
-
status: 200,
|
|
275
|
-
headers: { 'Content-Type': 'text/event-stream' }
|
|
276
|
-
})
|
|
277
|
-
);
|
|
278
|
-
|
|
279
|
-
await expect(
|
|
280
|
-
client.testStreamRequest('/api/empty-stream')
|
|
281
|
-
).rejects.toThrow('No response body for streaming');
|
|
282
|
-
});
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
describe('stub integration', () => {
|
|
286
|
-
it('should use stub when provided instead of fetch', async () => {
|
|
287
|
-
const stubFetch = vi.fn().mockResolvedValue(
|
|
288
|
-
new Response(JSON.stringify({ success: true, source: 'stub' }), {
|
|
289
|
-
status: 200
|
|
290
|
-
})
|
|
291
|
-
);
|
|
292
|
-
|
|
293
|
-
const stub = { containerFetch: stubFetch };
|
|
294
|
-
const stubClient = new TestHttpClient({
|
|
295
|
-
baseUrl: 'http://test.com',
|
|
296
|
-
port: 3000,
|
|
297
|
-
stub
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
const result =
|
|
301
|
-
await stubClient.testRequest<TestSourceResponse>('/api/stub-test');
|
|
302
|
-
|
|
303
|
-
expect(result.success).toBe(true);
|
|
304
|
-
expect(result.source).toBe('stub');
|
|
305
|
-
expect(stubFetch).toHaveBeenCalledWith(
|
|
306
|
-
'http://localhost:3000/api/stub-test',
|
|
307
|
-
{ method: 'GET' },
|
|
308
|
-
3000
|
|
309
|
-
);
|
|
310
|
-
expect(mockFetch).not.toHaveBeenCalled();
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
it('should handle stub errors', async () => {
|
|
314
|
-
const stubFetch = vi
|
|
315
|
-
.fn()
|
|
316
|
-
.mockRejectedValue(new Error('Stub connection failed'));
|
|
317
|
-
const stub = { containerFetch: stubFetch };
|
|
318
|
-
const stubClient = new TestHttpClient({
|
|
319
|
-
baseUrl: 'http://test.com',
|
|
320
|
-
port: 3000,
|
|
321
|
-
stub
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
await expect(stubClient.testRequest('/api/stub-error')).rejects.toThrow(
|
|
325
|
-
'Stub connection failed'
|
|
326
|
-
);
|
|
327
|
-
});
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
describe('edge cases and resilience', () => {
|
|
331
|
-
it('should handle responses with unusual status codes', async () => {
|
|
332
|
-
const unusualStatusTests = [
|
|
333
|
-
{ status: 201, shouldSucceed: true },
|
|
334
|
-
{ status: 202, shouldSucceed: true },
|
|
335
|
-
{ status: 409, shouldSucceed: false },
|
|
336
|
-
{ status: 422, shouldSucceed: false },
|
|
337
|
-
{ status: 429, shouldSucceed: false }
|
|
338
|
-
];
|
|
339
|
-
|
|
340
|
-
for (const test of unusualStatusTests) {
|
|
341
|
-
mockFetch.mockResolvedValueOnce(
|
|
342
|
-
new Response(
|
|
343
|
-
test.shouldSucceed
|
|
344
|
-
? JSON.stringify({ success: true, status: test.status })
|
|
345
|
-
: JSON.stringify({ error: `Status ${test.status}` }),
|
|
346
|
-
{ status: test.status }
|
|
347
|
-
)
|
|
348
|
-
);
|
|
349
|
-
|
|
350
|
-
if (test.shouldSucceed) {
|
|
351
|
-
const result = await client.testRequest<TestStatusResponse>(
|
|
352
|
-
'/api/unusual-status'
|
|
353
|
-
);
|
|
354
|
-
expect(result.success).toBe(true);
|
|
355
|
-
expect(result.status).toBe(test.status);
|
|
356
|
-
} else {
|
|
357
|
-
await expect(
|
|
358
|
-
client.testRequest('/api/unusual-status')
|
|
359
|
-
).rejects.toThrow();
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
});
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
describe('container startup retry logic', () => {
|
|
366
|
-
it('should retry 503 errors with "no container instance available"', async () => {
|
|
367
|
-
vi.useFakeTimers();
|
|
368
|
-
|
|
369
|
-
mockFetch
|
|
370
|
-
.mockResolvedValueOnce(
|
|
371
|
-
new Response('Error: There is no container instance available', {
|
|
372
|
-
status: 503
|
|
373
|
-
})
|
|
374
|
-
)
|
|
375
|
-
.mockResolvedValueOnce(
|
|
376
|
-
new Response(JSON.stringify({ success: true, data: 'recovered' }), {
|
|
377
|
-
status: 200
|
|
378
|
-
})
|
|
379
|
-
);
|
|
380
|
-
|
|
381
|
-
const promise = client.testRequest<TestDataResponse>('/api/test');
|
|
382
|
-
await vi.advanceTimersByTimeAsync(5_000);
|
|
383
|
-
const result = await promise;
|
|
384
|
-
|
|
385
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
386
|
-
expect(result.success).toBe(true);
|
|
387
|
-
expect(result.data).toBe('recovered');
|
|
388
|
-
|
|
389
|
-
vi.useRealTimers();
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
it('should retry 500 errors with "container port not found"', async () => {
|
|
393
|
-
vi.useFakeTimers();
|
|
394
|
-
|
|
395
|
-
mockFetch
|
|
396
|
-
.mockResolvedValueOnce(
|
|
397
|
-
new Response('Connection refused: container port not found', {
|
|
398
|
-
status: 500
|
|
399
|
-
})
|
|
400
|
-
)
|
|
401
|
-
.mockResolvedValueOnce(
|
|
402
|
-
new Response(JSON.stringify({ success: true }), { status: 200 })
|
|
403
|
-
);
|
|
404
|
-
|
|
405
|
-
const promise = client.testRequest('/api/test');
|
|
406
|
-
await vi.advanceTimersByTimeAsync(5_000);
|
|
407
|
-
const result = await promise;
|
|
408
|
-
|
|
409
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
410
|
-
expect(result.success).toBe(true);
|
|
411
|
-
|
|
412
|
-
vi.useRealTimers();
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
it('should retry 500 errors with "the container is not listening"', async () => {
|
|
416
|
-
vi.useFakeTimers();
|
|
417
|
-
|
|
418
|
-
mockFetch
|
|
419
|
-
.mockResolvedValueOnce(
|
|
420
|
-
new Response('Error: the container is not listening on port 3000', {
|
|
421
|
-
status: 500
|
|
422
|
-
})
|
|
423
|
-
)
|
|
424
|
-
.mockResolvedValueOnce(
|
|
425
|
-
new Response(JSON.stringify({ success: true }), { status: 200 })
|
|
426
|
-
);
|
|
427
|
-
|
|
428
|
-
const promise = client.testRequest('/api/test');
|
|
429
|
-
await vi.advanceTimersByTimeAsync(5_000);
|
|
430
|
-
const result = await promise;
|
|
431
|
-
|
|
432
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
433
|
-
expect(result.success).toBe(true);
|
|
434
|
-
|
|
435
|
-
vi.useRealTimers();
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
it('should NOT retry 500 errors with "no such image"', async () => {
|
|
439
|
-
mockFetch.mockResolvedValueOnce(
|
|
440
|
-
new Response('Error: no such image: my-container:latest', {
|
|
441
|
-
status: 500
|
|
442
|
-
})
|
|
443
|
-
);
|
|
444
|
-
|
|
445
|
-
await expect(client.testRequest('/api/test')).rejects.toThrow();
|
|
446
|
-
expect(mockFetch).toHaveBeenCalledTimes(1); // No retry
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
it('should NOT retry 500 errors with "container already exists"', async () => {
|
|
450
|
-
mockFetch.mockResolvedValueOnce(
|
|
451
|
-
new Response('Error: container already exists', { status: 500 })
|
|
452
|
-
);
|
|
453
|
-
|
|
454
|
-
await expect(client.testRequest('/api/test')).rejects.toThrow();
|
|
455
|
-
expect(mockFetch).toHaveBeenCalledTimes(1); // No retry
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
it('should NOT retry 500 errors with unknown patterns', async () => {
|
|
459
|
-
mockFetch.mockResolvedValueOnce(
|
|
460
|
-
new Response('Internal server error: database connection failed', {
|
|
461
|
-
status: 500
|
|
462
|
-
})
|
|
463
|
-
);
|
|
464
|
-
|
|
465
|
-
await expect(client.testRequest('/api/test')).rejects.toThrow();
|
|
466
|
-
expect(mockFetch).toHaveBeenCalledTimes(1); // Fail-safe: don't retry
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
it('should NOT retry 404 or other non-500/503 errors', async () => {
|
|
470
|
-
mockFetch.mockResolvedValueOnce(
|
|
471
|
-
new Response('Not found', { status: 404 })
|
|
472
|
-
);
|
|
473
|
-
|
|
474
|
-
await expect(client.testRequest('/api/test')).rejects.toThrow();
|
|
475
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
it('should respect MIN_TIME_FOR_RETRY_MS and stop retrying', async () => {
|
|
479
|
-
vi.useFakeTimers();
|
|
480
|
-
|
|
481
|
-
// Mock responses that would trigger retry
|
|
482
|
-
mockFetch.mockResolvedValue(
|
|
483
|
-
new Response('No container instance available', { status: 503 })
|
|
484
|
-
);
|
|
485
|
-
|
|
486
|
-
const promise = client.testRequest('/api/test');
|
|
487
|
-
|
|
488
|
-
// Fast-forward past retry budget (120s)
|
|
489
|
-
await vi.advanceTimersByTimeAsync(125_000);
|
|
490
|
-
|
|
491
|
-
// Should eventually give up and throw the 503 error
|
|
492
|
-
await expect(promise).rejects.toThrow();
|
|
493
|
-
|
|
494
|
-
vi.useRealTimers();
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
it('should use exponential backoff: 3s, 6s, 12s, 24s, 30s', async () => {
|
|
498
|
-
vi.useFakeTimers();
|
|
499
|
-
const delays: number[] = [];
|
|
500
|
-
let callCount = 0;
|
|
501
|
-
|
|
502
|
-
mockFetch.mockImplementation(async () => {
|
|
503
|
-
delays.push(Date.now());
|
|
504
|
-
callCount++;
|
|
505
|
-
// After 5 attempts, return success to avoid timeout
|
|
506
|
-
if (callCount >= 5) {
|
|
507
|
-
return new Response(JSON.stringify({ success: true }), {
|
|
508
|
-
status: 200
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
return new Response('No container instance available', { status: 503 });
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
const promise = client.testRequest('/api/test');
|
|
515
|
-
|
|
516
|
-
// Advance time to allow all retries
|
|
517
|
-
await vi.advanceTimersByTimeAsync(80_000);
|
|
518
|
-
|
|
519
|
-
await promise;
|
|
520
|
-
|
|
521
|
-
// Check delays between attempts (approximately)
|
|
522
|
-
// Attempt 1 at 0ms, Attempt 2 at ~3000ms, Attempt 3 at ~9000ms, etc.
|
|
523
|
-
expect(delays.length).toBeGreaterThanOrEqual(4);
|
|
524
|
-
|
|
525
|
-
vi.useRealTimers();
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
it('should retry multiple transient errors in sequence', async () => {
|
|
529
|
-
vi.useFakeTimers();
|
|
530
|
-
|
|
531
|
-
mockFetch
|
|
532
|
-
.mockResolvedValueOnce(
|
|
533
|
-
new Response('No container instance available', { status: 503 })
|
|
534
|
-
)
|
|
535
|
-
.mockResolvedValueOnce(
|
|
536
|
-
new Response('Container port not found', { status: 500 })
|
|
537
|
-
)
|
|
538
|
-
.mockResolvedValueOnce(
|
|
539
|
-
new Response('The container is not listening', { status: 500 })
|
|
540
|
-
)
|
|
541
|
-
.mockResolvedValueOnce(
|
|
542
|
-
new Response(JSON.stringify({ success: true }), { status: 200 })
|
|
543
|
-
);
|
|
544
|
-
|
|
545
|
-
const promise = client.testRequest('/api/test');
|
|
546
|
-
|
|
547
|
-
// Advance time to allow all retries (3s + 6s + 12s = 21s)
|
|
548
|
-
await vi.advanceTimersByTimeAsync(25_000);
|
|
549
|
-
|
|
550
|
-
const result = await promise;
|
|
551
|
-
|
|
552
|
-
expect(mockFetch).toHaveBeenCalledTimes(4);
|
|
553
|
-
expect(result.success).toBe(true);
|
|
554
|
-
|
|
555
|
-
vi.useRealTimers();
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
it('should handle case-insensitive error matching', async () => {
|
|
559
|
-
vi.useFakeTimers();
|
|
560
|
-
|
|
561
|
-
mockFetch
|
|
562
|
-
.mockResolvedValueOnce(
|
|
563
|
-
new Response('ERROR: CONTAINER PORT NOT FOUND', { status: 500 })
|
|
564
|
-
)
|
|
565
|
-
.mockResolvedValueOnce(
|
|
566
|
-
new Response(JSON.stringify({ success: true }), { status: 200 })
|
|
567
|
-
);
|
|
568
|
-
|
|
569
|
-
const promise = client.testRequest('/api/test');
|
|
570
|
-
|
|
571
|
-
// Advance time for first retry (3s)
|
|
572
|
-
await vi.advanceTimersByTimeAsync(5_000);
|
|
573
|
-
|
|
574
|
-
const result = await promise;
|
|
575
|
-
|
|
576
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
577
|
-
expect(result.success).toBe(true);
|
|
578
|
-
|
|
579
|
-
vi.useRealTimers();
|
|
580
|
-
});
|
|
581
|
-
});
|
|
582
|
-
});
|