Skip to content

fix(clients): honour token_provider on the SSE-backed clients and never hang on a dead stream - #200

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

fix(clients): honour token_provider on the SSE-backed clients and never hang on a dead stream#200
jfrench9 merged 1 commit into
mainfrom
bugfix/sse-token-provider

Conversation

@jfrench9

Copy link
Copy Markdown
Member

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.pyresolve_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.pyevent_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.

…er hang on a dead stream

The facade accepted a `token_provider`, but only the GraphQL facades used
it: OperatorClient, OperationClient and QueryClient captured `token` /
`headers` at construction for every REST call and every SSE connect. The
backend revokes the previous JWT on each session refresh, so a rotating
credential left those three clients dead after the first rotation — and a
stream that then failed to open emitted the transport Exception, which the
dict-only `on_error` handlers choked on inside `emit`, leaving the wait
loop spinning forever. Python twin of typescript-client #206.

- token_utils.resolve_auth_headers builds request/stream headers per call:
  static headers unchanged without a provider, the provider's current
  credential (routed by shape) replacing any stale auth header with one.
  apply_auth_header is the single routing rule; auth_integration aliases it.
- Operator/Query/Operation clients build a fresh Client and SSEConfig from
  it per call and per connect.
- OperatorClient splits run errors from transport errors and, when the
  stream gives no verdict, follows the run over /v1/operations/{id}/status
  (poll_interval; definitive 4xx ends it, transient failures retried).
  error_details passes through on every result path.
- Operator/Query handlers accept Exception payloads, register
  error/max_retries_exceeded, take the cancellation payload, and raise when
  the stream ends without a result instead of spinning or returning None.
@jfrench9
jfrench9 merged commit 8cbbe12 into main Aug 29, 2026
4 checks passed
@jfrench9
jfrench9 deleted the bugfix/sse-token-provider branch August 29, 2026 19:05
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