Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7492d88
[DRAFT] feat: add support for resumable uploads
parthea Sep 11, 2026
326ae78
lint
parthea Sep 11, 2026
9936e0d
lint
parthea Sep 11, 2026
9e689b8
mypy
parthea Sep 14, 2026
9c49a81
lint
parthea Sep 14, 2026
b0de580
address review feedback
parthea Sep 14, 2026
ae11c27
address review feedback
parthea Sep 14, 2026
f57c598
Update packages/google-api-core/google/api_core/resumable_transfer/up…
parthea Sep 14, 2026
efbc623
Update packages/google-api-core/google/api_core/resumable_transfer/up…
parthea Sep 14, 2026
41a3759
Update packages/google-api-core/google/api_core/resumable_transfer/up…
parthea Sep 14, 2026
405396e
lint
parthea Sep 14, 2026
5409b00
additional headers->headers
parthea Sep 14, 2026
0e0ad52
lint
parthea Sep 14, 2026
3936458
cover
parthea Sep 14, 2026
8c9c900
cover
parthea Sep 14, 2026
96f3114
cover
parthea Sep 14, 2026
59de7db
lint
parthea Sep 14, 2026
e13ff31
cover
parthea Sep 14, 2026
c9fbb46
lint
parthea Sep 14, 2026
5bd4bec
mypy
parthea Sep 14, 2026
1e12de7
cover
parthea Sep 15, 2026
fd49ae5
lint
parthea Sep 15, 2026
08f97ac
mypy
parthea Sep 15, 2026
09aff46
add regression test for missing data
parthea Sep 15, 2026
a51f058
add comments
parthea Sep 15, 2026
f783a6f
resolve issue where data may be lost on partial commit recovery
parthea Sep 15, 2026
dd4167a
address review feedback
parthea Sep 15, 2026
db1b980
cover
parthea Sep 15, 2026
8fa74b3
address review feedback
parthea Sep 15, 2026
ae8a644
Address review feedback
parthea Sep 16, 2026
5f56962
address review feedback
parthea Sep 16, 2026
73847dd
fix build
parthea Sep 16, 2026
62fabec
address review feedback
parthea Sep 16, 2026
2260a2a
Merge remote-tracking branch 'origin/main' into feat/resumable-transf…
parthea Sep 16, 2026
fc3797e
add entry for google-cloud-spanner-dbapi-driver to satisfy regenerati…
parthea Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions librarian.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2000,6 +2000,10 @@ libraries:
- python-gapic-name=spanner
metadata_name_override: spanner
default_version: v1
- name: google-cloud-spanner-dbapi-driver
version: 0.0.0
python:
library_type: INTEGRATION
- name: google-cloud-speech
version: 2.40.0
apis:
Expand Down
35 changes: 35 additions & 0 deletions packages/google-api-core/google/api_core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,41 @@ class AsyncRestUnsupportedParameterError(NotImplementedError):
pass


class ResumableTransferError(GoogleAPICallError):
"""Base class for resumable transfer errors."""

upload_url: Optional[str] = None
chunk_size: Optional[int] = None

def __init__(
self,
message: str,
*args,
upload_url: Optional[str] = None,
chunk_size: Optional[int] = None,
**kwargs,
) -> None:
super().__init__(message, *args, **kwargs)
self.upload_url = upload_url
self.chunk_size = chunk_size


class TransferStalledError(ResumableTransferError):
"""Raised when upload throughput stays below minimum rate past stall timeout."""


class UnseekableStreamError(ResumableTransferError):
"""Raised when server recovery requires rewinding a non-seekable stream."""


class UploadCancelledError(ResumableTransferError):
"""Raised when the upload is cancelled by the client or server."""


class MissingStatusHeaderError(ResumableTransferError):
"""Raised when server response lacks the required X-Goog-Upload-Status header."""


def exception_class_for_http_status(status_code):
"""Return the exception class for a specific HTTP status code.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Resumable transfer library for Google APIs."""

from google.api_core.exceptions import (
MissingStatusHeaderError,
ResumableTransferError,
TransferStalledError,
UnseekableStreamError,
UploadCancelledError,
)
from google.api_core.resumable_transfer.common import (
DEFAULT_CHUNK_SIZE,
Command,
ProgressState,
ResumableUploadConfig,
Status,
UploadProgress,
)
from google.api_core.resumable_transfer.upload import ResumableUploadSession
from google.api_core.resumable_transfer.upload_async import (
AsyncResumableUploadSession,
AsyncUploadOperation,
)

