Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ with no arguments. On a workstation, calling `RapidataClient()` with no
credentials instead opens a browser login once and caches the token in
`~/.config/rapidata/credentials.json`.

Running many workers in parallel (e.g. a distributed training job)? Don't
give each worker its own credentials — authenticate once and share the token
via a file, as described in [Distributed Training](distributed_training.md).

## Direct token request

To call the API without the SDK, request a token from the token endpoint and
Expand Down
139 changes: 139 additions & 0 deletions docs/distributed_training.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Distributed Training

When you use Rapidata from a large distributed job — for example a ranking
flow queried from hundreds or thousands of GPU workers — don't let every
worker authenticate on its own. Each `RapidataClient()` exchanges the client
credentials for an access token at startup, and because all those tokens
expire at the same time (~1 hour later), every worker hits the auth server
again in the same instant. At scale this looks like a coordinated burst and
leads to rate limiting and failed requests.

The fix is to authenticate **once** and share the token:

- A single **coordinator** process (your launcher, or rank 0) holds the
client credentials. It fetches the access token, refreshes it *before* it
expires, and writes it to a file on storage all workers can read (a shared
filesystem like NFS, or a mounted volume).
- Every **worker** points the SDK at that file. Workers never see the client
secret, make no auth-server calls of their own, and automatically re-read
the file when the current token expires.

## Coordinator

Start the coordinator first — it authenticates with the client credentials
as usual (see [Authentication](authentication.md)) and keeps the shared file
fresh with `maintain_token_file()`:

```python
from rapidata import RapidataClient

coordinator = RapidataClient(leeway=300) # (1)!
coordinator.maintain_token_file("/shared/rapidata_token.json").join() # (2)!
```

1. 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` renews the token once it is within
5 minutes of expiry, well before workers need a new one.
2. Writes the file immediately, then keeps rewriting it atomically from a
background thread (every 60 seconds by default), creating the directory
if needed. `.join()` blocks the process forever — drop it if your
coordinator also does other work, e.g. when rank 0 both trains and
refreshes the token. The root-level `/shared` is just for the example —
use whatever storage all your workers actually mount (an NFS path, a
shared volume, …).

!!! warning "Keep the token file secure"
The file contains a bearer token — anyone who can read it can act on
your Rapidata account until the token expires. Write it only to storage
that your training job alone can access, and restrict the file
permissions accordingly.

### Writing the file yourself

If the built-in helper doesn't fit (different storage backend, custom
rotation), export the token with `get_token()` and write it however you
like. `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.

```python
import json
import os
import time

from rapidata import RapidataClient

TOKEN_FILE = "/shared/rapidata_token.json"

coordinator = RapidataClient(leeway=300)

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())
time.sleep(60)
Comment on lines +65 to +85

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'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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

```

Two things to keep when rolling your own:

- **Write atomically** (temp file + `os.replace`, as above) so workers never
read a half-written file.
- **Keep the absolute `expires_at` field** in the JSON — it's how workers
decide when to re-read the file. If you produce the token without the SDK
(e.g. a `curl` call to the token endpoint), the response only carries the
relative `expires_in`, so add `expires_at` yourself.

## Workers

Once the coordinator has written the file, workers pass its 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.

## Practical tips

- **Stagger worker startup.** Even with a shared token, thousands of workers
constructing their first client and firing their first API call in the
same second is an unnecessary burst. A few seconds of random jitter before
the first call smooths it out.
- **Create one client per process and keep it.** The client refreshes its
token from the file transparently; there is no need to reconstruct it.
- **Keep the coordinator alive for the whole run.** If it stops refreshing,
workers fail with an invalid-token error once the token in the file
expires.

## Older SDK versions

Before `token_file` was available, the same pattern worked by passing the
token dict directly — but the SDK never re-reads the file, so each worker
must construct a new client when the token expires:

```python
import json

from rapidata import RapidataClient

with open("/shared/rapidata_token.json") as f:
client = RapidataClient(token=json.load(f))
```
5 changes: 4 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ plugins:
- mri_advanced.md
Flows:
- flows.md
- distributed_training.md
AI Agents:
- ai_agents.md
enable_markdown_urls: true
Expand Down Expand Up @@ -138,7 +139,9 @@ nav:
- Select Words: examples/select_words_job.md
- Free Text: examples/free_text_job.md
- Ranking: examples/ranking_job.md
- Ranking Flows: flows.md
- Ranking Flows:
- Getting Started: flows.md
- Distributed Training: distributed_training.md
- Model Ranking:
- Getting Started: mri.md
- Advanced: mri_advanced.md
Expand Down
41 changes: 40 additions & 1 deletion openapi/templates/rest.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,27 @@ class RESTClientObject:
self.configuration = configuration

