Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new resumable transfer library for Google APIs, implementing both synchronous (using requests) and asynchronous (using aiohttp) resumable upload sessions, supported by a sans-I/O protocol state machine and comprehensive tests. The feedback highlights several key areas for improvement: ensuring backward compatibility with Python 3.7/3.8 by replacing asyncio.to_thread with loop.run_in_executor, handling byte-type header keys in the state machine, rejecting unsupported str and dict stream types early, retrying timeouts globally in the synchronous session, and removing redundant deadline checks.
…load_state.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
…load_async.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
…load.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
daniel-sanche
left a comment
There was a problem hiding this comment.
I'm still digesting this, but giving a quick first round of comments
| data = self._buffered_chunk | ||
| data_len = len(data) | ||
|
|
||
| is_last = data_len < chunk_size |
There was a problem hiding this comment.
Gemini says there is a data-loss issue here. It says there are cases where data_len < chuck size without it being the last chunk, like if it retries after a partial upload. Can you verify that?
| if captured: | ||
| while captured: | ||
| yield captured.pop(0) | ||
|
|
There was a problem hiding this comment.
I find the scope of captured very confusing. The same reference is used as a class attribute, context manager, and local variable
I was very confused reading this methodf for the first time, because I wasn't sure how captured gets populated. But after a while, I realized it's another way of accessing of self._captured_progress. I think we should avoid that
| if computed_size is None: | ||
| computed_size = len(stream) | ||
| elif not hasattr(stream, "read") and isinstance(stream, Iterable): | ||
| stream_obj = io.BytesIO(b"".join(stream)) |
There was a problem hiding this comment.
This seems to load the whole stream into memory, right? I thought the purpose of using an iterator here would be to avoid that
| recovery_loop = google.api_core.retry.Retry( | ||
| predicate=lambda e: isinstance(e, _RecoveryRetransmit) | ||
| ) | ||
| return recovery_loop(do_transmit)() |
There was a problem hiding this comment.
All these nested retries make me nervous. Especially since some of these retries are managed by the user, and some are hard-coded. It looks like the request is set up like this:
iter_upload() / upload()
│
├── 1️⃣ [LAYER 1: Start Request Retry] (upload.py:L463)
│ initiate()
│ └── _get_retry(is_start=True)(do_initiate) <-- Retries initial POST /start
│
└── 5️⃣ [LAYER 5: Manual Chunk Loop] (upload.py:L658)
_transmit_all_chunks()
└── while not finished:
_transmit_chunk()
│
└── 4️⃣ [LAYER 4: Recovery Retransmit Loop] (upload.py:L579-L582)
recovery_loop = Retry(predicate=_RecoveryRetransmit)(do_transmit)
└── do_transmit()
│
├── 2️⃣ [LAYER 2: Chunk HTTP Retry] (upload.py:L534)
│ _get_retry()(do_http) <-- Retries POST chunk on 503/timeout
│
└── on 400/409/412 or missing header:
_recover()
│
└── 3️⃣ [LAYER 3: Query HTTP Retry] (upload.py:L609)
_get_retry()(do_query) <-- Retries POST query on 503
# Then raises synthetic exception:
raise _RecoveryRetransmit() <-- Bounces back to Layer 4!
Can we try to flatten this? This seems like a place to use StreamingRetry, instead of trying to wrap each request
| raise | ||
|
|
||
| await asyncio.sleep(delay) | ||
| delay = min(delay * multiplier, max_delay) |
There was a problem hiding this comment.
Why are you re-implementing async_retry here? Why not use the one in api_core?
| Raises: | ||
| Exception: Re-raises any exception encountered during the background transfer. | ||
| """ | ||
| while True: |
There was a problem hiding this comment.
this looks risky. If the _progress_queue isn't updated properly, this method will hang
Gemini points out this can happen if the task is ended with a BaseException, like asyncio.CancelledError. Those are never inserted into the queue
| retry: Optional[google.api_core.retry.Retry] = None | ||
| on_progress: Optional[Callable[[common.UploadProgress], None]] = None | ||
| response_type: Optional[Any] = None | ||
| content_type: Optional[str] = None |
There was a problem hiding this comment.
Do we have to store all of this on a shared config class?
It seems like some of these would be better off as method arguments (eg start_retry vs retry, on_progress, etc). They don't all apply equally to sync vs async, and initialize vs upload
| progress_queue.put_nowait(exc) | ||
| raise | ||
|
|
||
| task = asyncio.create_task(_run()) |
There was a problem hiding this comment.
This seems surprising to me. upload is a sync method, that starts a background task?
I'd expect upload to return the awaitable, and then the user can choose to await it directly, or create a background task
| stall_minimum_rate: Minimum transfer rate in bytes per second. Defaults to 64 KiB/s. | ||
| stall_timeout: Stall duration threshold in seconds. Defaults to 120s. | ||
| headers: Additional HTTP headers dispatched exclusively with start request. | ||
| deadline: Overall global deadline for the upload process. |
There was a problem hiding this comment.
It seems like the deadline isn't strictly enforced, right? We only check the deadline after a failure, but let healthy streams keep going?
We should make sure that's what we want, and then document it
| Attributes: | ||
| chunk_size: Size in bytes for each uploaded data chunk. Defaults to 10 MiB. | ||
| start_timeout: Local per-request timeout in seconds for start request. | ||
| start_retry: Custom retry policy for the start request. |
There was a problem hiding this comment.
I wonder if we actually want to expose the full retry object to end-users? Is it ok if they use custom predicates, or could that break stuff? (Gemini seems to think this could break some internal logic)
Maybe we should encapsulate some of the retry details? Or raise an exception if they change non-backoff fields? (Maybe we should make a simplified version of the retry config?)
| headers: Optional[Union[Mapping[str, str], Sequence[Tuple[str, str]]]] = None | ||
| deadline: Optional[datetime.datetime] = None | ||
| timeout: Optional[float] = None | ||
| retry: Optional[google.api_core.retry.Retry] = None |
There was a problem hiding this comment.
Is it fully safe to accept user-specified retry configs here? what if they choose different retryable errors than we expect?
| >= self._config.stall_timeout | ||
| ): | ||
| self._get_deadline_remaining() | ||
| raise exceptions.TransferStalledError( |
There was a problem hiding this comment.
Are you sure this is right? It looks like any stall will be raised as a terminal error, and not be retried. Is that part of the spec?
Towards b/457416314, b/556259599