Skip to content

Commit acf82b9

Browse files
committed
Retrying requestRaw and chunked sandbox uploads
1 parent 88f3164 commit acf82b9

3 files changed

Lines changed: 203 additions & 29 deletions

File tree

packages/sdk-ts/src/http.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,4 +561,101 @@ describe('HttpClient', () => {
561561
}
562562
})
563563
})
564+
565+
describe('requestRaw', () => {
566+
test('returns Response directly on success', async () => {
567+
globalThis.fetch = mock(async () =>
568+
new Response('file contents', { status: 200 }),
569+
) as unknown as typeof fetch
570+
571+
const client = createClient()
572+
const response = await client.requestRaw({ method: 'GET', path: '/v1/files' })
573+
expect(response.status).toBe(200)
574+
expect(await response.text()).toBe('file contents')
575+
})
576+
577+
test('retries on 500 errors', async () => {
578+
let calls = 0
579+
globalThis.fetch = mock(async () => {
580+
calls++
581+
if (calls === 1) {
582+
return new Response('internal error', { status: 500 })
583+
}
584+
return new Response('ok', { status: 200 })
585+
}) as unknown as typeof fetch
586+
587+
const client = createClient({ retries: 1 })
588+
const response = await client.requestRaw({ method: 'PUT', path: '/v1/files' })
589+
expect(response.status).toBe(200)
590+
expect(calls).toBe(2)
591+
})
592+
593+
test('retries on network errors', async () => {
594+
let calls = 0
595+
globalThis.fetch = mock(async () => {
596+
calls++
597+
if (calls === 1) {
598+
throw new TypeError('fetch failed')
599+
}
600+
return new Response('ok', { status: 200 })
601+
}) as unknown as typeof fetch
602+
603+
const client = createClient({ retries: 1 })
604+
const response = await client.requestRaw({ method: 'PUT', path: '/v1/files' })
605+
expect(response.status).toBe(200)
606+
expect(calls).toBe(2)
607+
})
608+
609+
test('throws after exhausting retries on 500', async () => {
610+
globalThis.fetch = mock(async () =>
611+
new Response(JSON.stringify(errorBody('internal_error', 'Server error')), {
612+
status: 500,
613+
headers: { 'Content-Type': 'application/json' },
614+
}),
615+
) as unknown as typeof fetch
616+
617+
const client = createClient({ retries: 1 })
618+
try {
619+
await client.requestRaw({ method: 'PUT', path: '/v1/files' })
620+
expect.unreachable('should have thrown')
621+
} catch (err) {
622+
expect(err).toBeInstanceOf(SandchestError)
623+
expect((err as SandchestError).status).toBe(500)
624+
}
625+
})
626+
627+
test('does not retry on 4xx client errors', async () => {
628+
let calls = 0
629+
globalThis.fetch = mock(async () => {
630+
calls++
631+
return new Response(
632+
JSON.stringify(errorBody('not_found', 'Not found', 'req_1')),
633+
{ status: 404, headers: { 'Content-Type': 'application/json' } },
634+
)
635+
}) as unknown as typeof fetch
636+
637+
const client = createClient({ retries: 2 })
638+
try {
639+
await client.requestRaw({ method: 'GET', path: '/v1/files' })
640+
expect.unreachable('should have thrown')
641+
} catch (err) {
642+
expect(err).toBeInstanceOf(NotFoundError)
643+
expect(calls).toBe(1)
644+
}
645+
})
646+
647+
test('wraps network errors into ConnectionError after exhausting retries', async () => {
648+
globalThis.fetch = mock(async () => {
649+
throw new TypeError('fetch failed')
650+
}) as unknown as typeof fetch
651+
652+
const client = createClient({ retries: 1 })
653+
try {
654+
await client.requestRaw({ method: 'PUT', path: '/v1/files' })
655+
expect.unreachable('should have thrown')
656+
} catch (err) {
657+
expect(err).toBeInstanceOf(ConnectionError)
658+
}
659+
})
660+
})
564661
})

packages/sdk-ts/src/http.ts

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -187,32 +187,60 @@ export class HttpClient {
187187
}
188188

189189
const timeoutMs = options.timeout ?? this.timeout
190-
const controller = new AbortController()
191-
const timer = setTimeout(() => controller.abort(), timeoutMs)
192-
193-
try {
194-
const response = await fetch(url, {
195-
method: options.method,
196-
headers,
197-
body: options.body,
198-
signal: controller.signal,
199-
})
190+
let lastError: Error | undefined
191+
192+
for (let attempt = 0; attempt <= this.retries; attempt++) {
193+
if (attempt > 0) {
194+
await sleep(backoffDelay(attempt - 1))
195+
}
196+
197+
const controller = new AbortController()
198+
const timer = setTimeout(() => controller.abort(), timeoutMs)
199+
200+
try {
201+
const response = await fetch(url, {
202+
method: options.method,
203+
headers,
204+
body: options.body,
205+
signal: controller.signal,
206+
})
207+
208+
clearTimeout(timer)
209+
210+
if (response.ok) {
211+
return response
212+
}
200213

201-
clearTimeout(timer)
214+
// Retry on server errors
215+
if (response.status >= 500 && attempt < this.retries) {
216+
await response.text().catch(() => {}) // drain body to free connection
217+
lastError = new SandchestError({
218+
code: 'internal_error',
219+
message: `HTTP ${response.status}`,
220+
status: response.status,
221+
requestId: response.headers.get('x-request-id') ?? '',
222+
})
223+
continue
224+
}
202225

203-
if (!response.ok) {
204226
const errorBody = (await response.json().catch(() => null)) as ApiErrorBody | null
205227
const requestId = errorBody?.request_id ?? response.headers.get('x-request-id') ?? ''
206228
const message = errorBody?.message ?? `HTTP ${response.status}`
207229
throw this.parseErrorResponse(response.status, message, requestId, errorBody)
208-
}
230+
} catch (error) {
231+
clearTimeout(timer)
232+
if (error instanceof SandchestError) throw error
233+
234+
if (attempt < this.retries) {
235+
lastError = error instanceof Error ? error : new Error(String(error))
236+
continue
237+
}
209238

210-
return response
211-
} catch (error) {
212-
clearTimeout(timer)
213-
if (error instanceof SandchestError) throw error
214-
throw this.wrapRawError(error, timeoutMs)
239+
throw this.wrapRawError(lastError ?? error, timeoutMs)
240+
}
215241
}
242+
243+
throw this.wrapRawError(lastError, timeoutMs)
216244
}
217245

