Skip to content

Commit 87117c9

Browse files
Enhance Retry Logic and Configuration for Storage Control API (#787)
1 parent 29d05fc commit 87117c9

7 files changed

Lines changed: 342 additions & 46 deletions

File tree

cloudbuild/cleanup.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@ gcloud storage rm --recursive "gs://gcsfs-test-hns-${SHORT_BUILD_ID}" || true &
1616
gcloud storage rm --recursive "gs://gcsfs-test-zonal-${SHORT_BUILD_ID}" || true &
1717
gcloud storage rm --recursive "gs://gcsfs-test-standard-for-zonal-${SHORT_BUILD_ID}" || true &
1818
gcloud storage rm --recursive "gs://gcsfs-test-zonal-core-${SHORT_BUILD_ID}" || true &
19-
gcloud storage rm --recursive "gs://gcsfs-test-hns-req-pay-${SHORT_BUILD_ID}" || true &
20-
gcloud storage rm --recursive "gs://gcsfs-test-standard-req-pay-${SHORT_BUILD_ID}" || true &
19+
gcloud storage rm --recursive "gs://gcsfs-test-hns-req-pay-${SHORT_BUILD_ID}" --billing-project="${PROJECT_ID}" || true &
20+
gcloud storage rm --recursive "gs://gcsfs-test-standard-req-pay-${SHORT_BUILD_ID}" --billing-project="${PROJECT_ID}" || true &
2121
wait

cloudbuild/e2e-tests-cloudbuild.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ steps:
160160
env:
161161
- "SHORT_BUILD_ID=${_SHORT_BUILD_ID}"
162162
- "ZONE=${_ZONE}"
163+
- "PROJECT_ID=${PROJECT_ID}"
163164
waitFor:
164165
[
165166
"run-standard-tests",

docs/source/retries.rst

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,31 +38,48 @@ For standard buckets, ``gcsfs`` uses a custom retry decorator (``retry_request``
3838
Hierarchical Namespace (HNS) Buckets
3939
------------------------------------
4040

41-
For HNS buckets, ``ExtendedGcsFileSystem`` utilizes the specialized Storage Control client (``StorageControlAsyncClient``) for folder-level operations (e.g., ``mkdir``, ``rename``).
41+
For HNS buckets, ``ExtendedGcsFileSystem`` utilizes the specialized Storage Control client (``StorageControlAsyncClient``) for control plane operations (e.g., ``mkdir``, ``rename``, ``get_storage_layout``).
4242

43-
- These calls utilize the underlying Google Cloud Python SDK's default retry behavior. Standard ``gcsfs`` retry logic (``retry_request``) is not applied to these control plane calls.
43+
- These calls utilize retry configuration based on ``google.api_core.retry.AsyncRetry``.
4444
- **Applicable Methods:**
4545
- ``get_storage_layout``: Used to determine bucket type.
4646
- ``create_folder``: Used for ``mkdir``.
4747
- ``get_folder``: Used for directory metadata and existence checks.
4848
- ``list_folders``: Used for directory listings (``ls``).
4949
- ``rename_folder``: Used for moving/renaming directories (``mv``).
50-
- **Non-Retried Methods:** Methods like ``delete_folder`` (used for ``rmdir``) are not retried by default.
50+
- ``delete_folder``: Used for deleting directories (``rmdir``, ``rm -r``).
5151
- **Retriable Errors:**
5252
- ``google.api_core.exceptions.DeadlineExceeded``
53+
- ``google.api_core.exceptions.ServiceUnavailable``
5354
- ``google.api_core.exceptions.InternalServerError``
55+
- ``google.api_core.exceptions.TooManyRequests``
5456
- ``google.api_core.exceptions.ResourceExhausted``
55-
- ``google.api_core.exceptions.ServiceUnavailable``
5657
- ``google.api_core.exceptions.Unknown``
57-
- **Backoff Strategy:** Exponential backoff with ``initial=1.0s``, ``maximum=60.0s``, and ``multiplier=2.0``.
58-
- **Overall Timeout (Deadline):** 60.0s
58+
- ``google.api_core.exceptions.Unauthenticated`` (when "Invalid Credentials" is in the message).
59+
60+
- **Configuration:**
61+
The retry behavior can be customized via the following parameters passed to the FileSystem instance:
62+
63+
- ``retry_timeout`` (float): The total deadline for the retry loop in seconds. Default: ``60.0``.
64+
- ``retry_initial`` (float): The initial delay between retries in seconds. Default: ``1.0``.
65+
- ``retry_maximum`` (float): The maximum delay between retries in seconds. Default: ``60.0``.
66+
- ``retry_multiplier`` (float): The multiplier applied to the delay after each retry. Default: ``2.0``.
67+
68+
Per-attempt timeout is controlled by an internal ``STORAGE_CONTROL_RPC_TIMEOUT`` constant, currently set to ``30.0s``.
69+
70+
Configuring Retries via fsspec
71+
------------------------------
72+
73+
Since ``gcsfs`` integrates with the ``fsspec`` configuration system, these retry parameters can be set using ``fsspec`` `configuration files or environment variables <https://filesystem-spec.readthedocs.io/en/latest/features.html#configuration>`_
74+
75+
These settings will be automatically picked up by any ``GCSFileSystem`` instance when experimental HNS support is enabled (which is the default).
5976

6077
Rapid Storage (Zonal Buckets)
6178
-----------------------------
6279

6380
For Zonal buckets, ``ZonalFile`` utilizes the specialized gRPC clients (``AsyncMultiRangeDownloader`` for reads and ``AsyncAppendableObjectWriter`` for writes).
6481

65-
- Similar to HNS buckets, control plane operations for Zonal buckets (such as ``get_storage_layout`` or folder operations) utilize the same ``StorageControlAsyncClient`` retry mechanism described in the HNS section above.
82+
- Similar to HNS buckets, control plane operations for Zonal buckets (such as ``get_storage_layout`` or folder operations) utilize the same Storage Control retry mechanism described in the **Storage Control API** section above.
6683
- File read/write operations (data plane) for Zonal buckets utilize the underlying Google Cloud Python SDK's default retry behavior for gRPC streams. Standard ``gcsfs`` retry logic (``retry_request``) is not applied to these data plane calls.
6784
- **AsyncMultiRangeDownloader (MRD) Retries (Reads):**
6885
- **Applicable Methods:**

gcsfs/extended_gcsfs.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
from gcsfs import __version__ as version
2121
from gcsfs import zb_hns_utils
2222
from gcsfs.core import GCSFile, GCSFileSystem
23+
from gcsfs.retry import DEFAULT_RETRY_CONFIG, get_storage_control_retry_config
2324
from gcsfs.zonal_file import ZonalFile
2425

2526
logger = logging.getLogger("gcsfs")
2627

2728
USER_AGENT = "python-gcsfs"
29+
STORAGE_CONTROL_RPC_TIMEOUT = 30.0
2830

2931

3032
class BucketType(Enum):
@@ -51,6 +53,26 @@ class ExtendedGcsFileSystem(GCSFileSystem):
5153
"""
5254

5355
def __init__(self, *args, finalize_on_close=False, **kwargs):
56+
"""
57+
Parameters
58+
----------
59+
finalize_on_close : bool, default False
60+
By default, files in zonal buckets are left unfinalized to allow appends.
61+
**kwargs : dict
62+
Additional arguments passed to GCSFileSystem.
63+
Supports retry configuration overrides for Storage Control API:
64+
- retry_timeout: Total time to spend retrying (seconds).
65+
- retry_initial: Initial delay between retries (seconds).
66+
- retry_maximum: Maximum delay between retries (seconds).
67+
- retry_multiplier: Multiplier for delay between retries.
68+
These map to `google.api_core.retry.AsyncRetry` arguments (without 'retry_' prefix).
69+
"""
70+
valid_keys = DEFAULT_RETRY_CONFIG.keys()
71+
self.retry_config = {
72+
k[6:]: v
73+
for k, v in kwargs.items()
74+
if k.startswith("retry_") and k[6:] in valid_keys and v is not None
75+
}
5476
super().__init__(*args, **kwargs)
5577
# By default, files in zonal buckets are left unfinalized to allow appends.
5678
self.finalize_on_close = finalize_on_close
@@ -79,6 +101,9 @@ def _user_project(self):
79101
)
80102
return None
81103

104+
def _get_retry_config(self, **kwargs):
105+
return get_storage_control_retry_config(self.retry_config, **kwargs)
106+
82107
@property
83108
def grpc_client(self):
84109
if self.asynchronous and self._grpc_client is None:
@@ -141,7 +166,11 @@ async def _get_bucket_type(self, bucket):
141166
client = await self._get_control_plane_client()
142167
bucket_name_value = f"projects/_/buckets/{bucket}/storageLayout"
143168
logger.debug(f"get_storage_layout request for name: {bucket_name_value}")
144-
response = await client.get_storage_layout(name=bucket_name_value)
169+
response = await client.get_storage_layout(
170+
name=bucket_name_value,
171+
retry=self._get_retry_config(),
172+
timeout=STORAGE_CONTROL_RPC_TIMEOUT,
173+
)
145174

146175
if response.location_type == "zone":
147176
return BucketType.ZONAL_HIERARCHICAL
@@ -514,7 +543,11 @@ async def _mv(self, path1, path2, **kwargs):
514543

515544
logger.debug(f"rename_folder request: {request}")
516545
client = await self._get_control_plane_client()
517-
operation = await client.rename_folder(request=request)
546+
operation = await client.rename_folder(
547+
request=request,
548+
retry=self._get_retry_config(),
549+
timeout=STORAGE_CONTROL_RPC_TIMEOUT,
550+
)
518551
await operation.result()
519552
self._update_dircache_after_rename(path1, path2)
520553

@@ -679,7 +712,11 @@ async def _create_hns_folder(self, path, bucket, key, create_parents):
679712
try:
680713
logger.debug(f"create_folder request: {request}")
681714
client = await self._get_control_plane_client()
682-
await client.create_folder(request=request)
715+
await client.create_folder(
716+
request=request,
717+
retry=self._get_retry_config(),
718+
timeout=STORAGE_CONTROL_RPC_TIMEOUT,
719+
)
683720
# Instead of invalidating the parent cache, update it to add the new entry.
684721
parent_path = self._parent(path)
685722
if parent_path in self.dircache:
@@ -725,7 +762,11 @@ async def _get_directory_info(self, path, bucket, key, generation):
725762

726763
# Verify existence using get_folder API
727764
client = await self._get_control_plane_client()
728-
response = await client.get_folder(request=request)
765+
response = await client.get_folder(
766+
request=request,
767+
retry=self._get_retry_config(),
768+
timeout=STORAGE_CONTROL_RPC_TIMEOUT,
769+
)
729770

730771
# If successful, return directory metadata
731772
return {
@@ -798,7 +839,11 @@ async def _rmdir(self, path):
798839

799840
logger.debug(f"delete_folder request: {request}")
800841
client = await self._get_control_plane_client()
801-
await client.delete_folder(request=request)
842+
await client.delete_folder(
843+
request=request,
844+
retry=self._get_retry_config(),
845+
timeout=STORAGE_CONTROL_RPC_TIMEOUT,
846+
)
802847

803848
# Remove the directory from the cache and from its parent's listing.
804849
self.dircache.pop(path, None)
@@ -1121,7 +1166,11 @@ async def _get_all_folders(self, path, bucket, prefix=""):
11211166
logger.debug(f"list_folders request: {request}")
11221167

11231168
client = await self._get_control_plane_client()
1124-
async for folder in await client.list_folders(request=request):
1169+
async for folder in await client.list_folders(
1170+
request=request,
1171+
retry=self._get_retry_config(),
1172+
timeout=STORAGE_CONTROL_RPC_TIMEOUT,
1173+
):
11251174
folders.append(self._create_folder_entry(bucket, folder))
11261175

11271176
return folders

gcsfs/retry.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,16 @@
77
import google.auth.exceptions
88
import requests.exceptions
99
from decorator import decorator
10+
from google.api_core import exceptions as api_exceptions
11+
from google.api_core.retry import AsyncRetry
1012

1113
logger = logging.getLogger("gcsfs")
14+
DEFAULT_RETRY_CONFIG = {
15+
"timeout": 60.0,
16+
"initial": 1.0,
17+
"maximum": 60.0,
18+
"multiplier": 2.0,
19+
}
1220

1321

1422
class HttpError(Exception):
@@ -176,3 +184,47 @@ async def retry_request(func, retries=6, *args, **kwargs):
176184
continue
177185
logger.exception(f"{func.__name__} non-retriable exception: {e}")
178186
raise e
187+
188+
189+
def _is_transient_exception(exception):
190+
is_transient = isinstance(
191+
exception,
192+
(
193+
api_exceptions.DeadlineExceeded,
194+
api_exceptions.ServiceUnavailable,
195+
api_exceptions.InternalServerError,
196+
api_exceptions.TooManyRequests,
197+
api_exceptions.ResourceExhausted,
198+
api_exceptions.Unknown,
199+
),
200+
)
201+
if (
202+
not is_transient
203+
and isinstance(exception, api_exceptions.Unauthenticated)
204+
and "Invalid Credentials" in str(exception)
205+
):
206+
is_transient = True
207+
return is_transient
208+
209+
210+
def get_storage_control_retry_config(base_config=None, **kwargs) -> AsyncRetry:
211+
"""
212+
Returns an AsyncRetry object configured for Storage Control API calls.
213+
214+
Priority: kwargs (timeout, etc.) > base_config > package defaults.
215+
216+
Args:
217+
base_config: A dict containing base settings.
218+
**kwargs: Direct call-site overrides (e.g., timeout=10).
219+
"""
220+
retry_kwargs = DEFAULT_RETRY_CONFIG.copy()
221+
valid_keys = DEFAULT_RETRY_CONFIG.keys()
222+
if base_config:
223+
retry_kwargs.update(
224+
{k: v for k, v in base_config.items() if k in valid_keys and v is not None}
225+
)
226+
227+
overrides = {k: v for k, v in kwargs.items() if k in valid_keys and v is not None}
228+
retry_kwargs.update(overrides)
229+
230+
return AsyncRetry(predicate=_is_transient_exception, **retry_kwargs)

0 commit comments

Comments
 (0)