Skip to content

Commit 0b0c728

Browse files
mishushakovclaude
andauthored
Fix volume SDK issues: transports, timeouts, empty files, eager stream errors (#1431)
Fixes a batch of review findings in the JS and Python volume SDKs, aligning behavior between the two. Python now caches `AsyncVolume` HTTP transports per event loop and proxy (sync per thread) instead of a process-wide singleton, applies the 60s default `request_timeout` to metadata operations that previously ran with httpx timeouts disabled, no longer falls back to `E2B_ACCESS_TOKEN` for volume content auth, and no longer mutates the caller's `headers` dict. JS `Volume.readFile` now returns empty values instead of `undefined` for empty files, and volume content requests get the documented 60s default request timeout. All changes are covered by new mock-based unit tests (no live API needed) plus changesets for both SDKs. ## Usage examples ```python volume = await AsyncVolume.connect(volume_id) # Times out after 60s by default (previously could hang indefinitely) entries = await volume.list("/") ``` ```ts const volume = await Volume.connect(volumeId) // Returns an empty Blob / ReadableStream for empty files (previously undefined) const blob = await volume.readFile('empty.txt', { format: 'blob' }) // Times out after the documented 60s by default; pass 0 to disable await volume.getInfo('file.txt', { requestTimeoutMs: 0 }) ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 91e84d9 commit 0b0c728

12 files changed

Lines changed: 466 additions & 28 deletions

File tree

.changeset/great-pears-search.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@e2b/python-sdk": patch
3+
---
4+
5+
Fix several issues in the volume content API client:
6+
7+
- `AsyncVolume` HTTP transports are now cached per event loop (and per proxy) instead of a process-wide singleton, and sync transports are cached per thread.
8+
- Volume metadata operations (`list`, `make_dir`, `get_info`, `update_metadata`, `remove`) now respect `request_timeout` (60s by default) instead of running with httpx timeouts disabled.
9+
- Volume content auth no longer falls back to the `E2B_ACCESS_TOKEN` environment variable; it requires the volume token, matching the JS SDK.
10+
- `VolumeConnectionConfig` no longer mutates the caller's `headers` dict.

.changeset/heavy-pots-repair.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"e2b": patch
3+
---
4+
5+
Fix `Volume.readFile` returning `undefined` instead of an empty `Blob`/`ReadableStream` for empty files, and apply the documented 60s default request timeout to volume content requests.

packages/js-sdk/src/volume/client.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { buildRequestSignal } from '../connectionConfig'
77
import { createApiLogger, Logger } from '../logs'
88
import type { Volume } from './index'
99

10+
const REQUEST_TIMEOUT_MS = 60_000 // 60 seconds
1011
const FILE_TIMEOUT_MS = 3_600_000 // 1 hour
1112

1213
export interface VolumeApiOpts {
@@ -72,7 +73,7 @@ export class VolumeConnectionConfig {
7273
readonly token?: string
7374
readonly headers?: Record<string, string>
7475
readonly logger?: Logger
75-
readonly requestTimeoutMs?: number
76+
readonly requestTimeoutMs: number
7677
readonly signal?: AbortSignal
7778
readonly proxy?: string
7879

@@ -86,7 +87,7 @@ export class VolumeConnectionConfig {
8687
this.token = opts?.token || volume.token
8788
this.headers = opts?.headers
8889
this.logger = opts?.logger
89-
this.requestTimeoutMs = opts?.requestTimeoutMs
90+
this.requestTimeoutMs = opts?.requestTimeoutMs ?? REQUEST_TIMEOUT_MS
9091
this.signal = opts?.signal
9192
this.proxy = opts?.proxy || volume.proxy
9293
}

packages/js-sdk/src/volume/index.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -561,19 +561,22 @@ export class Volume {
561561
throw err
562562
}
563563

564+
// When the file is empty, `res.data` is `undefined`, so empty values are synthesized below.
564565
if (format === 'bytes') {
565-
return new Uint8Array(res.data as ArrayBuffer)
566+
return res.data instanceof ArrayBuffer
567+
? new Uint8Array(res.data)
568+
: new Uint8Array()
566569
}
567570

568571
if (format === 'text') {
569-
// When the file is empty, res.data is parsed as `{}`. This is a workaround to return an empty string.
570-
if (res.response.headers.get('content-length') === '0') {
571-
return ''
572-
}
573572
return typeof res.data === 'string' ? res.data : ''
574573
}
575574

576-
return res.data
575+
if (format === 'blob') {
576+
return res.data instanceof Blob ? res.data : new Blob([])
577+
}
578+
579+
return res.data instanceof ReadableStream ? res.data : new Blob([]).stream()
577580
}
578581

579582
/**

packages/js-sdk/tests/volume/volume.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ const volumes = new Map<
1313
{ volumeID: string; name: string; token: string }
1414
>()
1515

16+
// In-memory store for mock volume file contents, keyed by path
17+
const volumeFiles = new Map<string, string>()
18+
1619
const restHandlers = [
1720
// POST /volumes - create (returns VolumeAndToken)
1821
http.post(apiUrl('/volumes'), async ({ request }) => {
@@ -58,6 +61,22 @@ const restHandlers = [
5861
return new HttpResponse(null, { status: 204 })
5962
}
6063
),
64+
65+
// GET /volumecontent/:volumeID/file - read file content
66+
http.get(apiUrl('/volumecontent/:volumeID/file'), ({ request }) => {
67+
const path = new URL(request.url).searchParams.get('path') ?? ''
68+
const content = volumeFiles.get(path)
69+
if (content === undefined) {
70+
return HttpResponse.json(
71+
{ code: 404, message: 'Not found' },
72+
{ status: 404 }
73+
)
74+
}
75+
return new HttpResponse(content.length > 0 ? content : null, {
76+
status: 200,
77+
headers: { 'Content-Length': String(content.length) },
78+
})
79+
}),
6180
]
6281

6382
const server = setupServer(...restHandlers)
@@ -67,6 +86,7 @@ afterAll(() => server.close())
6786
afterEach(() => {
6887
server.resetHandlers()
6988
volumes.clear()
89+
volumeFiles.clear()
7090
})
7191

7292
describe('Volume CRUD', () => {
@@ -187,3 +207,84 @@ describe('Volume CRUD', () => {
187207
expect(listAfter).toHaveLength(0)
188208
})
189209
})
210+
211+
describe('Volume content readFile', () => {
212+
it('should return content for a non-empty file in every format', async () => {
213+
volumeFiles.set('hello.txt', 'hello world')
214+
const vol = await Volume.create('content-volume')
215+
216+
const text = await vol.readFile('hello.txt')
217+
expect(text).toBe('hello world')
218+
219+
const bytes = await vol.readFile('hello.txt', { format: 'bytes' })
220+
expect(bytes).toBeInstanceOf(Uint8Array)
221+
expect(new TextDecoder().decode(bytes)).toBe('hello world')
222+
223+
const blob = await vol.readFile('hello.txt', { format: 'blob' })
224+
expect(blob).toBeInstanceOf(Blob)
225+
expect(await blob.text()).toBe('hello world')
226+
227+
const stream = await vol.readFile('hello.txt', { format: 'stream' })
228+
expect(stream).toBeInstanceOf(ReadableStream)
229+
expect(await new Response(stream).text()).toBe('hello world')
230+
})
231+
232+
it('should return empty values for an empty file in every format', async () => {
233+
volumeFiles.set('empty.txt', '')
234+
const vol = await Volume.create('content-volume')
235+
236+
const text = await vol.readFile('empty.txt')
237+
expect(text).toBe('')
238+
239+
const bytes = await vol.readFile('empty.txt', { format: 'bytes' })
240+
expect(bytes).toBeInstanceOf(Uint8Array)
241+
expect(bytes.length).toBe(0)
242+
243+
const blob = await vol.readFile('empty.txt', { format: 'blob' })
244+
expect(blob).toBeInstanceOf(Blob)
245+
expect(blob.size).toBe(0)
246+
247+
const stream = await vol.readFile('empty.txt', { format: 'stream' })
248+
expect(stream).toBeInstanceOf(ReadableStream)
249+
expect(await new Response(stream).text()).toBe('')
250+
})
251+
252+
it('should throw NotFoundError for a missing file', async () => {
253+
const vol = await Volume.create('content-volume')
254+
255+
await expect(vol.readFile('missing.txt')).rejects.toThrow(NotFoundError)
256+
})
257+
258+
it('should reject at call time for a missing file with stream format', async () => {
259+
const vol = await Volume.create('content-volume')
260+
261+
await expect(
262+
vol.readFile('missing.txt', { format: 'stream' })
263+
).rejects.toThrow(NotFoundError)
264+
})
265+
})
266+
267+
describe('VolumeConnectionConfig request timeout', () => {
268+
it('should default the request timeout to 60 seconds', async () => {
269+
const vol = await Volume.create('timeout-volume')
270+
271+
const config = new VolumeConnectionConfig(vol)
272+
expect(config.requestTimeoutMs).toBe(60_000)
273+
expect(config.getSignal()).toBeInstanceOf(AbortSignal)
274+
})
275+
276+
it('should let a per-call timeout override the default', async () => {
277+
const vol = await Volume.create('timeout-volume')
278+
279+
const config = new VolumeConnectionConfig(vol, { requestTimeoutMs: 1_000 })
280+
expect(config.requestTimeoutMs).toBe(1_000)
281+
})
282+
283+
it('should disable the timeout when requestTimeoutMs is 0', async () => {
284+
const vol = await Volume.create('timeout-volume')
285+
286+
const config = new VolumeConnectionConfig(vol, { requestTimeoutMs: 0 })
287+
expect(config.requestTimeoutMs).toBe(0)
288+
expect(config.getSignal()).toBeUndefined()
289+
})
290+
})

packages/python-sdk/e2b/volume/client_async/__init__.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import asyncio
12
import logging
23
import os
3-
from typing import Optional
4+
from typing import Dict, Optional, Tuple
45

56
import httpx
67
from httpx import Limits
8+
from httpx._types import ProxyTypes
79

810
from e2b.api.metadata import default_headers
911
from e2b.exceptions import AuthenticationException
@@ -18,32 +20,40 @@
1820
keepalive_expiry=int(os.getenv("E2B_KEEPALIVE_EXPIRY", "300")),
1921
)
2022

23+
TransportKey = Tuple[int, Optional[ProxyTypes]]
24+
2125

2226
def get_api_client(config: VolumeConnectionConfig, **kwargs) -> AsyncVolumeApiClient:
2327
if config.access_token is None:
2428
raise AuthenticationException(
25-
"Access token is required for volume operations. "
26-
"Set `E2B_ACCESS_TOKEN` or pass `token` in options.",
29+
"Volume token is required for volume content operations. "
30+
"Use `AsyncVolume.create`/`AsyncVolume.connect` to obtain it "
31+
"or pass `token` in options.",
2732
)
2833

2934
headers = {
3035
**default_headers,
3136
**(config.headers or {}),
3237
}
3338

39+
request_timeout = config.request_timeout
40+
3441
return AsyncVolumeApiClient(
3542
base_url=config.api_url,
3643
token=config.access_token,
3744
auth_header_name="Authorization",
3845
prefix="Bearer",
3946
headers=headers,
47+
timeout=(
48+
httpx.Timeout(request_timeout) if request_timeout is not None else None
49+
),
4050
httpx_args={"proxy": config.proxy, "transport": get_transport(config)},
4151
**kwargs,
4252
)
4353

4454

4555
class AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
46-
singleton: Optional["AsyncTransportWithLogger"] = None
56+
_instances: Dict[TransportKey, "AsyncTransportWithLogger"] = {}
4757

4858
async def handle_async_request(self, request):
4959
url = f"{request.url.scheme}://{request.url.host}{request.url.path}"
@@ -58,12 +68,14 @@ def pool(self):
5868

5969

6070
def get_transport(config: VolumeConnectionConfig) -> AsyncTransportWithLogger:
61-
if AsyncTransportWithLogger.singleton is not None:
62-
return AsyncTransportWithLogger.singleton
71+
key: TransportKey = (id(asyncio.get_running_loop()), config.proxy)
72+
73+
if key in AsyncTransportWithLogger._instances:
74+
return AsyncTransportWithLogger._instances[key]
6375

6476
transport = AsyncTransportWithLogger(
6577
limits=limits,
6678
proxy=config.proxy,
6779
)
68-
AsyncTransportWithLogger.singleton = transport
80+
AsyncTransportWithLogger._instances[key] = transport
6981
return transport

packages/python-sdk/e2b/volume/client_sync/__init__.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import logging
22
import os
3-
from typing import Optional
3+
import threading
4+
from typing import Dict, Optional
45

56
import httpx
67
from httpx import Limits
8+
from httpx._types import ProxyTypes
79

810
from e2b.api.metadata import default_headers
911
from e2b.exceptions import AuthenticationException
@@ -18,32 +20,40 @@
1820
keepalive_expiry=int(os.getenv("E2B_KEEPALIVE_EXPIRY", "300")),
1921
)
2022

23+
TransportKey = Optional[ProxyTypes]
24+
2125

2226
def get_api_client(config: VolumeConnectionConfig, **kwargs) -> VolumeApiClient:
2327
if config.access_token is None:
2428
raise AuthenticationException(
25-
"Access token is required for volume operations. "
26-
"Set `E2B_ACCESS_TOKEN` or pass `token` in options.",
29+
"Volume token is required for volume content operations. "
30+
"Use `Volume.create`/`Volume.connect` to obtain it "
31+
"or pass `token` in options.",
2732
)
2833

2934
headers = {
3035
**default_headers,
3136
**(config.headers or {}),
3237
}
3338

39+
request_timeout = config.request_timeout
40+
3441
return VolumeApiClient(
3542
base_url=config.api_url,
3643
token=config.access_token,
3744
auth_header_name="Authorization",
3845
prefix="Bearer",
3946
headers=headers,
47+
timeout=(
48+
httpx.Timeout(request_timeout) if request_timeout is not None else None
49+
),
4050
httpx_args={"proxy": config.proxy, "transport": get_transport(config)},
4151
**kwargs,
4252
)
4353

4454

4555
class TransportWithLogger(httpx.HTTPTransport):
46-
singleton: Optional["TransportWithLogger"] = None
56+
_thread_local = threading.local()
4757

4858
def handle_request(self, request):
4959
url = f"{request.url.scheme}://{request.url.host}{request.url.path}"
@@ -58,12 +68,18 @@ def pool(self):
5868

5969

6070
def get_transport(config: VolumeConnectionConfig) -> TransportWithLogger:
61-
if TransportWithLogger.singleton is not None:
62-
return TransportWithLogger.singleton
71+
instances: Dict[TransportKey, TransportWithLogger] = getattr(
72+
TransportWithLogger._thread_local, "instances", {}
73+
)
74+
key: TransportKey = config.proxy
75+
cached = instances.get(key)
76+
if cached is not None:
77+
return cached
6378

6479
transport = TransportWithLogger(
6580
limits=limits,
6681
proxy=config.proxy,
6782
)
68-
TransportWithLogger.singleton = transport
83+
instances[key] = transport
84+
TransportWithLogger._thread_local.instances = instances
6985
return transport

packages/python-sdk/e2b/volume/connection_config.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,6 @@ def _debug():
5757
def _volume_api_url():
5858
return os.getenv("E2B_VOLUME_API_URL")
5959

60-
@staticmethod
61-
def _access_token():
62-
return os.getenv("E2B_ACCESS_TOKEN")
63-
6460
@staticmethod
6561
def _get_request_timeout(
6662
default_timeout: Optional[float],
@@ -91,11 +87,11 @@ def __init__(
9187
or self._volume_api_url()
9288
or ("http://localhost:8080" if self.debug else f"https://api.{self.domain}")
9389
)
94-
self.access_token = token or self._access_token()
90+
self.access_token = token
9591
self.token = self.access_token
9692
self.proxy = proxy
9793

98-
self.headers = headers or {}
94+
self.headers = dict(headers) if headers else {}
9995
self.headers["User-Agent"] = f"e2b-python-sdk/{package_version}"
10096

10197
self.request_timeout = self._get_request_timeout(

0 commit comments

Comments
 (0)