Skip to content

fix(oauth): serialize revoke with refresh, bound token requests, typed OAuth error codes - #617

Open
hogan-yuan wants to merge 5 commits into
mainfrom
fix/oauth-revoke-race-java-close
Open

hogan-yuan wants to merge 5 commits into
mainfrom
fix/oauth-revoke-race-java-close

Conversation

@hogan-yuan

@hogan-yuan hogan-yuan commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Summary

Follow-up to #616, covering the findings of two post-merge review rounds. revoke / clear are still unreleased, so their CHANGELOG [Unreleased] entries are amended in place.

Concurrency and timeouts (Rust core, all SDKs)

  • OAuth::revoke holds the token lock for the whole request. Previously a concurrent access_token() refresh could rotate the refresh token after revoke had read it. The request then revoked the stale token, the server answered 200 (RFC 7009 accepts invalid tokens), and the new refresh token stayed valid, so the caller believed it had disconnected. access_token() already holds the same lock across its refresh.
  • Token refresh and revocation are both bounded by a 30-second timeout (TOKEN_REQUEST_TIMEOUT). Both run under the token lock, and the oauth2 reqwest client has no timeout of its own, so a hung request used to block every request that needs a token, plus revoke() / clear(), indefinitely.
  • The oauth2 client is built (possibly probing the region) before the lock is taken.
  • Trade-off: if the server rotates the refresh token but its response arrives after the timeout, the new token is lost and the next refresh fails with invalid_grant. A hung refresh while building the client now falls back to browser authorization after 30 s instead of never returning.
  • Tests:
    • revoke_holds_token_lock_for_the_whole_request uses an injected HTTP client and fails on the previous implementation.
    • revoke_times_out_and_releases_the_token_lock uses a paused tokio clock (test-util dev-feature).

Typed OAuth error codes (all SDKs except Node.js)

  • OAuthError::code() gives every variant a stable numeric code. Codes are documented in a table and never reused, e.g. 1 no token, 3 refresh failed, 4 revoke failed, 16 token file delete failed.
  • Error::OAuth and HttpClientError::OAuth now hold the typed OAuthError, and SimpleError::OAuth { code, message } carries the code. C (lb_error_code), C++ (Status::code()), Java (OpenApiException.getCode()) and Python (OpenApiException.code) therefore expose it with no binding-specific code.
  • A request that fails because the access token cannot be obtained now reports kind OAuth with its code, instead of kind Other.
  • Breaking (Rust): the shapes of Error::OAuth, SimpleError::OAuth and HttpClientError::OAuth change. Display output is unchanged. The next release is already a major one because of feat(oauth): add OAuth revoke/clear and use PKCE in the authorization flow #616.
  • The code table is documented on OAuthError::code, lb_error_code (and so longbridge.h), C++ Status::code(), Java OpenApiException.getCode() and the Python stub (code: Optional[int], ErrorKind.OAuth). Binding exception text that includes the code changes (Python str(), Java toString()).
  • Node.js still reports all errors as plain messages. Exposing codes there needs a napi error type of its own and is left for a separate change.

Java

  • OAuth.revoke() / clear() are synchronized against close(), which prevents a use-after-free of the native handle.
  • GridContext.create / FundContext.create now read the Config handle inside synchronized (config), like every other context.
  • The futures of OAuthBuilder.build(), revoke() and clear() complete exceptionally with OpenApiException (kind OAuth, with the code) instead of a bare RuntimeException. Breaking for build(): only code that inspects the failure cause changes; join() / get() still wrap it in CompletionException / ExecutionException.

Test plan

  • cargo test -p longbridge-oauth (33 unit tests + 2 doctests; every OAuth error code asserted), cargo test -p longbridge-httpcli, cargo test -p longbridge --lib (incl. new oauth_errors_keep_their_code)
  • cargo check of longbridge-c, longbridge-java, longbridge-python, longbridge-nodejs; cargo build -p longbridge-c (regenerated longbridge.h matches the committed one, which now documents the OAuth codes on lb_error_code)
  • cargo clippy --all --all-features: no warnings in touched files
  • cargo +nightly fmt --all
  • All Java sources compile with javac --release 9

🤖 Generated with Claude Code

…ear against close

- Rust: OAuth::revoke holds the token lock for the whole request, so a
  concurrent access_token refresh cannot rotate the refresh token between
  reading and revoking it (revoking the stale token while the new one
  stays valid)
- Java: OAuth.revoke()/clear() are synchronized with close(), so a
  concurrent close() cannot free the native handle before the JNI call
  clones it (use-after-free); their errors now surface as
  OpenApiException like other SDK calls
…ken lock

- the revocation request now runs under a 30s timeout: it holds the
  token lock, so a hung request would otherwise block access_token()
  and every request that needs a token
- build the oauth2 client (which may probe the region) before taking
  the lock
- split out revoke_with() taking an injectable HTTP client and add a
  test asserting the lock is held while the request is in flight
…oss SDKs

- token refresh runs under the token lock like revoke, so bound it with
  the same 30s timeout (TOKEN_REQUEST_TIMEOUT); add a paused-clock test
  for the revoke timeout releasing the lock
- OAuthError::code() gives every variant a stable numeric code;
  Error::OAuth / HttpClientError::OAuth hold the typed OAuthError and
  SimpleError::OAuth carries the code, so C/C++/Java/Python callers can
  tell no-token, refresh, revoke and file failures apart; a request that
  cannot obtain a token reports kind OAuth instead of Other
- Java: OAuthBuilder.build() fails with OpenApiException like
  revoke()/clear(); GridContext/FundContext.create read the Config
  handle inside synchronized (config)
@hogan-yuan
hogan-yuan force-pushed the fix/oauth-revoke-race-java-close branch from 2cfb197 to 642c868 Compare November 4, 2026 08:42
@hogan-yuan hogan-yuan changed the title fix(oauth): serialize revoke with token refresh; guard Java revoke/clear against close fix(oauth): serialize revoke with refresh, bound token requests, typed OAuth error codes Nov 4, 2026
…g wording

- lb_error_code documents the OAuth code table (propagated to
  longbridge.h); C++ Status::code() points to it
- openapi.pyi: OpenApiException.code is Optional[int] and ErrorKind has
  the OAuth member
- OAuthBuilder.build() javadoc names the failure type
- assert every OAuthError code, not a sample
- CHANGELOG: the Java change is the future's failure cause, not a thrown
  exception; note binding exception text changes and the refresh-timeout
  trade-off
The stub declared __init__(code, message) and no trace_id; the class in
longbridge/__init__.py takes (kind, code, trace_id, message).
@hogan-yuan hogan-yuan mentioned this pull request Nov 4, 2026
5 tasks done

This branch has not been deployed

No deployments
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