fix: retry GCSClient.isdir() on transient 5xx errors - #3442
Conversation
GCSClient.isdir() calls buckets().get().execute() and objects().list().execute() raw without any retry, so a transient 5xx (e.g. "503 We encountered an internal error") in the exists() -> isdir() fallback path could kill a long-running batch in one shot. Attach @gcs_retry so both calls inherit the existing 5-attempt exponential backoff shared with _obj_exists / _do_put / download. All calls inside isdir are idempotent GETs/LISTs, so re-running them on retry is safe. Add mock-based tests covering: 200 one-shot success (no retry), empty prefix returning False, 503 retry succeeding on the third attempt, and giving up after the 5-attempt limit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RetryTest had no markers, so pytest_collection_modifyitems tagged it "unmarked" instead of "gcloud". tox's gcloud env filters on -m gcloud, so `tox run -e py310-gcloud` silently deselected all 6 RetryTest cases instead of running them.
httplib2 is provided by google-auth, which the top-of-file try/except already gates test collection on. Importing it again per-test was redundant; centralizing it also lets the skip message name the required package.
Each RetryTest method rebuilt identical httplib2.Response/HttpError pairs for 404 and 503. Defining them once as HTTP_ERROR_404 / HTTP_ERROR_503 module constants removes the duplication and makes it obvious the same canned errors are shared across tests.
Several tests hardcoded numbers that were only correct because they happened to match something else in the same test: retry-limit tests repeated IOError/503 exactly 5 times to match gcs_retry's stop_after_attempt, and the isdir tests wrote "foo/bar" as a list item name that only made sense next to "gs://bucket/foo". Both were free to drift out of sync silently. Now the retry-limit tests derive their side_effect count from the decorated function's own retry.stop.max_attempt_number, and the isdir tests share a single `prefix`/`path` pair so the item name is provably scoped under the path being checked.
…etUp RetryTest mixed generic gcs_retry-decorator tests with isdir-specific retry tests. Split the isdir cases into GCSClientIsDirRetryTest with a setUp that provides the mock client and default 404 get()-response, so each test only arranges its own list() behavior. These tests conceptually belong on GCSClientTest, but that class does live-GCS integration setup (_GCSBaseTestCase) rather than local mocking — noted with a comment rather than migrating it now, since that needs its own refactor to convert GCSClientTest to real unit tests.
|
I've got some minor feedback on the organization and reuse of the tests. Rather than commenting on them, I'll push a couple commits to address my own feedback. |
|
Pushed 5 follow-up test-only commits, no production code changes:
Verified with: Result: |
dlstadther
left a comment
There was a problem hiding this comment.
I was good with these changes. But i made some test adjustments to ensure they run with the gcloud marker, DRY some behavior, and reorganize (with some compromise) where tests live. No behaviors were changed.
I'm approving the PR, but since I made adjustment to your code, please confirm that I've indeed retained your intended test behaviors.
Wrapping isdir() with @gcs_retry nested it with _obj_exists's own @gcs_retry: on a persistent 5xx both layers retried independently, giving a multiplicative 5*5 = 25-attempt budget with full exponential backoff. Drop @gcs_retry from isdir itself and extract the two remaining raw execute() calls into small @gcs_retry-decorated helpers: _bucket_exists (buckets.get, root-path branch) and _list_prefix (objects.list, the prefix probe that appeared in the original traceback). Each API call now has its own independent 5-attempt budget with no nesting. test_isdir_fails_after_retry_limit now reads max_attempts from _list_prefix.retry instead of isdir.retry, since retry lives on the helper now. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Thanks for the cleanup, @dlstadther — I confirmed the test behaviors are preserved (the That said, re-reading the change I noticed a nested-retry issue I want to fix before this lands. The traceback that motivated this PR wasn't from Would you mind taking another look? |
dlstadther
left a comment
There was a problem hiding this comment.
Removed an unnecessary comment block. Otherwise, all good
Description
Attach
@gcs_retrytoGCSClient.isdir()so that the two rawexecute()calls inside it (buckets().get()andobjects().list()) inherit the same 5-attempt exponential backoff already used by_obj_exists,_do_put, anddownload. Both calls are idempotent GET/LIST, so retrying is safe.Scope is limited to
isdir. Side-effectful methods (remove,copy, etc.) are intentionally left alone — retrying those safely needs additional idempotency handling and belongs in a separate PR.Motivation and Context
A single transient 5xx from
objects.listcan kill a long-running batch, because theTarget.exists()→GCSClient.exists()→GCSClient.isdir()fallback path was callingexecute()without any retry:gcs_retryalready exists and is attached to the sibling methods;isdirwas just missing the decorator.Have you tested this? If so, how?
I have included unit tests. Four new mock-based cases were added to
RetryTest, covering both the retry paths and the untouched happy paths:test_isdir_no_retry_on_success— 200 returns immediately (regression guard)test_isdir_returns_false_when_no_items— empty prefix returnsFalsetest_isdir_retries_on_5xx— 503 → 503 → 200 succeeds on the third attempttest_isdir_fails_after_retry_limit— five consecutive 503s re-raiseHttpErrorAll 6 tests in
RetryTestpass locally. The@pytest.mark.gcloudintegration tests were not re-run (they need live GCS credentials), butisdir's behavior on 2xx responses is unchanged.