Skip to content

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

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

parthea wants to merge 59 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.py Outdated
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

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.

upload() returns an awaitable AsyncUploadOperation so callers can either await session.upload(stream) directly or iterate async for p in op.progress(): before awaiting the final response.

Deferring task creation so execution starts lazily on await or .progress() is a forward-compatible change since the public API and calling patterns stay identical. Happy to follow up on that in a separate PR but I can come back to this if you have a strong opinion about it.

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's a forward-compatible change if we assume the user is calling await session.upload(). But if they call session.upload() and write code that assumes it is running in the background, that could lead to problems if changed later

It makes me a bit nervous because it is non standard, and could lead to unexpected consequences. And managing background thread lifecycles is always difficult. I'd prefer a simpler approach if possible. But I think this can work if needed

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
@parthea
parthea requested a review from a team as a code owner September 16, 2026 17:15

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

It's definitely looking closer, but I'm still seeing some issues. Try to make sure the test cases I shared are incorporated (or something like them)

I'm mostly concerned about the public API, and data loss issues. But I left comments on other things as well

content_type: Optional[str] = None,
response_type: Optional[Any] = None,
start_retry: Optional[google.api_core.retry.Retry] = None,
start_timeout: Optional[float] = 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.

It seems surprising to me that we expose start_retry and start_timeout, here, but then also allow overriding it on as an argument to initate

Is that needed? Could we just keep this on initiate?

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! initiate isn't called directly by users. We can remove the arguments to initiate which are considered a duplicate. We need to keep start_retry and start_timeout here otherwise callers using session.upload() would have no way to configure retry or timeout for the initial start request.

Done in 8322a1e

There is a section under Setting Scotty upload configuration in the requirements doc that mentions the need for configuring a retry policy for the initial start command, as well as the chunk upload.

exceptions.UploadCancelledError,
exceptions.UnseekableStreamError,
),
):

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: Maybe these should be in a constant list called TERMINAL_ERRORS? It's not clear from reading the code why some errors are handled different from others

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 point. Fixed in 546f206

and exc.code in common.RECOVERABLE_STATUS_CODES
)
):
return 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.

Are you sure this should be above the user-supplied predicate? Would they want a say in whether to retry these? What about the ConnectionErrors below?

(Personally, I'm not convinved it's a good idea to allow a user-supplied predicate. Users may try to use this as both a whitelist or a blacklist, and it's not always clear how to compose their predicate with the system one. If it's not part of the requirements, I'd say we should just warn that custom predicates aren't supported for now)

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.

The requirements specify maintaining a similar UX to existing generated methods. To avoid adding technical debt, and causing confusion for users who would like to set custom predicates, custom predicates could remain supported for Category 1 transient errors only. I'll update the doc string to capture the limitation.

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 c6289da

errors. Terminal errors (``DeadlineExceeded``, ``TransferStalledError``,
``UploadCancelledError``, and ``UnseekableStreamError``) are never
retried.
start_timeout: Optional timeout in seconds for the start request.

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.

For some of these arguments, it looks like None means "use default". Can we calrify that in the docstrings?

(I also wonder if it would be cleaner to have default values/sentinels here in the signature, instead of replacing None values with defaults later. _DEFAULT_START_TIMEOUT, for instance)

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 c17a7d5. I hardcoded the timeout value in the docstring to avoid exposing the internal constant _DEFAULT_START_TIMEOUT.

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.

I'm a bit confused about initiate. Do we expect users to call it before upload, or should it be internal?

Will calling initiate on an already-initiated sstream create a new url?

Would keeping these two stages separate be simpler here?

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.

Do we expect users to call it before upload, or should it be internal?

Good point. I'll move it to internal since we don't expect users to call it

Will calling initiate on an already-initiated sstream create a new url?

Yes, calling initiate() again sends another X-Goog-Upload-Command: start request and overwrites self._state._resumable_url with a new session URL. We won't call it twice

Would keeping these two stages separate be simpler here?

Generated client methods return an unstarted ResumableUploadSession so callers can either start a new transfer via session.upload() or resume an interrupted transfer via session.resume() on that same session object without issuing an extra start request. Renaming initiate() to _initiate() will make it clear that we don't expect users to call it.

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 4dd0b3e


_, _, body_bytes = final_resp_tuple
self._response = self._format_response(body_bytes)
return self._response

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.

The type annotations say this will be a ResponseProto, but _format_response will actually return bytes by default here (which is different from the sync implementation)

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! Fixed in 61853c9

I updated _format_response_payload so both sync and async return bytes when response_type is None

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

Gemini pointed out that requests.exceptions.Timeout is raised as soon as per_attempt_timeout is hit, which is defined as max(5.0, min(next_chunk_timeout, 2.0 * expected_sec)). That can be as low as 5 seconds, so it seems like it can exit much sooner than stall_timeout (120 seconds).

progress_queue.put_nowait(exc)
raise

task = asyncio.create_task(_run())

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's a forward-compatible change if we assume the user is calling await session.upload(). But if they call session.upload() and write code that assumes it is running in the background, that could lead to problems if changed later

It makes me a bit nervous because it is non standard, and could lead to unexpected consequences. And managing background thread lifecycles is always difficult. I'd prefer a simpler approach if possible. But I think this can work if needed

"""Awaits completion of the upload task and returns the server response."""
return self._task.__await__()

async def progress(self) -> AsyncIterator[common.UploadProgress]:

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 see both the sync and async classes have multiple ways to track progress, which leads to some degree of complexity. Personally, I think we should stick with one or the other, and keep things simpler

It looks like we could simplify a lot of code if we dropped this progress method, since we wouldn't need to manage the background task/progress queue. Or if you did want to keep a progress generator, it seems like th ecode would be simpler to expose it as iter_progress like the sync version.

I'm fine with keeping both methods if you want, it just adds more complexity than we probably need here

A callable accepting an exception and returning a boolean.
"""

def should_retry(exc: Exception) -> bool:

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.

Should asyncio.TimeoutError be included here?

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