Skip to content

feat(google-api-core): add support for resumable uploads - #18352

Open
parthea wants to merge 73 commits into
mainfrom
feat/resumable-transfer-api-core
Open

parthea wants to merge 73 commits into
mainfrom
feat/resumable-transfer-api-core

Conversation

@parthea

@parthea parthea commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Towards b/457416314, b/556259599

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload_async.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload_state.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload_async.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload_async.py Outdated
@parthea parthea changed the title [DRAFT] feat: add support for resumable uploads [DRAFT] feat(google-api-core): add support for resumable uploads Sep 11, 2026
parthea and others added 9 commits September 11, 2026 21:58
…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>
@parthea parthea changed the title [DRAFT] feat(google-api-core): add support for resumable uploads feat(google-api-core): add support for resumable uploads Sep 14, 2026
@parthea
parthea marked this pull request as ready for review September 14, 2026 19:39
@parthea
parthea requested a review from a team as a code owner September 14, 2026 19:39

@daniel-sanche daniel-sanche left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still digesting this, but giving a quick first round of comments

Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload_async.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload_async.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload_async.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload.py Outdated
Comment thread packages/google-api-core/google/api_core/resumable_transfer/upload.py Outdated

@daniel-sanche daniel-sanche left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()``.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: what happens if both upload_url and resume_url are passed? Should we collapse these into a single argument?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 2223340.

@property
def upload_url(self) -> Optional[str]:
"""Optional[str]: The unique upload URL for this session."""
return self._state.resumable_url

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Resolved in 2223340. resumable_url was removed.

initiate server offset recovery. Terminal errors
(``DeadlineExceeded``, ``TransferStalledError``,
``UploadCancelledError``, and ``UnseekableStreamError``) are never
retried.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e85aa4b



class ProtocolState(object):
"""Encapsulates the state and command formatting for Resumable Upload protocol."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be public?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it's not used directly in the generated code. I'll move it to internal

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 2736d95

UploadProgress snapshots for each progress transition.
"""
if not self._consumed:
self._consumed = True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed 9f0c894

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

@daniel-sanche daniel-sanche Sep 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I've pushed 2 fixes in e275716:

  1. Removed the separate resumable_url parameter so __init__ only takes upload_url which removes the confusion.
  2. Added validation in .build_start_request() to raise ValueError("upload_url must be provided to start an upload.") when self._initial_url is missing (matching build_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,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this mean we inherit the default deadline of 120 seconds?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to clear self._buffered_chunk or any other state data? (_stall_timeout_started? _aggregate_lag)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 369a790

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@daniel-sanche daniel-sanche Sep 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bug. I responded in #18352 (comment) and fixed the issue in e29f1cb

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants