fix(clients): resolve the SSE token per connect via tokenProvider - #206
Merged
Conversation
The stream endpoint authenticates the JWT it finds in the URL, and the
backend revokes the previous JWT on every session refresh. SSEClient
captured `token` once at construction, so the first refresh (~25 min in
the browser apps) left every later stream attempt carrying a dead
credential: the operator POST succeeded through the REST interceptor,
the run executed and billed, and the console reported "SSE connection
failed before open" with nobody attached.
- SSEClient resolves the credential inside `connect()` — `tokenProvider`
first, static `token` otherwise — so reconnects pick up rotation too.
- `tokenProvider` is threaded from RoboSystemsClients and the React
hooks into OperatorClient, OperationClient and QueryClient; core
already registers it via `setSDKClientConfig`.
- OperatorClient follows a queued run over `/v1/operations/{id}/status`
when it cannot open the stream, so an already-executing run is never
lost; `pollIntervalMs` tunes the fallback. `error_details` now passes
through on every result path.
- vitest resolves `.ts` before the CommonJS `.js` that `tsc` leaves next
to the sources, so local runs exercise the sources (and `vi.mock`)
like a fresh CI checkout does.
jfrench9
added a commit
to RoboFinSystems/robosystems-python-client
that referenced
this pull request
Aug 29, 2026
…er hang on a dead stream (#200) ## Summary Python counterpart of RoboFinSystems/robosystems-typescript-client#206. The facade already accepts a `token_provider`, but only the GraphQL facades (ledger / investor / library) consulted it — `OperatorClient`, `OperationClient` and `QueryClient` captured `token` / `headers` once at construction and reused them for every REST call and every SSE connect. The backend revokes the previous JWT on each session refresh, so for a rotating credential those three clients went dead after the first rotation (and a provider-only config never worked for them at all: `execute_query` raised `No API key provided`). It was worse than a 401: a stream that fails to open emits the transport `Exception` itself, and the operator / query `on_error` handlers did `err.get(...)` on it — the `AttributeError` was swallowed by `emit` (which only logs), `completed` never flipped, and `_wait_for_*` spun forever. `OperationClient` had already fixed this for itself; the fix never reached the other two. The `on_cancelled()` handlers took no argument, so a cancellation event hung the same way. The three clients now resolve the credential per call / per connect through the same `token_provider` the GraphQL facades use, transport errors end the wait, and `OperatorClient` follows a queued run over `/status` whenever the stream gives no verdict — so a run that is already executing (and billing) is never lost. ## Changes Hand-written facades under `robosystems_client/clients/` only; generated `api/` and `models/` untouched. - `token_utils.py` — `resolve_auth_headers(config)`: the static `headers` unchanged when no provider is set (today's behaviour byte-for-byte, plus the static `token` routed by shape when the headers carry no credential); with a provider, any `X-API-Key` / `Authorization` in the static headers is replaced by the provider's current credential. `apply_auth_header` moves here as the single routing rule; `auth_integration._apply_auth_header` is now a thin alias of it. - `sse_client.py` — `event_error_message(err)`: text of an `error` payload whether it is a terminal-event dict or a transport `Exception`. - `operator_client.py` — `_rest_client()` / `_sse_config()` build a fresh `Client` / `SSEConfig` per call and per connect from `resolve_auth_headers` (the REST path drops the `AuthenticatedClient(auth_header_name="X-API-Key")` form, which sent a JWT as an API key alongside the real Bearer header). `_wait_for_operator_completion` splits run errors (`operation_error` / `operation_cancelled` → raise) from transport errors (`error` / `max_retries_exceeded` → no verdict); with no verdict it calls `_poll_for_completion`, which polls `GET /v1/operations/{id}/status` until `completed` / `failed` / `cancelled`, ends on a definitive 4xx, retries up to three consecutive transient failures, and relays status messages to `on_progress`. New `OperatorOptions.poll_interval` (seconds, default 2.0) and `OperatorResult.error_details`, passed through on the sync and stream paths as well (`_operator_result` is the one mapper). `close()` now reaches an in-flight stream (the field it checked was never assigned). - `query_client.py` — same `_rest_client()` / `_sse_config()` per-call resolution; `on_error` accepts `Exception` payloads; `error` / `max_retries_exceeded` registered on both waits; the wait and the streaming generator raise when the stream ends without a verdict instead of spinning or returning `None`; `on_cancelled` takes the event payload. - `operation_client.py` — stream, status and cancel headers come from `resolve_auth_headers` per call (sync and async monitors). `self.headers` / `self.token` are kept as attributes. - `README.md` — "Rotating Credentials (`token_provider`)" section. - Tests — `test_auth_header_resolution.py` (resolver, routing alias, `event_error_message`, operation-client headers), `test_operator_client_ops.py` (stream completion, provider-at-connect for stream and REST, polling fallback for a stream that cannot open and one that ends early, run error, cancellation, failed status, definitive 404, transient retry, give-up, `error_details`), `test_query_client_sse.py` (completion, transport error, retries exhausted, no verdict, cancellation, provider-at-connect; same for the streaming generator). Sync paths covered; the async operation monitor got the header change but its wait logic is unchanged and untested here. ## Compatibility ADDITIVE - New: `token_utils.resolve_auth_headers`, `token_utils.apply_auth_header`, `sse_client.event_error_message`, `OperatorOptions.poll_interval`, `OperatorResult.error_details`. - Unchanged signatures and return types. Runtime behaviour differs only on paths that previously failed or hung: a queued operator run whose stream gives no verdict now resolves via `/status` instead of spinning; a transport error or cancellation on an operator/query stream now raises instead of hanging (or returning `None`); a `token_provider` is honoured by the operator / operations / query clients. With no provider, request and stream headers are exactly what they were. - Ships as a **minor** under the contract (additive stable-tier surface). Version bump is the release dispatch's job, not this PR's. ## Testing - `just test-all` equivalent run in-session: `ruff format --check` and `ruff check` clean, `basedpyright` 0 errors, `pytest` 558 passed / 17 skipped (35 added). - Not exercised against a live session; the failing prod sequence this mirrors is documented in the TypeScript PR.
This was referenced Aug 29, 2026
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.
Summary
Progress streams stopped authenticating after the first session refresh.
SSEClientcapturedtokenonce at construction, but the stream endpoint authenticates the JWT it finds in the URL and the backend revokes the previous JWT on every refresh — so in a tab open longer than ~25 minutes the operatorPOSTstill succeeded (the REST interceptor injects a fresh token per call), the run executed and billed, and the client rejected withSSE connection failed before openwhile nobody was attached. Confirmed in prod logs (auth_token_invalidon/v1/operations/{id}/streamseconds after a successful enqueue, minutes after asession_refreshrevocation).The credential is now resolved on every connect through the same
tokenProviderthe GraphQL facades already use, andOperatorClientfollows a queued run over/statuswhen it cannot open the stream at all, so an already-executing run is never lost.Changes
Hand-written facade only; generated
sdk/untouched.clients/SSEClient.ts—SSEConfig.tokenProvider;connect()resolves the credential per attempt (tokenProviderfirst, statictokenotherwise), so automatic reconnects pick up a rotation too. A throwing provider fails the connect with a descriptive error, matching the GraphQL client; anullresult connects without a token.clients/OperatorClient.ts—OperatorClientConfig(exported) gainstokenProvider. WhensseClient.connect()rejects,waitForOperatorCompletionfalls back to pollingGET /v1/operations/{id}/status(REST auth path) untilcompleted/failed/cancelled; a definitive 4xx ends the wait immediately, transient failures are retried up to three consecutive times, andonProgressreceives the status messages.OperatorOptions.pollIntervalMstunes the interval (default 2000 ms). The sync-200, stream, and polling paths now share one result mapper, which also passeserror_detailsthrough (OperatorResult.error_details) — the console already reads it.close()now actually closes the active stream (the field it checked was never assigned before).clients/OperationClient.ts,clients/QueryClient.ts— config types accepttokenProvider; it flows into theSSEClientthey construct.clients/index.ts—RoboSystemsClientsthreadstokenProviderintoquery,operator,operations, andcreateSSEClient(), not only the GraphQL facades.robosystems-corealready registers a provider viasetSDKClientConfig, so consumers pick this up with the version bump alone.clients/hooks.ts—useQuery,useStreamingQuery,useOperation,useMultipleOperations,useSDKClientspassgetSDKClientConfig().tokenProviderto the clients they build.clients/README.md— documents that the SSE-backed clients taketokenProviderand why a static token is not enough in the browser.SSEClientprovider resolution/rotation/null/throw;OperatorClientstream completion, provider-at-connect, polling fallback (running → completed), failed run, definitive 404, transient retry, give-up,error_detailspassthrough;RoboSystemsClientsthreading;useSDKClientspropagation.vitest.config.ts— resolves.tsbefore.js.tscemits CommonJS.jsnext to every source (gitignored, present after any local build) and Vite's default order picked the compiled file, wherevi.mockcannot interceptrequire()— the hooks suite had been running against the last build with its config mock inert (its mock exported a name the hooks never import, and nothing noticed). Local runs now match a fresh CI checkout.Compatibility
ADDITIVE
tokenProvideronSSEConfig,OperatorClientConfig(new export), and theOperationClient/QueryClientconstructor configs;OperatorOptions.pollIntervalMs;OperatorResult.error_details./statusinstead of rejecting, andOperatorClient.close()now closes an active stream.SSEClient.connect()was already async; it now awaits the credential before constructing theEventSource.Ships as a minor under the contract (additive stable-tier surface). Version bump is the release dispatch's job, not this PR's.
Testing
npm run test:allequivalent via the pre-commit hook: prettier, lint, typecheck clean; 313 tests passed across 10 files (15 added).npm run buildsucceeded.vi.mock-based test — meaningful after a local build; before it, the suite passed against stale compiled output.