@@ -466,9 +466,13 @@ async def read_file(
466466
467467 :param path: Path to the file
468468 :param format: Format of the file content—`text` by default
469- :param stream_idle_timeout: Deprecated and ignored. A stalled streamed
470- read is bounded by a transport-wide idle read timeout instead
471- (60 seconds), which resets on every chunk.
469+ :param stream_idle_timeout: Idle timeout in **seconds** for a streamed
470+ read (`format="stream"`)—abort with `httpx.ReadTimeout` if the
471+ response head or the next chunk doesn't arrive within this
472+ window. Resets on every chunk, so it bounds a stalled stream
473+ without limiting total transfer time. Defaults to the
474+ transport-wide idle read timeout (60 seconds); pass `0` to
475+ disable.
472476 :param opts: Connection options
473477
474478 :return: File content as string, bytes, or async iterator of bytes
@@ -486,27 +490,38 @@ async def read_file(
486490 # whole-request deadline that would kill long downloads, so a
487491 # streamed read is sent with one only when the caller set
488492 # `request_timeout` explicitly (making it the total-transfer
489- # deadline). A stalled stream is instead bounded by the
490- # transport-wide idle read timeout (see `get_transport`), which
491- # resets on every chunk without limiting total transfer time.
493+ # deadline).
492494 stream_timeout = VolumeConnectionConfig ._get_request_timeout (
493495 None , opts .get ("request_timeout" )
494496 )
495- # The streaming transport carries the idle read timeout; the
496- # regular one must not (it would cut off slow uploads and
497- # responses), so streamed reads get their own client.
498- stream_client = get_volume_api_client (config , for_streaming = True )
497+ # By default a stalled stream is bounded by the streaming
498+ # transport's idle read timeout (see `get_transport`). An
499+ # explicit `stream_idle_timeout` is applied per read with
500+ # `wait_for` instead, on the regular transport — so values above
501+ # the transport bound aren't capped by it and `0` disables idle
502+ # bounding entirely. (The sync client can't interrupt a blocking
503+ # read, so only the async flavor honors the per-call value.)
504+ stream_client = get_volume_api_client (
505+ config , for_streaming = stream_idle_timeout is None
506+ )
507+
508+ async def read_bounded (awaitable ):
509+ if not stream_idle_timeout :
510+ return await awaitable
511+ return await asyncio .wait_for (awaitable , stream_idle_timeout )
499512
500513 async def stream_file () -> AsyncIterator [bytes ]:
501514 # pyqwest raises the builtin TimeoutError; keep the httpx
502515 # exception the streamed-read contract established.
503516 try :
504- async with stream_client .get_async_httpx_client ().stream (
517+ stream_cm = stream_client .get_async_httpx_client ().stream (
505518 method = "GET" ,
506519 url = f"/volumecontent/{ self ._volume_id } /file" ,
507520 params = params ,
508521 timeout = stream_timeout ,
509- ) as response :
522+ )
523+ response = await read_bounded (stream_cm .__aenter__ ())
524+ try :
510525 if response .status_code == 404 :
511526 raise NotFoundException (f"Path { path } not found" )
512527
@@ -519,10 +534,17 @@ async def stream_file() -> AsyncIterator[bytes]:
519534 )
520535 raise handle_api_exception (api_response , VolumeException )
521536
522- async for chunk in response .aiter_bytes ():
537+ chunks = response .aiter_bytes ()
538+ while True :
539+ try :
540+ chunk = await read_bounded (chunks .__anext__ ())
541+ except StopAsyncIteration :
542+ break
523543 yield chunk
544+ finally :
545+ await stream_cm .__aexit__ (None , None , None )
524546 # asyncio.TimeoutError is distinct from the builtin until 3.11,
525- # and the adapter's whole-request deadline raises it.
547+ # and wait_for and the adapter's whole-request deadline raise it.
526548 except (TimeoutError , asyncio .TimeoutError ) as e :
527549 raise httpx .ReadTimeout (str (e )) from e
528550
0 commit comments