218246
/** Wrap non-SDK errors into typed TimeoutError or ConnectionError. */

packages/sdk-ts/src/sandbox.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ const WAIT_READY_DEFAULT_TIMEOUT = 120_000
4040
const WAIT_READY_POLL_INTERVAL = 1_000
4141
const TEXT_ENCODER = new TextEncoder()
4242
const TEXT_DECODER = new TextDecoder()
43+
/** Uploads larger than this threshold are split into chunks to avoid body-size issues. */
44+
const UPLOAD_CHUNK_SIZE = 2 * 1024 * 1024 // 2 MB
4345
const VALIDATE_TAR_SCRIPT = `
4446
import posixpath
4547
import sys
@@ -201,14 +203,49 @@ export class Sandbox {
201203
await this.fs.upload(path, TEXT_ENCODER.encode(content))
202204
},
203205
uploadDir: async (path: string, tarball: Uint8Array): Promise<void> => {
204-
const tmpPath = `/tmp/.sandchest-upload-${crypto.randomUUID()}.tar.gz`
205-
await this._http.requestRaw({
206-
method: 'PUT',
207-
path: `/v1/sandboxes/${this.id}/files`,
208-
query: { path: tmpPath, batch: true },
209-
body: tarball,
210-
headers: { 'Content-Type': 'application/octet-stream' },
211-
})
206+
const uuid = crypto.randomUUID()
207+
const tmpPath = `/tmp/.sandchest-upload-${uuid}.tar.gz`
208+
const chunkPaths: string[] = []
209+
210+
// Upload the tarball — chunked for large files to avoid body-size issues
211+
if (tarball.byteLength <= UPLOAD_CHUNK_SIZE) {
212+
await this._http.requestRaw({
213+
method: 'PUT',
214+
path: `/v1/sandboxes/${this.id}/files`,
215+
query: { path: tmpPath, batch: true },
216+
body: tarball,
217+
headers: { 'Content-Type': 'application/octet-stream' },
218+
})
219+
} else {
220+
const totalChunks = Math.ceil(tarball.byteLength / UPLOAD_CHUNK_SIZE)
221+
for (let i = 0; i < totalChunks; i++) {
222+
const start = i * UPLOAD_CHUNK_SIZE
223+
const end = Math.min(start + UPLOAD_CHUNK_SIZE, tarball.byteLength)
224+
const chunkPath = `/tmp/.sandchest-chunk-${uuid}-${String(i).padStart(4, '0')}`
225+
chunkPaths.push(chunkPath)
226+
await this._http.requestRaw({
227+
method: 'PUT',
228+
path: `/v1/sandboxes/${this.id}/files`,
229+
query: { path: chunkPath },
230+
body: tarball.slice(start, end),
231+
headers: { 'Content-Type': 'application/octet-stream' },
232+
})
233+
}
234+
235+
// Concatenate chunks into the final tarball
236+
const catResult = await this._execSync(
237+
['sh', '-c', `cat /tmp/.sandchest-chunk-${uuid}-* > ${tmpPath}`],
238+
{ operation: 'uploadDir:concat' },
239+
)
240+
if (catResult.exit_code !== 0) {
241+
throw new ExecFailedError({
242+
operation: 'uploadDir:concat',
243+
exitCode: catResult.exit_code,
244+
stderr: catResult.stderr,
245+
})
246+
}
247+
}
248+
212249
try {
213250
const mkdirResult = await this._execSync(['mkdir', '-p', path], {
214251
operation: 'uploadDir:mkdir',
@@ -255,9 +292,12 @@ export class Sandbox {
255292
})
256293
}
257294
} finally {
258-
await this._execSync(['rm', '-f', tmpPath], {
259-
operation: 'uploadDir:cleanup',
260-
}).catch(() => {})
295+
// Clean up tarball and any chunk files
296+
const cleanupPaths = [tmpPath, ...chunkPaths]
297+
await this._execSync(
298+
['rm', '-f', ...cleanupPaths],
299+
{ operation: 'uploadDir:cleanup' },
300+
).catch(() => {})
261301
}
262302
},
263303
download: async (path: string): Promise<Uint8Array> => {
@@ -362,6 +402,15 @@ export class Sandbox {
362402

363403
cmd.push('--', validated.url, dest)
364404

405+
// Ensure the parent directory exists (sandbox root FS is read-only outside writable paths)
406+
const parentDir = dest.includes('/') ? dest.slice(0, dest.lastIndexOf('/')) : undefined
407+
if (parentDir && parentDir !== '') {
408+
await this._execSync(['mkdir', '-p', parentDir], {
409+
operation: 'git.clone:mkdir',
410+
timeout: 5,
411+
}).catch(() => {}) // Best-effort — parent may already exist
412+
}
413+
365414
const result = await this._execSync(cmd, {
366415
operation: 'git.clone',
367416
timeout: options?.timeout ?? 120,

0 commit comments

Comments
 (0)