Skip to content

fix(clients): resolve the SSE token per connect via tokenProvider - #206

Merged
jfrench9 merged 1 commit into
mainfrom
bugfix/sse-token-provider
Aug 29, 2026
Merged

fix(clients): resolve the SSE token per connect via tokenProvider#206
jfrench9 merged 1 commit into
mainfrom
bugfix/sse-token-provider

Conversation

@jfrench9

Copy link
Copy Markdown
Member

Summary

Progress streams stopped authenticating after the first session refresh. SSEClient captured token once 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 operator POST still succeeded (the REST interceptor injects a fresh token per call), the run executed and billed, and the client rejected with SSE connection failed before open while nobody was attached. Confirmed in prod logs (auth_token_invalid on /v1/operations/{id}/stream seconds after a successful enqueue, minutes after a session_refresh revocation).

The credential is now resolved on every connect through the same tokenProvider the GraphQL facades already use, and OperatorClient follows a queued run over /status when 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.tsSSEConfig.tokenProvider; connect() resolves the credential per attempt (tokenProvider first, static token otherwise), so automatic reconnects pick up a rotation too. A throwing provider fails the connect with a descriptive error, matching the GraphQL client; a null result connects without a token.
  • clients/OperatorClient.tsOperatorClientConfig (exported) gains tokenProvider. When sseClient.connect() rejects, waitForOperatorCompletion falls back to polling GET /v1/operations/{id}/status (REST auth path) until completed / failed / cancelled; a definitive 4xx ends the wait immediately, transient failures are retried up to three consecutive times, and onProgress receives the status messages. OperatorOptions.pollIntervalMs tunes the interval (default 2000 ms). The sync-200, stream, and polling paths now share one result mapper, which also passes error_details through (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 accept tokenProvider; it flows into the SSEClient they construct.
  • clients/index.tsRoboSystemsClients threads tokenProvider into query, operator, operations, and createSSEClient(), not only the GraphQL facades. robosystems-core already registers a provider via setSDKClientConfig, so consumers pick this up with the version bump alone.
  • clients/hooks.tsuseQuery, useStreamingQuery, useOperation, useMultipleOperations, useSDKClients pass getSDKClientConfig().tokenProvider to the clients they build.
  • clients/README.md — documents that the SSE-backed clients take tokenProvider and why a static token is not enough in the browser.
  • Tests — SSEClient provider resolution/rotation/null/throw; OperatorClient stream completion, provider-at-connect, polling fallback (running → completed), failed run, definitive 404, transient retry, give-up, error_details passthrough; RoboSystemsClients threading; useSDKClients propagation.
  • vitest.config.ts — resolves .ts before .js. tsc emits CommonJS .js next to every source (gitignored, present after any local build) and Vite's default order picked the compiled file, where vi.mock cannot intercept require() — 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

  • New optional config: tokenProvider on SSEConfig, OperatorClientConfig (new export), and the OperationClient / QueryClient constructor configs; OperatorOptions.pollIntervalMs; OperatorResult.error_details.
  • Existing calls are unchanged in signature and return type. Two runtime behaviors differ, both on paths that previously failed: a queued operator run whose stream cannot open now resolves via /status instead of rejecting, and OperatorClient.close() now closes an active stream. SSEClient.connect() was already async; it now awaits the credential before constructing the EventSource.

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:all equivalent via the pre-commit hook: prettier, lint, typecheck clean; 313 tests passed across 10 files (15 added). npm run build succeeded.
  • Note the vitest resolution change is what makes the new hooks assertion — and every vi.mock-based test — meaningful after a local build; before it, the suite passed against stale compiled output.
  • Not exercised end-to-end against a live session in this branch; the failing prod sequence it fixes is documented above from the API logs.

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
jfrench9 merged commit 108f515 into main Aug 29, 2026
5 checks passed
@jfrench9
jfrench9 deleted the bugfix/sse-token-provider branch August 29, 2026 18:53
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.
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.

1 participant