__all__ = [
"Command",
"DEFAULT_CHUNK_SIZE",
"MissingStatusHeaderError",
"ProgressState",
"ResumableTransferError",
"Status",
"TransferStalledError",
"UnseekableStreamError",
"UploadCancelledError",
"UploadProgress",
"ResumableUploadConfig",
"ResumableUploadSession",
"AsyncResumableUploadSession",
"AsyncUploadOperation",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Common constants, headers, and shared configuration for Resumable Upload protocol."""

import dataclasses
import datetime
import enum
from typing import Any, Mapping, Optional, Sequence, Tuple, Union

import google.protobuf.message
import proto
from google.protobuf import json_format

# Default chunk size: 10 MiB
DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024

# Protocol Headers
HEADER_PROTOCOL = "X-Goog-Upload-Protocol"
HEADER_COMMAND = "X-Goog-Upload-Command"
HEADER_STATUS = "X-Goog-Upload-Status"
HEADER_URL = "X-Goog-Upload-URL"
HEADER_OFFSET = "X-Goog-Upload-Offset"
HEADER_SIZE_RECEIVED = "X-Goog-Upload-Size-Received"
HEADER_CONTENT_TYPE = "X-Goog-Upload-Header-Content-Type"
HEADER_CONTENT_LENGTH = "X-Goog-Upload-Header-Content-Length"
HEADER_CHUNK_GRANULARITY = "X-Goog-Upload-Chunk-Granularity"

PROTOCOL_RESUMABLE = "resumable"


class Command(str, enum.Enum):
"""Protocol commands."""

START = "start"
UPLOAD = "upload"
FINALIZE = "finalize"
QUERY = "query"
CANCEL = "cancel"


class Status(str, enum.Enum):
"""Server upload status values."""

ACTIVE = "active"
FINAL = "final"
CANCELLED = "cancelled"


class ProgressState(str, enum.Enum):
"""Progress notification state values."""

STARTED = "started"
UPLOADING = "uploading"
RECOVERING = "recovering"
OFFSET_RECEIVED = "offset received"
FINALIZED = "finalized"


@dataclasses.dataclass(frozen=True)
class UploadProgress:
"""Upload progress notification payload.

Attributes:
upload_url: The unique session URL for this upload.
chunk_size: The actual negotiated chunk size.
bytes_uploaded: The total confirmed bytes committed so far.
total_bytes: The total size of the stream in bytes, if known.
state: The current progress state.
"""

upload_url: str
chunk_size: int
bytes_uploaded: int
total_bytes: Optional[int]
state: ProgressState


# HTTP status codes indicating transient retryable errors
RETRYABLE_STATUS_CODES = (408, 429, 500, 502, 503, 504)

# HTTP status codes indicating state consistency errors requiring recovery
RECOVERABLE_STATUS_CODES = (400, 409, 412, 416)


@dataclasses.dataclass
class ResumableUploadConfig:
"""Configuration options for a resumable upload.

Attributes:
chunk_size: Size in bytes for each uploaded data chunk. Defaults to 10 MiB.
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.
"""

chunk_size: int = DEFAULT_CHUNK_SIZE
stall_minimum_rate: int = 64 * 1024
stall_timeout: float = 120.0
headers: Optional[Union[Mapping[str, str], Sequence[Tuple[str, str]]]] = None
deadline: Optional[datetime.datetime] = None

@property
def start_headers(self) -> Optional[Sequence[Tuple[str, str]]]:
"""Returns normalized additional headers for the start request."""
if self.headers is None:
return None
if isinstance(self.headers, Mapping):
return list(self.headers.items())
return list(self.headers)


def _format_response_payload(
response: Union[Any, bytes],
response_type: Optional[Any],
) -> Any:
"""Formats raw response or bytes into protobuf or proto-plus message type if configured.

Args:
response: Raw HTTP response object or response body bytes.
response_type: Deserializer callable, proto.Message class, or
google.protobuf.message.Message class or instance.

Returns:
Deserialized protobuf message or the raw response object / bytes.
"""
if response_type is None:
return response

content: bytes
if isinstance(response, bytes):
content = response
elif hasattr(response, "content"):
content = response.content
else:
content = bytes(response)

from_json_fn = getattr(response_type, "from_json", None)
if callable(from_json_fn):
if isinstance(response_type, type) and issubclass(response_type, proto.Message):
return from_json_fn(content, ignore_unknown_fields=True)
return from_json_fn(content)
if isinstance(response_type, type) and issubclass(
response_type, google.protobuf.message.Message
):
instance = response_type()
return json_format.Parse(content, instance, ignore_unknown_fields=True)
if isinstance(response_type, google.protobuf.message.Message):
return json_format.Parse(content, response_type, ignore_unknown_fields=True)
if callable(response_type):
return response_type(content)

return response
Loading
Loading