fix: Improve reliability of S3 credential refresh - #891
Merged
Conversation
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]>
tchaton
approved these changes
Aug 24, 2026
|
Codecov Report❌ Patch coverage is 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:
|
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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
S3Client/R2Clientrefetch 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:/v1/auth/loginPOST, which urllib3 was never retrying.storage_optionskey still raises at once.Root cause
_login_and_get_temp_bucket_credentialsmakes two calls, and mounts aRetryon the session for both. urllib3 defaultsallowed_methodsto 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 aJSONDecodeError, or as a missing-token error that named neither the status nor the login call.That failure then propagated out of the
clientproperty 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_timeonly advanced on success while_clientkept 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—_CredentialsUnavailableErrorwhen the control plane or IMDS was unreachable or answered 5xx/429/408,_CredentialsConfigurationErrorfor missing config or a rejected 4xx — and only those are retried. Anything else is a local mistake: a badstorage_optionskey 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_PERIODis 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_BUDGETkeeps 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.pyhas 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.sendusedkwargs.get("timeout", self.timeout), butrequestsalways passestimeoutexplicitly — asNonewhen 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.26inrequirements.txt.client.pyhas always importedurllib3.util.retry.Retrydirectly while urllib3 was only ever a transitive dependency, and the effective floor fromrequestsandbotocoreallows 1.25.x, which has neitherDEFAULT_ALLOWED_METHODSnor theallowed_methodskwarg — 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
printand a comment inget_r2_bucket_credentialsthat promised a fallback to hardcoded credentials that does not exist.Tests
Twenty new tests in
tests/streaming/test_client.pycover 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 theRuntimeErrorthe 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.pyandtest_fs_provider.pyare green locally (81 passed), serially and under CI's-n 2 --dist=loadgroup, as areruff,ruff formatandmypy.