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
daniel-sanche
left a comment
There was a problem hiding this comment.
Its looking really close, but a couple more comments
| config: Optional upload configuration parameters. Defaults to | ||
| ``ResumableUploadConfig()`` when ``None``. | ||
| resumable_url: Pre-existing upload session URL if resuming. When | ||
| ``None``, a new session is created during ``upload()``. |
There was a problem hiding this comment.
nit: what happens if both upload_url and resume_url are passed? Should we collapse these into a single argument?
| @property | ||
| def upload_url(self) -> Optional[str]: | ||
| """Optional[str]: The unique upload URL for this session.""" | ||
| return self._state.resumable_url |
There was a problem hiding this comment.
Maybe it's just me, but I find it confusing that the state holds both an upload_url and a resumable_url, but this upload_url proprety returns the resumable_url
There was a problem hiding this comment.
Agreed. Resolved in 2223340. resumable_url was removed.
| initiate server offset recovery. Terminal errors | ||
| (``DeadlineExceeded``, ``TransferStalledError``, | ||
| ``UploadCancelledError``, and ``UnseekableStreamError``) are never | ||
| retried. |
There was a problem hiding this comment.
Can the docstring mention that the custom predicate completely repplaced the default transient error logic?
Without reading the internals, I'd be wondering how this is composed with the default logic (i.e. AND/OR/replacement)
|
|
||
|
|
||
| class ProtocolState(object): | ||
| """Encapsulates the state and command formatting for Resumable Upload protocol.""" |
There was a problem hiding this comment.
Does this need to be public?
There was a problem hiding this comment.
No, it's not used directly in the generated code. I'll move it to internal
| UploadProgress snapshots for each progress transition. | ||
| """ | ||
| if not self._consumed: | ||
| self._consumed = True |
There was a problem hiding this comment.
It looks like there's a flaw here: The operation is marked as consumed as soon as the generator is started. But its possible for the user to yield a couple items from the generator, and then await the rest of the operation. That would lead to a broken state.
We should probably only mark this as consumed after the hitting the end of the stream (if I understand correctly)
| req_headers[common.HEADER_CONTENT_LENGTH] = str(size) | ||
|
|
||
| payload = body.encode("utf-8") if isinstance(body, str) else body | ||
| return "POST", self._initial_url, req_headers, payload |
There was a problem hiding this comment.
gemini points out a flaw here: This is always called, whether the upload was started with a upload_url, or resume_url. But this code assumes the upload_url is present, and doesn't take the resume_url into account
Maybe the idea is that upload() should never be called with a resume url? But the docstrings don't make that clear, and it doesn't seem like an error would be handled cleanly
I think we should be clearer about how these urls are handled, since we can't change the API in the future
There was a problem hiding this comment.
Good catch! I've pushed 2 fixes in e275716:
- Removed the separate
resumable_urlparameter so__init__only takesupload_urlwhich removes the confusion. - Added validation in
.build_start_request()to raiseValueError("upload_url must be provided to start an upload.")whenself._initial_urlis missing (matchingbuild_chunk_request(),build_query_request(), and
build_cancel_request()which already had this check).
| request_body=request_body, | ||
| size=computed_size, | ||
| progress_queue=progress_queue, | ||
| ) |
There was a problem hiding this comment.
Personally, I think a lot of complexity would go away if we kept initiate as a required, public call. We could keep all the arguments separate from other methods, and well scoped.
But if this is how other languages handle it, it should be fine
| return retry_override.with_predicate(wrapped_pred) | ||
| return google.api_core.retry.StreamingRetry( | ||
| predicate=self._get_retry_predicate(is_start=False) | ||
| ) |
There was a problem hiding this comment.
Doesn't this mean we inherit the default deadline of 120 seconds?
There was a problem hiding this comment.
Great catch! For uploads taking longer than 120 seconds, any transient error after the 120-second mark would immediately hit RetryError instead of recovering.
I updated _get_streaming_retry in upload.py and _get_async_streaming_retry in upload_async.py to set timeout=None on the default StreamingRetry / AsyncStreamingRetry instances so that overall transfer deadlines and stall detection are controlled by ResumableUploadConfig.deadline and stall_timeout / stall_minimum_rate (along with per-attempt chunk timeouts).
Fixed in b49b94c
| Raises: | ||
| ValueError: If required arguments are missing or response not received. | ||
| GoogleAPICallError: If an unrecoverable API error occurs. | ||
| """ |
There was a problem hiding this comment.
Do we need to clear self._buffered_chunk or any other state data? (_stall_timeout_started? _aggregate_lag)
| 1.0, | ||
| expected_sec - self._aggregate_lag + self._config.stall_timeout, | ||
| ) | ||
| per_attempt_timeout = max(5.0, min(next_chunk_timeout, 2.0 * expected_sec)) |
There was a problem hiding this comment.
Why is this being clamped to 5 seconds? Doesn't this kind of remove the benefit of timeout_override, if it can only be 5 seconds max?
|
|
||
| if self._aggregate_lag > 0.0: | ||
| if self._stall_timeout_started is None: | ||
| self._stall_timeout_started = t_start |
There was a problem hiding this comment.
I added this as a child comment in a different thread, but to make sure it's not lost: shouldn't we only count current_lag as stall time? This seems to treat the entire operation as time spent stalled, even if it's just slightly over
There was a problem hiding this comment.
This was a bug. I responded in #18352 (comment) and fixed the issue in e29f1cb
Towards b/457416314, b/556259599