Skip to content

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

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

parthea wants to merge 20 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

data = self._buffered_chunk
data_len = len(data)

is_last = data_len < chunk_size

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

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

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.

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

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.

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)

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

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.

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

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

@daniel-sanche daniel-sanche Sep 14, 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.

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.

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

@daniel-sanche daniel-sanche Sep 15, 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 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

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.

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(

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.

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?

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