Skip to content

fix: retry GCSClient.isdir() on transient 5xx errors - #3442

Merged
dlstadther merged 9 commits into
spotify:masterfrom
mski-iksm:fix/gcs-isdir-retry-5xx
Jul 18, 2026
Merged

dlstadther merged 9 commits into
spotify:masterfrom
mski-iksm:fix/gcs-isdir-retry-5xx

Conversation

@mski-iksm

Copy link
Copy Markdown
Contributor

Description

Attach @gcs_retry to GCSClient.isdir() so that the two raw execute() calls inside it (buckets().get() and objects().list()) inherit the same 5-attempt exponential backoff already used by _obj_exists, _do_put, and download. 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.list can kill a long-running batch, because the Target.exists() → GCSClient.exists() → GCSClient.isdir() fallback path was calling execute() without any retry:

File "luigi/contrib/gcs.py", line 200, in exists
    return self.isdir(path)
File "luigi/contrib/gcs.py", line 217, in isdir
    resp = self.client.objects().list(bucket=bucket, prefix=obj, maxResults=20).execute()
googleapiclient.errors.HttpError: <HttpError 503 ... "We encountered an internal error. Please try again.">

gcs_retry already exists and is attached to the sibling methods; isdir was 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 returns False
  • test_isdir_retries_on_5xx — 503 → 503 → 200 succeeds on the third attempt
  • test_isdir_fails_after_retry_limit — five consecutive 503s re-raise HttpError

All 6 tests in RetryTest pass locally. The @pytest.mark.gcloud integration tests were not re-run (they need live GCS credentials), but isdir's behavior on 2xx responses is unchanged.

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>
@mski-iksm
mski-iksm requested a review from dlstadther as a code owner July 16, 2026 15:48
@mski-iksm
mski-iksm requested a review from a team July 16, 2026 15:48
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.
@dlstadther

Copy link
Copy Markdown
Contributor

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.

@dlstadther

Copy link
Copy Markdown
Contributor

Pushed 5 follow-up test-only commits, no production code changes:

  • e9d4b0ba test(gcs): mark RetryTest with pytest.mark.gcloud — the new class had no markers, so tox -e py310-gcloud (which filters on -m gcloud) silently deselected all 6 of its tests instead of running them.
  • c1b1df3a test(gcs): hoist httplib2 import to module-level try/except — httplib2 is a google-auth dependency, so the per-test import httplib2 was redundant with the file's existing top-level import guard; also names google-auth in the skip message.
  • e99a88e8 test(gcs): hoist repeated 404/503 HttpError fixtures to module scope — de-dupes the identical httplib2.Response/HttpError pairs each test was rebuilding.
  • a40c94eb test(gcs): align RetryTest arrange steps with their assertions — replaces hardcoded retry counts (5) and item-name strings ("foo/bar") with values derived from gcs_retry's own retry.stop.max_attempt_number and from the test's own path, so they can't silently drift out of sync with what's actually being asserted.
  • 205b0384 test(gcs): split isdir retry tests into their own class with shared setUp — splits the generic gcs_retry-decorator tests from the isdir-specific ones into GCSClientIsDirRetryTest, with a setUp for the shared mock client. Left a comment noting these tests conceptually belong on GCSClientTest, but that class does live-GCS integration setup via _GCSBaseTestCase and would need its own refactor to plain unit tests first.

Verified with:

tox run -e py310-gcloud -- test/contrib/gcs_test.py::GCSClientIsDirRetryTest

Result: 4 passed, 5 warnings in 21.92s

dlstadther
dlstadther previously approved these changes Jul 17, 2026

@dlstadther dlstadther 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 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>
@mski-iksm

Copy link
Copy Markdown
Contributor Author

Thanks for the cleanup, @dlstadther — I confirmed the test behaviors are preserved (the gcloud marker catch was a great one).

That said, re-reading the change I noticed a nested-retry issue I want to fix before this lands. isdir internally calls _obj_exists, which is already @gcs_retry-decorated. Putting @gcs_retry on isdir too means both layers retry on a persistent 5xx, giving a multiplicative 5 × 5 = 25-attempt budget with full exponential backoff — not what I intended.

The traceback that motivated this PR wasn't from _obj_exists (already retry-protected) but from the two raw execute() calls inside isdir: buckets().get() and objects().list(). So I've pushed a follow-up that drops @gcs_retry from isdir itself and instead extracts those two raw calls into small @gcs_retry-decorated helpers (_bucket_exists, _list_prefix). Each API call now has its own independent 5-attempt budget (15 max total) with no nesting. The existing tests only needed a one-line change to point at _list_prefix.retry instead of isdir.retry.

Would you mind taking another look?

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

Removed an unnecessary comment block. Otherwise, all good

@dlstadther
dlstadther enabled auto-merge (squash) July 18, 2026 11:11
@dlstadther
dlstadther merged commit 715f65c into spotify:master Jul 18, 2026
50 checks passed
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