self.session: Optional[OAuth2Client] = None
self._token_file: Optional[str] = None
self._token_leeway: int = 60

def setup_oauth_client_credentials(
self, client_id: str, client_secret: str, token_endpoint: str, scope: str
self,
client_id: str,
client_secret: str,
token_endpoint: str,
scope: str,
leeway: int = 60,
):
client_args = self._get_session_defaults()
self.session = OAuth2Client(
client_id=client_id,
client_secret=client_secret,
token_endpoint=token_endpoint,
scope=scope,
leeway=leeway,
**client_args,
)
self._token_leeway = leeway

try:
self.session.fetch_token()
Expand All @@ -85,6 +94,7 @@ class RESTClientObject:
token: dict,
token_endpoint: str,
leeway: int = 60,
token_file: str | None = None,
):
client_args = self._get_session_defaults()
self.session = OAuth2Client(
Expand All @@ -95,6 +105,32 @@ class RESTClientObject:
leeway=leeway,
**client_args,
)
self._token_file = token_file
self._token_leeway = leeway

def _reload_token_from_file_if_expired(self, token_file: str):
# A pre-supplied token has no refresh path of its own; an external
# process keeps the token file fresh, so re-read it once ours expires.
assert self.session is not None
token = self.session.token
if token is not None and not token.is_expired(leeway=self._token_leeway):
return
with open(token_file) as f:
self.session.token = json.load(f)

def get_token(self) -> dict:
"""Return the complete token object, refreshing it first if expired."""
if not self.session:
raise ApiValueError(
"OAuth2 session is not initialized. Please initialize it before making requests."
)
if self._token_file is not None:
self._reload_token_from_file_if_expired(self._token_file)
elif self.session.token is None or not self.session.ensure_active_token(
self.session.token
):
raise ApiValueError("The current token is expired and cannot be refreshed.")
return dict(self.session.token)

def request(
self,
Expand Down Expand Up @@ -135,6 +171,9 @@ class RESTClientObject:
"OAuth2 session is not initialized. Please initialize it before making requests."
)

if self._token_file is not None:
self._reload_token_from_file_if_expired(self._token_file)

timeout = self._build_timeout(_request_timeout)

for attempt in range(_TRANSIENT_RETRY_MAX_ATTEMPTS + 1):
Expand Down
41 changes: 40 additions & 1 deletion src/rapidata/api_client/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,27 @@ def __init__(self, configuration) -> None:
self.configuration = configuration

self.session: Optional[OAuth2Client] = None
self._token_file: Optional[str] = None
self._token_leeway: int = 60

def setup_oauth_client_credentials(
self, client_id: str, client_secret: str, token_endpoint: str, scope: str
self,
client_id: str,
client_secret: str,
token_endpoint: str,
scope: str,
leeway: int = 60,
):
client_args = self._get_session_defaults()
self.session = OAuth2Client(
client_id=client_id,
client_secret=client_secret,
token_endpoint=token_endpoint,
scope=scope,
leeway=leeway,
**client_args,
)
self._token_leeway = leeway

try:
self.session.fetch_token()
Expand All @@ -95,6 +104,7 @@ def setup_oauth_with_token(
token: dict,
token_endpoint: str,
leeway: int = 60,
token_file: str | None = None,
):
client_args = self._get_session_defaults()
self.session = OAuth2Client(
Expand All @@ -105,6 +115,32 @@ def setup_oauth_with_token(
leeway=leeway,
**client_args,
)
self._token_file = token_file
self._token_leeway = leeway

def _reload_token_from_file_if_expired(self, token_file: str):
# A pre-supplied token has no refresh path of its own; an external
# process keeps the token file fresh, so re-read it once ours expires.
assert self.session is not None
token = self.session.token
if token is not None and not token.is_expired(leeway=self._token_leeway):
return
with open(token_file) as f:
self.session.token = json.load(f)

def get_token(self) -> dict:
"""Return the complete token object, refreshing it first if expired."""
if not self.session:
raise ApiValueError(
"OAuth2 session is not initialized. Please initialize it before making requests."
)
if self._token_file is not None:
self._reload_token_from_file_if_expired(self._token_file)
elif self.session.token is None or not self.session.ensure_active_token(
self.session.token
):
raise ApiValueError("The current token is expired and cannot be refreshed.")
return dict(self.session.token)

def request(
self,
Expand Down Expand Up @@ -145,6 +181,9 @@ def request(
"OAuth2 session is not initialized. Please initialize it before making requests."
)

if self._token_file is not None:
self._reload_token_from_file_if_expired(self._token_file)

timeout = self._build_timeout(_request_timeout)

for attempt in range(_TRANSIENT_RETRY_MAX_ATTEMPTS + 1):
Expand Down
Loading
Loading