feat(auth): support shared token file for distributed workers#655
Conversation
Co-Authored-By: lino <lino@rapidata.ai>
Review: shared token file for distributed workersNice, focused solution to a real scaling problem (thundering-herd on the identity service). The plumbing through Correctness / robustness
Minor / consider
Test coverageThe repo has no automated test suite (no Everything elseEnv var fallback ordering, |
| response = requests.post( | ||
| TOKEN_URL, | ||
| data={ | ||
| "grant_type": "client_credentials", | ||
| "client_id": os.environ["RAPIDATA_CLIENT_ID"], | ||
| "client_secret": os.environ["RAPIDATA_CLIENT_SECRET"], | ||
| "scope": "openid roles email api", | ||
| }, | ||
| ) | ||
| response.raise_for_status() | ||
| token = response.json() | ||
| # Workers decide when to re-read the file based on this absolute | ||
| # timestamp — the relative expires_in is meaningless to a later reader. | ||
| token["expires_at"] = int(time.time()) + token["expires_in"] | ||
| return token |
There was a problem hiding this comment.
should this not use the sdk client methods?
There was a problem hiding this comment.
Good call — the SDK had no public way to get the token out, which is why the example went to the raw endpoint. Added RapidataClient.get_token() in 2efaa77: it returns the complete token object (with absolute expires_at), refreshing it first if expired, so the coordinator example is now fully SDK-native. Verified the refresh path: authlib stores grant_type=client_credentials in the session metadata on the initial fetch, so ensure_active_token re-fetches on demand.
Co-Authored-By: lino <lino@rapidata.ai>
OverviewThis PR adds a Potential correctness issues
Minor / polish
Docs
Test coverageNo automated tests are included (the repo doesn't appear to have a test suite at all currently, so this isn't a regression in convention), but this is an auth-critical, security-sensitive code path - a couple of unit tests would be valuable and cheap:
SecurityNo credentials are logged; workers never receive the client secret, which is the stated goal. The atomic-write documentation appropriately warns against partially-written files. No other security concerns spotted. Nice, well-motivated feature with solid docs. The main thing I'd want resolved before merge is confirming behavior of reassigning |
| ## Workers | ||
|
|
||
| Pass the file path to the client, or set the `RAPIDATA_TOKEN_FILE` | ||
| environment variable and construct the client with no arguments: | ||
|
|
||
| ```python | ||
| from rapidata import RapidataClient | ||
|
|
||
| client = RapidataClient(token_file="/shared/rapidata_token.json") | ||
| ``` | ||
|
|
||
| The SDK reads the token from the file at startup and re-reads it whenever the | ||
| in-memory token is within 60 seconds of expiry (configurable via `leeway`). | ||
| As long as the coordinator keeps the file fresh, workers never need to be | ||
| restarted or re-authenticated. | ||
|
|
||
| ## Coordinator | ||
|
|
||
| The coordinator authenticates with the client credentials as usual (see | ||
| [Authentication](authentication.md)), exports its token with `get_token()`, | ||
| and rewrites the shared file ahead of expiry: | ||
|
|
||
| ```python | ||
| import json | ||
| import os | ||
| import time | ||
|
|
||
| from rapidata import RapidataClient | ||
|
|
||
| TOKEN_FILE = "/shared/rapidata_token.json" | ||
|
|
||
| coordinator = RapidataClient() # (1)! | ||
|
|
||
|
|
||
| def write_token(token: dict) -> None: | ||
| tmp_path = TOKEN_FILE + ".tmp" | ||
| with open(tmp_path, "w") as f: | ||
| json.dump(token, f) | ||
| # Atomic replace: a worker reading concurrently sees either the old | ||
| # token or the new one, never a partially written file. | ||
| os.replace(tmp_path, TOKEN_FILE) | ||
|
|
||
|
|
||
| while True: | ||
| token = coordinator.get_token() # (2)! | ||
| write_token(token) | ||
| # Rewrite 5 minutes before expiry so workers never read a stale token. | ||
| time.sleep(max(token["expires_at"] - time.time() - 300, 60)) | ||
| ``` |
There was a problem hiding this comment.
can you swap the coordinator and worker around, since the coordinator has to be started first, i think it makes more sense?
additionally add a os.makedir for the /shared and add a note that in this example it's gonna be added as a root file
There was a problem hiding this comment.
Done in 6a637e4 — coordinator section now comes first, the example creates the directory with os.makedirs(..., exist_ok=True), and an annotation notes that the root-level /shared is just for the example (point it at whatever storage the workers actually mount).
Your sleep(1) test also surfaced something worth fixing: get_token() intentionally returns the cached token while it's valid and only refreshes within leeway of expiry (refreshing every call would be the stampede all over again) — but leeway was silently ignored on the client-credentials path, so the example's '5 minutes early' promise couldn't actually hold. Now plumbed through: the coordinator example uses RapidataClient(leeway=300) + a simple time.sleep(60) loop, which renews the file 5 minutes before expiry.
…d docs Co-Authored-By: lino <lino@rapidata.ai>
|
Review: feat(auth): support shared token file for distributed workers Overview Strengths
Issues / Suggestions
Summary |
| from rapidata import RapidataClient | ||
|
|
||
| TOKEN_FILE = "/shared/rapidata_token.json" # (1)! | ||
|
|
||
| coordinator = RapidataClient(leeway=300) # (2)! | ||
|
|
||
| os.makedirs(os.path.dirname(TOKEN_FILE), exist_ok=True) | ||
|
|
||
|
|
||
| def write_token(token: dict) -> None: | ||
| tmp_path = TOKEN_FILE + ".tmp" | ||
| with open(tmp_path, "w") as f: | ||
| json.dump(token, f) | ||
| # Atomic replace: a worker reading concurrently sees either the old | ||
| # token or the new one, never a partially written file. | ||
| os.replace(tmp_path, TOKEN_FILE) | ||
|
|
||
|
|
||
| while True: | ||
| write_token(coordinator.get_token()) # (3)! | ||
| time.sleep(60) |
There was a problem hiding this comment.
I'm wondering whether we want to implement this as well. I mean it's quite boilerplatey. We could have a method that can take a path and a duration and we would continously write to it. If the customer would want to write the token differently they could still write this part themselves. But i think having a default implementation would make adoption of this pattern easier
There was a problem hiding this comment.
Agreed — added RapidataClient.maintain_token_file(path, interval=60) in 36c887e. It writes the file once synchronously (so it's guaranteed to exist — or fail loudly — before workers start), creates the directory, then keeps rewriting it atomically from a daemon thread; transient failures inside the loop are logged and retried on the next interval instead of killing the thread. It returns the thread so a dedicated coordinator can just .join() it, while a rank-0-that-also-trains drops the join. The manual loop moved to a 'Writing the file yourself' subsection for custom setups.
| 1. For simplicity this example writes to a root-level `/shared` directory — | ||
| point it at whatever storage all your workers actually mount (an NFS | ||
| path, a shared volume, …). | ||
| 2. The coordinator is the only process that holds the client credentials — | ||
| via `RAPIDATA_CLIENT_ID` / `RAPIDATA_CLIENT_SECRET` or the cached | ||
| credentials file. `leeway=300` treats the token as expired once it is | ||
| within 5 minutes of its actual expiry, so the file is renewed well before | ||
| workers need it. | ||
| 3. `get_token()` returns the current token — including the absolute | ||
| `expires_at` timestamp workers rely on — and only fetches a new one when | ||
| it is within `leeway` of expiry. Polling it every minute therefore does | ||
| **not** spam the auth server; most iterations just rewrite the same token. |
There was a problem hiding this comment.
It's also important to mention that doing this exposes the token and the path where the token is written to should be kept secure from unauthorized access. Yes it's obvious but its important to state the obviousness in these sitatuiosn
There was a problem hiding this comment.
Added a dedicated warning admonition in 36c887e: the file contains a bearer token, anyone who can read it can act on the account until expiry, so write it only to storage the training job alone can access and restrict permissions. Also noted in the maintain_token_file docstring.
| 3. `get_token()` returns the current token — including the absolute | ||
| `expires_at` timestamp workers rely on — and only fetches a new one when | ||
| it is within `leeway` of expiry. Polling it every minute therefore does | ||
| **not** spam the auth server; most iterations just rewrite the same token. |
There was a problem hiding this comment.
This is an implementation detail that only concerns us, not our customers. This documentation should be written with details useful for the customer. The point it makes is valid but it can use some rewording
There was a problem hiding this comment.
Reworded in 36c887e — the annotation now frames it from the customer's side: get_token() is cheap to call at any frequency; it returns the current token and only contacts the auth server once the token is within leeway of expiry. (With maintain_token_file as the headline API, the manual loop and this note now live in the 'Writing the file yourself' subsection.)
Co-Authored-By: lino <lino@rapidata.ai>
Review: feat(auth): support shared token file for distributed workersNice feature — clean plumbing through Potential bug: token may not survive a second reload cycle
with open(token_file) as f:
self.session.token = json.load(f)This assigns a plain token = self.session.token
if token is not None and not token.is_expired(leeway=self._token_leeway):
return
Suggested fix: be explicit regardless of authlib's internal coercion behavior — from authlib.oauth2.rfc6749 import OAuth2Token
...
self.session.token = OAuth2Token(json.load(f))and add a smoke test that drives two reload cycles (expire → reload → expire → reload) to lock in the behavior. Robustness: hot-path reload has no error handling
Minor / non-blocking
Nice catches
Test coverageThe repo has no existing unit test suite (no SecurityThe "authenticate once, share a bearer token via file" model is a reasonable trade-off for the auth-server thundering-herd problem described in the PR, and the risk (anyone with file read access can act as the client until expiry) is called out clearly in the new docs page. No token values are logged in the diff. No other concerns spotted. |
Why
A customer running the SDK (ranking flows) across thousands of GPU workers hit auth-scale problems: every worker's
RapidataClient()exchanged the client credentials for its own token at startup, and since all tokens expire together (~1 h), all workers re-hit the identity service in the same instant. They worked around it with one coordinator process that refreshes the token and writes it to a shared file, with workers reusing it.The SDK already half-supports this via the
token=dict param — but a pre-supplied client-credentials token has no refresh path in authlib (norefresh_token, nogrant_typemetadata), so once it expires every request raisesInvalidTokenErrorand the client must be rebuilt.What
RapidataClient(token_file=...)(orRAPIDATA_TOKEN_FILEenv var): the client loads the token JSON from the file at startup and re-reads the file whenever the in-memory token is withinleeway(60 s) of expiry. Workers never hold the client secret and never need to be reconstructed — the coordinator just keeps the file fresh.OpenAPIService→RESTClientObject.setup_oauth_with_token(bothrest.pyand itsrest.mustachetemplate, per repo convention).expires_at, atomic temp-file +os.replacewrites), worker usage, startup-jitter tip, and the recreate-the-client fallback for older SDK versions. Cross-linked from the Authentication guide.Verification
pyright src/rapidata/rapidata_client src/rapidata/service: 0 errorsblack --checkclean on touched files undersrc/rapidata/rapidata_clientRapidataClient()construction viaRAPIDATA_TOKEN_FILEalone (no client credentials) worksuv run --group docs mkdocs buildsucceeds; new page renders in nav🔗 Session: https://session-43887903.poseidon.rapidata.internal/
🤖 Generated with Claude Code