Skip to content

fix: Improve reliability of S3 credential refresh - #891

Merged
dhedey merged 4 commits into
mainfrom
bugfix/s3-credential-refresh-reliability
Aug 24, 2026
Merged

fix: Improve reliability of S3 credential refresh#891
dhedey merged 4 commits into
mainfrom
bugfix/s3-credential-refresh-reliability

Conversation

@dhedey

@dhedey dhedey commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

S3Client / R2Client refetch temporary bucket credentials from the control plane on a timer. Four things on that path turned one blip into a dead run — reported by a user streaming from /teamspace/s3_connections/* across 4 ranks × 8 workers, dying 8–9 hours in on a burst of boto3 failures:

  • Retries the /v1/auth/login POST, which urllib3 was never retrying.
  • Keeps serving the current credentials when a refresh fails, instead of killing the worker.
  • Retries a failed refresh on a timer rather than on every read.
  • Waits out a control-plane outage when creating the first client, where there is nothing to fall back on.
  • Retries only failures that can clear, so a bad storage_options key still raises at once.
  • Jitters the refresh interval per process, so forked workers stop refreshing in lockstep.
  • Moves the interval from 55 to 45 minutes, for a usable margin inside the 1 hour credential TTL.
Root cause

_login_and_get_temp_bucket_credentials makes two calls, and mounts a Retry on the session for both. urllib3 defaults allowed_methods to a set that excludes POST, so only the credentials GET was ever retried — a single 429 or 502 on the login POST failed the whole refresh. A non-200 login was also read straight as JSON, so it surfaced as a JSONDecodeError, or as a missing-token error that named neither the status nor the login call.

That failure then propagated out of the client property into the caller. Nothing between there and the DataLoader catches it, so one failed refresh killed the worker and the run — even though the credentials in hand were still valid for another five minutes. And because _last_time only advanced on success while _client kept the old client, every subsequent read re-attempted the refresh: one outage became a login storm from every worker, which is the burst the user saw.

Two things made that window small and likely to be hit by everyone at once. The credentials carry no expiry for us to read, and 3300s was set against a TTL of 3600s, leaving five minutes to recover. Workers are forked together, so they mint together and expire together.

After: the login POST is retried; a failed refresh logs, keeps the current client, and backs off for 60s; only once it is _REFRESH_GRACE_PERIOD (900s) past the interval are the credentials assumed dead and the error raised. The interval is jittered downwards and re-rolled per process, in __setstate__ and on a pid change, so forked workers spread out.

Creating the first client has no credentials to fall back on, so it gets the same 60s timer and 900s bound rather than the caller's error — otherwise a control plane that is down at job start kills the job in the time it takes to lose one retry budget. Each attempt logs how long it has been waiting and that data loading is blocked meanwhile.

Retrying only helps failures that can clear, so credential fetches raise a _CredentialsError_CredentialsUnavailableError when the control plane or IMDS was unreachable or answered 5xx/429/408, _CredentialsConfigurationError for missing config or a rejected 4xx — and only those are retried. Anything else is a local mistake: a bad storage_options key raises on the first attempt, as it did before. Initial creation waits only for the unavailable kind; a refresh gives both the grace period, because it holds working credentials and a mid-deploy 403 from a proxy is not worth dying on. A real revocation is still caught by the deadline.

The two budgets are separate because they answer to different constraints. _REFRESH_GRACE_PERIOD is 600s so that 2700 + 600 stays ~5 minutes under the 1 hour TTL rather than running exactly to it; waiting for the first client holds nothing, so _INITIAL_RETRY_BUDGET keeps 900s.

Scope

The per-request retry budget drops from 2880 to 4, which shortens a single call from ~96h of backoff to ~7s. That budget was never the thing protecting a refresh — login is the first call and POST was never retried, so one 429 there was already instantly fatal — and a refresh holds the client lock for its whole duration, so blocking every thread in the process for days is not a fallback, it is a hang. Waiting is now done by the surrounding timer, where it is bounded, logged, and lets the job keep reading. broadcast.py has its own copy of that constant and is untouched.

Not addressed here: workers still refresh independently, so a Studio makes N calls per interval rather than one. Sharing across a process tree is a bigger change and I'd rather do it separately.

Fixes a pre-existing bug this change would otherwise have been built on: _CustomRetryAdapter.send used kwargs.get("timeout", self.timeout), but requests always passes timeout explicitly — as None when the caller gave none — so the default never applied and the login POST had no timeout at all. The retry budget bounded attempts but not wall time.

Declares urllib3 >=1.26 in requirements.txt. client.py has always imported urllib3.util.retry.Retry directly while urllib3 was only ever a transitive dependency, and the effective floor from requests and botocore allows 1.25.x, which has neither DEFAULT_ALLOWED_METHODS nor the allowed_methods kwarg — both arrived in 1.26.0. Latent before; retrying the login POST makes it load-bearing. The bound sits inside both existing constraints, so it excludes nothing anything else needs.

Also drops a print and a comment in get_r2_bucket_credentials that promised a fallback to hardcoded credentials that does not exist.

Tests

Twenty new tests in tests/streaming/test_client.py cover POST being retried; a failed refresh keeping the current client, not retrying on every access, riding out a 403, and raising past the grace period; the jitter being bounded and re-rolled on unpickle; initial creation retrying until the control plane returns and giving up when it does not; local and permanent failures not being retried; and the adapter applying its default timeout. They fail without the change, the behavioural ones with the RuntimeError the fix is meant to absorb. The retry interval is a module constant so the tests set it to zero — the file runs in 7.5s.

Five existing tests needed updating: one for the new default interval, and four whose mocks never set a login status_code. The old "login failure" test asserted a missing-token error while mocking something that was never a login failure; it is split into a rejected-login case and a 200-without-token case.

tests/streaming/test_client.py, test_downloader.py and test_fs_provider.py are green locally (81 passed), serially and under CI's -n 2 --dist=loadgroup, as are ruff, ruff format and mypy.

dhedey and others added 4 commits August 24, 2026 15:40
Temporary S3/R2 credentials are refetched from the control plane on a timer.
Four things made a single blip on that path fatal to a long run:

- The `/v1/auth/login` POST was never retried. urllib3's `Retry` defaults
  `allowed_methods` to a set that excludes POST, so one 429 or 502 on login
  failed the whole refresh even though the credentials GET beside it would
  have retried. A non-200 login also surfaced as a JSONDecodeError or a
  generic missing-token error, with nothing pointing at the login call.

- A failed refresh raised straight out of the `client` property into the
  caller, killing the DataLoader worker and with it the run — even though
  the credentials in hand were still valid. Worse, `_last_time` was only
  advanced on success, so every subsequent read re-attempted the refresh:
  one outage became a request storm from every worker. Failures now keep
  serving the current client and retry on a timer, until a grace period
  past the refetch interval, after which they raise.

- All workers refreshed in the same instant. They are forked together, so
  they mint credentials together and expire together. The interval is now
  jittered downwards, re-rolled per process.

- The interval was 55 minutes against a credential TTL of 1 hour for S3
  project-role connections (the response carries no expiry to read), which
  left 5 minutes to recover from a failed refresh. It is now 45 minutes,
  and the grace period above occupies the rest.

The per-request retry budget drops from 2880 to 4: a refresh runs inline on
a worker thread holding the client lock, so it has to return in seconds. The
grace period is what now rides out a longer outage.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…st client

The previous commit gave a failed *refresh* a grace period, because there are
working credentials to keep serving while it retries. Creating the *first*
client has no such fallback, so it went straight to the caller — and with the
per-request retry budget cut to 4, a control plane that was down at job start
now killed the job after ~157s rather than grinding on for days.

Creating the first client now retries on the same 60s timer, bounded by the
same 900s grace period, logging each attempt with how long it has been waiting
and that data loading is blocked meanwhile.

Retrying is only right for failures that can clear. A missing env var, an
absent data_connection_id, or a 4xx other than 408/429 will not fix itself, so
those raise `_CredentialsConfigurationError` and fail immediately on both
paths — otherwise a typo'd variable would hang a job for 15 minutes, and a
revoked grant would limp along until the credentials expired and surfaced as a
confusing S3 403.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Review of the previous commit found the retry loop was too indiscriminate, and
one of its tests could not run its own assertion on Windows.

Retrying was applied to anything `_create_client` raised, including failures
that never touch the network. A bogus `storage_options` key raised instantly on
main; with the retry loop it was logged as an outage and retried for the whole
budget while holding the client lock. Credential fetches now raise a
`_CredentialsError` — `_CredentialsUnavailableError` when the control plane or
IMDS could not be reached or returned a 5xx/429/408, `_CredentialsConfigurationError`
for missing config or a rejected 4xx — and only those are retried at all. Any
other exception is a local mistake and reaches the caller on the first attempt.

Initial creation retries only the unavailable kind; a refresh gives both kinds
the grace period, because it holds working credentials and a 403 mid-refresh
may be a proxy misbehaving during a deploy. A real revocation is still caught
by the deadline, one grace period later.

The two budgets are now separate. `_REFRESH_GRACE_PERIOD` drops to 600s so that
2700 + 600 leaves ~5 minutes under the 1 hour S3 TTL, instead of running exactly
to it and risking reads that fail as unexplained S3 403s. Waiting for the first
client has no TTL to respect, so it keeps 900s as `_INITIAL_RETRY_BUDGET`.

Also fixes `_CustomRetryAdapter.send` never applying `_DEFAULT_REQUEST_TIMEOUT`:
requests always passes `timeout` explicitly, as None when the caller gave none,
so `kwargs.get("timeout", self.timeout)` never saw its default and the login POST
had no timeout at all. That made the retry budget bound attempts but not wall
time — the premise this change set out to rely on.

Tests: `_client_with_failing_refresh` relied on `time() - _last_time > 0` being
true on the next call. Windows resolves `time.time()` to ~15ms before 3.13, so
both reads returned the same float, the refresh was never attempted and the
assertion never ran — on three CI matrix jobs. It now ages `_last_time` instead.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`streaming/client.py` imports `urllib3.util.retry.Retry` directly, but urllib3
appears nowhere in requirements.txt — it has only ever been present transitively.
The effective floor came from `requests` (>=1.21.1,<3) and `botocore`
(>=1.25.4,!=2.2.0,<3), so 1.25.4 through 1.25.11 resolve happily and lack both
`DEFAULT_ALLOWED_METHODS` and the `allowed_methods` kwarg, which arrived in
1.26.0. That was latent before; retrying the login POST makes it load-bearing.

The bound sits inside both existing constraints, so it excludes no version
anything else requires.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@codecov-commenter

codecov-commenter commented Aug 24, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 91.11111% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 82%. Comparing base (8805bae) to head (66c3ca7).
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@          Coverage Diff          @@
##            main    #891   +/-   ##
=====================================
  Coverage     82%     82%           
=====================================
  Files         65      65           
  Lines      13377   13439   +62     
=====================================
+ Hits       10949   11013   +64     
+ Misses      2428    2426    -2     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dhedey
dhedey merged commit ed1f1d2 into main Aug 24, 2026
35 checks passed
@dhedey
dhedey deleted the bugfix/s3-credential-refresh-reliability branch August 24, 2026 18:14
tchaton pushed a commit that referenced this pull request Aug 24, 2026
Release 0.2.72 with the S3 credential refresh reliability work (#891)
and the numpy deserialize copy fix (#887).

Also corrects the changelog: the multi-node FullShuffle fix and the
max_cache_size change were left under [unreleased] by #886, but #886 is
the commit v0.2.71 was tagged at, so they shipped in 0.2.71. Moved them
into that section and added the missing entry for #887.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
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.

3 participants