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