Skip to content

Commit 15b244e

Browse files
authored
fix(cost-insight): refresh storage batch polling token (#566)
## Summary - refresh Google credentials before each Storage Batch Operations polling request - refresh and retry once when a polling request returns 401, covering expired access tokens during long-running delete jobs - add regression coverage for auth refresh while waiting for batch delete jobs ## Tests - python -m pytest tests/test_storage_batch_operations.py -q --no-cov - python -m ruff check src tests - python -m pytest -q
1 parent b0db905 commit 15b244e

2 files changed

Lines changed: 531 additions & 10 deletions

File tree

cost-insight/src/cost_insight/common/storage_batch_operations.py

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ class StorageBatchOperationsTransientError(RuntimeError):
3535
"""Raised when polling should retry after a transient API failure."""
3636

3737

38+
class StorageBatchOperationsAuthError(RuntimeError):
39+
"""Raised when polling should refresh credentials and retry once."""
40+
41+
3842
def create_delete_job(
3943
*,
4044
project_id: str,
@@ -48,8 +52,8 @@ def create_delete_job(
4852
from google.auth.transport.requests import Request as GoogleAuthRequest
4953

5054
credentials, _ = default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
51-
if not credentials.valid:
52-
credentials.refresh(GoogleAuthRequest())
55+
auth_request = GoogleAuthRequest()
56+
_refresh_credentials_if_needed(credentials, auth_request)
5357

5458
request_url = (
5559
"https://storagebatchoperations.googleapis.com/v1/"
@@ -116,28 +120,58 @@ def wait_for_delete_job(
116120
from google.auth.transport.requests import Request as GoogleAuthRequest
117121

118122
credentials, _ = default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
119-
if not credentials.valid:
120-
credentials.refresh(GoogleAuthRequest())
123+
auth_request = GoogleAuthRequest()
121124

122125
request_url = f"https://storagebatchoperations.googleapis.com/v1/{job_name}"
123126
elapsed = 0
127+
auth_retried = False
124128
while True:
125129
try:
126-
payload = _get_job_payload(request_url=request_url, token=credentials.token)
130+
_refresh_credentials_if_needed(credentials, auth_request)
127131
except StorageBatchOperationsTransientError:
128-
logger.debug(
129-
"Polling Storage Batch Operations job %s hit transient error after %ss",
132+
elapsed = _sleep_for_transient(
133+
job_name=job_name,
134+
timeout_seconds=timeout_seconds,
135+
poll_interval_seconds=poll_interval_seconds,
136+
elapsed=elapsed,
137+
log_prefix="Refreshing credentials while polling",
138+
)
139+
auth_retried = False
140+
continue
141+
142+
try:
143+
payload = _get_job_payload(request_url=request_url, token=credentials.token)
144+
except StorageBatchOperationsAuthError:
145+
if auth_retried:
146+
raise
147+
logger.info(
148+
"Refreshing credentials while polling Storage Batch Operations job %s",
130149
job_name,
131-
elapsed,
132-
exc_info=True,
133150
)
134-
elapsed = _sleep_or_raise_timeout(
151+
try:
152+
_refresh_credentials(credentials, auth_request)
153+
except StorageBatchOperationsTransientError:
154+
elapsed = _sleep_for_transient(
155+
job_name=job_name,
156+
timeout_seconds=timeout_seconds,
157+
poll_interval_seconds=poll_interval_seconds,
158+
elapsed=elapsed,
159+
log_prefix="Refreshing credentials while polling",
160+
)
161+
continue
162+
auth_retried = True
163+
continue
164+
except StorageBatchOperationsTransientError:
165+
elapsed = _sleep_for_transient(
135166
job_name=job_name,
136167
timeout_seconds=timeout_seconds,
137168
poll_interval_seconds=poll_interval_seconds,
138169
elapsed=elapsed,
170+
log_prefix="Polling",
139171
)
172+
auth_retried = False
140173
continue
174+
auth_retried = False
141175
state = str(payload.get("state") or "STATE_UNSPECIFIED")
142176
logger.debug(
143177
"Polling Storage Batch Operations job %s: state=%s elapsed=%ss",
@@ -168,6 +202,10 @@ def _get_job_payload(*, request_url: str, token: str) -> dict[str, object]:
168202
return json.loads(response.read().decode("utf-8"))
169203
except HTTPError as exc:
170204
detail = exc.read().decode("utf-8", errors="replace")
205+
if exc.code == 401:
206+
raise StorageBatchOperationsAuthError(
207+
f"Storage Batch Operations get job authentication failure ({exc.code}): {detail}"
208+
) from exc
171209
if exc.code in {429, 500, 502, 503, 504}:
172210
raise StorageBatchOperationsTransientError(
173211
f"Storage Batch Operations get job transient failure ({exc.code}): {detail}"
@@ -179,6 +217,26 @@ def _get_job_payload(*, request_url: str, token: str) -> dict[str, object]:
179217
) from exc
180218

181219

220+
def _refresh_credentials_if_needed(credentials, auth_request) -> None:
221+
if not credentials.valid:
222+
_refresh_credentials(credentials, auth_request)
223+
224+
225+
def _refresh_credentials(credentials, auth_request) -> None:
226+
from google.auth import exceptions as google_auth_exceptions
227+
228+
try:
229+
credentials.refresh(auth_request)
230+
except URLError as exc:
231+
raise StorageBatchOperationsTransientError(
232+
f"Storage Batch Operations credential refresh transient failure: {str(exc.reason)}"
233+
) from exc
234+
except google_auth_exceptions.TransportError as exc:
235+
raise StorageBatchOperationsTransientError(
236+
f"Storage Batch Operations credential refresh transient failure: {exc}"
237+
) from exc
238+
239+
182240
def _coerce_job_status(
183241
*,
184242
job_name: str,
@@ -204,6 +262,29 @@ def _coerce_counter(value: object) -> int:
204262
return int(str(value))
205263

206264

265+
def _sleep_for_transient(
266+
*,
267+
job_name: str,
268+
timeout_seconds: int,
269+
poll_interval_seconds: int,
270+
elapsed: int,
271+
log_prefix: str,
272+
) -> int:
273+
logger.debug(
274+
"%s Storage Batch Operations job %s hit transient error after %ss",
275+
log_prefix,
276+
job_name,
277+
elapsed,
278+
exc_info=True,
279+
)
280+
return _sleep_or_raise_timeout(
281+
job_name=job_name,
282+
timeout_seconds=timeout_seconds,
283+
poll_interval_seconds=poll_interval_seconds,
284+
elapsed=elapsed,
285+
)
286+
287+
207288
def _sleep_or_raise_timeout(
208289
*,
209290
job_name: str,

0 commit comments

Comments
 (0)