From 2abc8fb3b57f7b80033df7aa782afb641e52c893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 4 Nov 2026 11:31:06 +0800 Subject: [PATCH 1/5] fix(oauth): serialize revoke with token refresh; guard Java revoke/clear 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 --- CHANGELOG.md | 4 ++-- .../src/main/java/com/longbridge/OAuth.java | 6 +++-- java/src/oauth.rs | 4 ++-- rust/crates/oauth/src/client.rs | 23 +++++++++++-------- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7928c97e4..ba44a4359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **All SDKs:** `OAuth.revoke()` revokes the current token on the server through the existing RFC 7009 endpoint (`POST /oauth2/revoke`): the refresh token when present, otherwise the access token. It only contacts the server; the in-memory and cached tokens are left untouched. On a client created with `from_token` (no client ID) it fails with a `RevokeFailed` error instead of calling the server. +- **All SDKs:** `OAuth.revoke()` revokes the current token on the server through the existing RFC 7009 endpoint (`POST /oauth2/revoke`): the refresh token when present, otherwise the access token. It only contacts the server; the in-memory and cached tokens are left untouched. On a client created with `from_token` (no client ID) it fails with a `RevokeFailed` error instead of calling the server. The token lock is held for the whole request, so a concurrent token refresh cannot rotate the refresh token in between (which would revoke the stale token and leave the new one valid). - **All SDKs:** `OAuth.clear()` drops the token locally: it clears the in-memory copy and deletes the cached token file (`~/.longbridge/openapi/tokens/`; a missing file is not an error). It does not contact the server, so call `revoke()` first to disconnect the application completely. After `clear()`, `access_token()` fails with `NoTokenAvailable`. The persisted token is deleted first and the in-memory token is cleared only when that succeeds, so a failed delete leaves the handle usable and the error is returned. - - Rust: `OAuth::revoke` / `OAuth::clear` (async), `OAuthError::RevokeFailed`, `OAuthError::TokenFileDelete`. C: `lb_oauth_revoke` / `lb_oauth_clear`. C++: `OAuth::revoke` / `OAuth::clear`. Java: `OAuth.revoke()` / `OAuth.clear()` returning `CompletableFuture`. Node.js: `oauth.revoke()` / `oauth.clear()` returning promises. Python: `OAuth.revoke()` / `OAuth.clear()` plus `revoke_async()` / `clear_async()`; the `openapi.pyi` stub is updated. The blocking Python calls release the GIL while waiting. In C/C++, a NULL handle (e.g. a moved-from C++ `OAuth`) is reported through the callback as an error instead of crashing. + - Rust: `OAuth::revoke` / `OAuth::clear` (async), `OAuthError::RevokeFailed`, `OAuthError::TokenFileDelete`. C: `lb_oauth_revoke` / `lb_oauth_clear`. C++: `OAuth::revoke` / `OAuth::clear`. Java: `OAuth.revoke()` / `OAuth.clear()` returning `CompletableFuture`, which fails with `OpenApiException` like other SDK calls; both are `synchronized` with `close()`, so a concurrent `close()` cannot free the native handle while it is in use (a closed handle throws `IllegalStateException`). Node.js: `oauth.revoke()` / `oauth.clear()` returning promises. Python: `OAuth.revoke()` / `OAuth.clear()` plus `revoke_async()` / `clear_async()`; the `openapi.pyi` stub is updated. The blocking Python calls release the GIL while waiting. In C/C++, a NULL handle (e.g. a moved-from C++ `OAuth`) is reported through the callback as an error instead of crashing. - **Rust:** `TokenStorage::delete(client_id)` removes a persisted token. It has a default implementation that returns an error, so existing custom storage backends keep compiling but `OAuth::clear` fails loudly (and keeps the in-memory token) instead of reporting success without wiping anything; override it to support `clear`. `FileTokenStorage` deletes the token file. ### Breaking changes diff --git a/java/javasrc/src/main/java/com/longbridge/OAuth.java b/java/javasrc/src/main/java/com/longbridge/OAuth.java index 787637bc5..3538e65e2 100644 --- a/java/javasrc/src/main/java/com/longbridge/OAuth.java +++ b/java/javasrc/src/main/java/com/longbridge/OAuth.java @@ -57,8 +57,9 @@ long getRaw() { * (see {@link #clear()}). * * @return A Future that completes when the token has been revoked + * @throws IllegalStateException If the handle has already been closed */ - public CompletableFuture revoke() { + public synchronized CompletableFuture revoke() { long r = getRaw(); return AsyncCallback.executeTask((callback) -> { SdkNative.oauthRevoke(r, callback); @@ -74,8 +75,9 @@ public CompletableFuture revoke() { * contacted (see {@link #revoke()}). * * @return A Future that completes when the local token has been cleared + * @throws IllegalStateException If the handle has already been closed */ - public CompletableFuture clear() { + public synchronized CompletableFuture clear() { long r = getRaw(); return AsyncCallback.executeTask((callback) -> { SdkNative.oauthClear(r, callback); diff --git a/java/src/oauth.rs b/java/src/oauth.rs index 66d75e518..5668ada8c 100644 --- a/java/src/oauth.rs +++ b/java/src/oauth.rs @@ -81,7 +81,7 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_oauthRevoke( oauth .revoke() .await - .map_err(|e| crate::error::JniError::Other(e.to_string())) + .map_err(|e| longbridge::Error::OAuth(e.to_string()).into()) })?; Ok(()) }) @@ -101,7 +101,7 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_oauthClear( oauth .clear() .await - .map_err(|e| crate::error::JniError::Other(e.to_string())) + .map_err(|e| longbridge::Error::OAuth(e.to_string()).into()) })?; Ok(()) }) diff --git a/rust/crates/oauth/src/client.rs b/rust/crates/oauth/src/client.rs index cb70f4363..51e7b634f 100644 --- a/rust/crates/oauth/src/client.rs +++ b/rust/crates/oauth/src/client.rs @@ -127,6 +127,11 @@ impl OAuth { /// The refresh token is revoked when present, otherwise the access token. /// This only calls the server: the in-memory and persisted tokens are left /// untouched. Call [`clear`](OAuth::clear) afterwards to drop them locally. + /// + /// The token lock is held for the whole request, so a concurrent + /// [`access_token`](OAuth::access_token) refresh cannot rotate the refresh + /// token between reading and revoking it (which would revoke a stale token + /// while the new one stays valid). pub async fn revoke(&self) -> OAuthResult<()> { // `from_token` clients have no client ID: the token was issued // elsewhere, so the server cannot revoke it on our behalf. @@ -138,19 +143,16 @@ impl OAuth { }); } - let token = self - .0 - .token - .lock() - .await - .clone() - .ok_or(OAuthError::NoTokenAvailable)?; + let guard = self.0.token.lock().await; + let token = guard.as_ref().ok_or(OAuthError::NoTokenAvailable)?; - let revocable = match token.refresh_token { + let revocable = match &token.refresh_token { Some(refresh_token) => { - StandardRevocableToken::RefreshToken(RefreshToken::new(refresh_token)) + StandardRevocableToken::RefreshToken(RefreshToken::new(refresh_token.clone())) + } + None => { + StandardRevocableToken::AccessToken(AccessToken::new(token.access_token.clone())) } - None => StandardRevocableToken::AccessToken(AccessToken::new(token.access_token)), }; tracing::debug!(client_id = %self.0.client_id, "revoking OAuth token"); @@ -173,6 +175,7 @@ impl OAuth { OAuthError::RevokeFailed { message } })?; + drop(guard); tracing::info!(client_id = %self.0.client_id, "token revoked"); Ok(()) } From 623df05887f50b2d1fb3bd7190fe54df01a9ffc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 4 Nov 2026 11:48:17 +0800 Subject: [PATCH 2/5] fix(oauth): bound revoke with a timeout and test that it holds the token 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 --- CHANGELOG.md | 2 +- rust/crates/oauth/src/client.rs | 98 ++++++++++++++++++++++++++++----- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba44a4359..b709d988f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **All SDKs:** `OAuth.revoke()` revokes the current token on the server through the existing RFC 7009 endpoint (`POST /oauth2/revoke`): the refresh token when present, otherwise the access token. It only contacts the server; the in-memory and cached tokens are left untouched. On a client created with `from_token` (no client ID) it fails with a `RevokeFailed` error instead of calling the server. The token lock is held for the whole request, so a concurrent token refresh cannot rotate the refresh token in between (which would revoke the stale token and leave the new one valid). +- **All SDKs:** `OAuth.revoke()` revokes the current token on the server through the existing RFC 7009 endpoint (`POST /oauth2/revoke`): the refresh token when present, otherwise the access token. It only contacts the server; the in-memory and cached tokens are left untouched. On a client created with `from_token` (no client ID) it fails with a `RevokeFailed` error instead of calling the server. The token lock is held for the whole request, so a concurrent token refresh cannot rotate the refresh token in between (which would revoke the stale token and leave the new one valid). While it runs, `access_token()` and every request that needs a token wait for it; the request is bounded by a 30-second timeout (`RevokeFailed` on expiry). - **All SDKs:** `OAuth.clear()` drops the token locally: it clears the in-memory copy and deletes the cached token file (`~/.longbridge/openapi/tokens/`; a missing file is not an error). It does not contact the server, so call `revoke()` first to disconnect the application completely. After `clear()`, `access_token()` fails with `NoTokenAvailable`. The persisted token is deleted first and the in-memory token is cleared only when that succeeds, so a failed delete leaves the handle usable and the error is returned. - Rust: `OAuth::revoke` / `OAuth::clear` (async), `OAuthError::RevokeFailed`, `OAuthError::TokenFileDelete`. C: `lb_oauth_revoke` / `lb_oauth_clear`. C++: `OAuth::revoke` / `OAuth::clear`. Java: `OAuth.revoke()` / `OAuth.clear()` returning `CompletableFuture`, which fails with `OpenApiException` like other SDK calls; both are `synchronized` with `close()`, so a concurrent `close()` cannot free the native handle while it is in use (a closed handle throws `IllegalStateException`). Node.js: `oauth.revoke()` / `oauth.clear()` returning promises. Python: `OAuth.revoke()` / `OAuth.clear()` plus `revoke_async()` / `clear_async()`; the `openapi.pyi` stub is updated. The blocking Python calls release the GIL while waiting. In C/C++, a NULL handle (e.g. a moved-from C++ `OAuth`) is reported through the callback as an error instead of crashing. - **Rust:** `TokenStorage::delete(client_id)` removes a persisted token. It has a default implementation that returns an error, so existing custom storage backends keep compiling but `OAuth::clear` fails loudly (and keeps the in-memory token) instead of reporting success without wiping anything; override it to support `clear`. `FileTokenStorage` deletes the token file. diff --git a/rust/crates/oauth/src/client.rs b/rust/crates/oauth/src/client.rs index 51e7b634f..657f408a7 100644 --- a/rust/crates/oauth/src/client.rs +++ b/rust/crates/oauth/src/client.rs @@ -1,11 +1,11 @@ -use std::{fmt, sync::Arc}; +use std::{fmt, future::Future, sync::Arc, time::Duration}; use longbridge_geo::is_cn; use oauth2::{ - AccessToken, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, - PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, RevocationErrorResponseType, - RevocationUrl, Scope, StandardErrorResponse, StandardRevocableToken, TokenUrl, - basic::BasicClient, reqwest::async_http_client, + AccessToken, AuthUrl, AuthorizationCode, ClientId, CsrfToken, HttpRequest, HttpResponse, + PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, + RevocationErrorResponseType, RevocationUrl, Scope, StandardErrorResponse, + StandardRevocableToken, TokenUrl, basic::BasicClient, reqwest::async_http_client, }; use poem::listener::{Acceptor, Listener, TcpListener}; @@ -131,7 +131,9 @@ impl OAuth { /// The token lock is held for the whole request, so a concurrent /// [`access_token`](OAuth::access_token) refresh cannot rotate the refresh /// token between reading and revoking it (which would revoke a stale token - /// while the new one stays valid). + /// while the new one stays valid). While it runs, `access_token` (and every + /// request that needs a token) waits; the request is therefore bounded by a + /// 30-second timeout. pub async fn revoke(&self) -> OAuthResult<()> { // `from_token` clients have no client ID: the token was issued // elsewhere, so the server cannot revoke it on our behalf. @@ -143,6 +145,23 @@ impl OAuth { }); } + // Built before taking the lock: the first call may probe the region. + let client = create_oauth_client( + &self.0.client_id, + &format!("http://localhost:{}/callback", self.0.callback_port), + ) + .await; + self.revoke_with(&client, async_http_client).await + } + + /// Revoke through `http_client` (injectable for tests), holding the token + /// lock for the whole request. + async fn revoke_with(&self, client: &BasicClient, http_client: C) -> OAuthResult<()> + where + C: FnOnce(HttpRequest) -> F, + F: Future>, + RE: std::error::Error + 'static, + { let guard = self.0.token.lock().await; let token = guard.as_ref().ok_or(OAuthError::NoTokenAvailable)?; @@ -157,18 +176,20 @@ impl OAuth { tracing::debug!(client_id = %self.0.client_id, "revoking OAuth token"); - let client = create_oauth_client( - &self.0.client_id, - &format!("http://localhost:{}/callback", self.0.callback_port), - ) - .await; - client + let request = client .revoke_token(revocable) .map_err(|e| OAuthError::RevokeFailed { message: e.to_string(), })? - .request_async(async_http_client) + .request_async(http_client); + tokio::time::timeout(REVOKE_TIMEOUT, request) .await + .map_err(|_| { + tracing::error!(client_id = %self.0.client_id, "token revocation timed out"); + OAuthError::RevokeFailed { + message: format!("timed out after {}s", REVOKE_TIMEOUT.as_secs()), + } + })? .map_err(|e| { let message = revoke_error_message(&e); tracing::error!(client_id = %self.0.client_id, error = %message, "failed to revoke token"); @@ -296,6 +317,9 @@ impl OAuth { } } +/// Upper bound for a revocation request, which holds the token lock. +const REVOKE_TIMEOUT: Duration = Duration::from_secs(30); + /// Describe a revocation failure, including the server's `error` and /// `error_description` (the `Display` of `ServerResponse` omits them). fn revoke_error_message( @@ -465,6 +489,54 @@ mod tests { })) } + #[tokio::test] + async fn revoke_holds_token_lock_for_the_whole_request() { + let oauth = OAuth(Arc::new(OAuthInner { + client_id: "my-client".to_string(), + callback_port: DEFAULT_CALLBACK_PORT, + storage: Arc::new(LegacyStorage), + token: tokio::sync::Mutex::new(Some(OAuthToken { + client_id: "my-client".to_string(), + access_token: "access".to_string(), + refresh_token: Some("refresh".to_string()), + expires_at: u64::MAX, + })), + })); + let client = build_oauth_client( + OAUTH_BASE_URL, + "my-client", + "http://localhost:60355/callback", + ); + + let (started_tx, started_rx) = tokio::sync::oneshot::channel::(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); + let http_client = move |req: HttpRequest| async move { + started_tx.send(req).unwrap(); + release_rx.await.unwrap(); + Ok::<_, std::io::Error>(HttpResponse { + status_code: oauth2::http::StatusCode::OK, + headers: Default::default(), + body: Vec::new(), + }) + }; + + let task = { + let oauth = oauth.clone(); + tokio::spawn(async move { oauth.revoke_with(&client, http_client).await }) + }; + + let req = started_rx.await.unwrap(); + let body = String::from_utf8(req.body).unwrap(); + assert!(body.contains("token=refresh"), "{body}"); + assert!(body.contains("token_type_hint=refresh_token"), "{body}"); + // A refresh (access_token) cannot run while the request is in flight. + assert!(oauth.0.token.try_lock().is_err()); + + release_tx.send(()).unwrap(); + task.await.unwrap().unwrap(); + assert!(oauth.0.token.try_lock().is_ok()); + } + #[tokio::test] async fn clear_keeps_memory_token_when_storage_delete_fails() { let oauth = oauth_with_storage(Arc::new(FailingDelete)); From 642c868817d8751db2fce58c7c71fa987499cf6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 4 Nov 2026 16:42:11 +0800 Subject: [PATCH 3/5] fix(oauth): bound refresh with a timeout; typed OAuth error codes across 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) --- CHANGELOG.md | 5 ++ c/src/oauth.rs | 10 +-- .../java/com/longbridge/OpenApiException.java | 13 ++-- .../java/com/longbridge/fund/FundContext.java | 2 +- .../java/com/longbridge/grid/GridContext.java | 2 +- java/src/error.rs | 2 +- java/src/oauth.rs | 6 +- python/pysrc/longbridge/openapi.pyi | 4 +- python/src/oauth.rs | 2 +- rust/crates/httpclient/src/error.rs | 4 +- rust/crates/httpclient/src/request.rs | 5 +- rust/crates/oauth/Cargo.toml | 2 +- rust/crates/oauth/src/client.rs | 69 ++++++++++++++++--- rust/crates/oauth/src/error.rs | 53 ++++++++++++++ rust/src/error.rs | 50 +++++++++++--- 15 files changed, 190 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b709d988f..f74bc7127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **All SDKs:** OAuth errors carry a stable numeric code (`OAuthError::code()` in Rust), exposed wherever the error kind is `OAuth`: `lb_error_code` (C), `Status::code()` (C++), `OpenApiException.getCode()` (Java) and `OpenApiException.code` (Python). Callers can tell failures apart without parsing messages, e.g. `1` = no token available, `3` = refresh failed, `4` = revoke failed, `16` = token file delete failed (full table on `OAuthError::code`). A request that fails because the access token cannot be obtained is reported the same way (kind `OAuth` with the code) instead of kind `Other`. The Node.js binding still reports errors as plain messages. - **All SDKs:** `OAuth.revoke()` revokes the current token on the server through the existing RFC 7009 endpoint (`POST /oauth2/revoke`): the refresh token when present, otherwise the access token. It only contacts the server; the in-memory and cached tokens are left untouched. On a client created with `from_token` (no client ID) it fails with a `RevokeFailed` error instead of calling the server. The token lock is held for the whole request, so a concurrent token refresh cannot rotate the refresh token in between (which would revoke the stale token and leave the new one valid). While it runs, `access_token()` and every request that needs a token wait for it; the request is bounded by a 30-second timeout (`RevokeFailed` on expiry). - **All SDKs:** `OAuth.clear()` drops the token locally: it clears the in-memory copy and deletes the cached token file (`~/.longbridge/openapi/tokens/`; a missing file is not an error). It does not contact the server, so call `revoke()` first to disconnect the application completely. After `clear()`, `access_token()` fails with `NoTokenAvailable`. The persisted token is deleted first and the in-memory token is cleared only when that succeeds, so a failed delete leaves the handle usable and the error is returned. - Rust: `OAuth::revoke` / `OAuth::clear` (async), `OAuthError::RevokeFailed`, `OAuthError::TokenFileDelete`. C: `lb_oauth_revoke` / `lb_oauth_clear`. C++: `OAuth::revoke` / `OAuth::clear`. Java: `OAuth.revoke()` / `OAuth.clear()` returning `CompletableFuture`, which fails with `OpenApiException` like other SDK calls; both are `synchronized` with `close()`, so a concurrent `close()` cannot free the native handle while it is in use (a closed handle throws `IllegalStateException`). Node.js: `oauth.revoke()` / `oauth.clear()` returning promises. Python: `OAuth.revoke()` / `OAuth.clear()` plus `revoke_async()` / `clear_async()`; the `openapi.pyi` stub is updated. The blocking Python calls release the GIL while waiting. In C/C++, a NULL handle (e.g. a moved-from C++ `OAuth`) is reported through the callback as an error instead of crashing. @@ -15,10 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking changes +- **Rust:** `Error::OAuth` now holds the typed `longbridge::oauth::OAuthError` instead of a `String` (it is also `From`), `SimpleError::OAuth` becomes `SimpleError::OAuth { code, message }`, and `HttpClientError::OAuth` holds an `OAuthError`. Code that constructs or destructures these variants needs updating; `Display` output is unchanged. +- **Java:** `OAuthBuilder.build()` now fails with `OpenApiException` (kind `OAuth`, with the code above) instead of a bare `RuntimeException`, like `revoke()` / `clear()` and other SDK calls. Code catching `RuntimeException` around it should catch `OpenApiException`. - **Rust:** `OAuthError` is now `#[non_exhaustive]` and gains `RevokeFailed` and `TokenFileDelete`. Exhaustive `match`es on `OAuthError` outside this crate need a wildcard arm; adding variants will no longer be a breaking change. ### Fixed +- **All SDKs:** an OAuth token refresh is bounded by a 30-second timeout (`RefreshTokenFailed: timed out after 30s`). It runs under the token lock, so a hung refresh previously blocked every request needing a token, and `revoke()` / `clear()`, indefinitely. +- **Java:** `GridContext.create` / `FundContext.create` read the `Config` native handle inside `synchronized (config)`, like every other context, so a concurrent `Config.close()` can no longer free it while it is being used. - **All SDKs:** the OAuth browser authorization flow now uses PKCE (RFC 7636, `S256`): the authorization request carries a `code_challenge` and the token exchange sends the matching `code_verifier`. The server requires PKCE for `public` clients, so previously such clients (e.g. those created through dynamic client registration) could not authorize through the SDK and failed with `OAuth authorization failed: server_error`. Clients that don't require PKCE are unaffected. ## [5.3.0] - 2026-10-23 diff --git a/c/src/oauth.rs b/c/src/oauth.rs index d27d650a7..81fd41c5e 100644 --- a/c/src/oauth.rs +++ b/c/src/oauth.rs @@ -55,7 +55,7 @@ pub unsafe extern "C" fn lb_oauth_new( open_url_callback(c_url.as_ptr(), open_url_userdata_usize as *mut c_void); }) .await - .map_err(|e| longbridge::Error::OAuth(e.to_string()))?; + .map_err(longbridge::Error::OAuth)?; Ok(Box::into_raw(Box::new(COAuth { inner: oauth }))) }); } @@ -86,7 +86,9 @@ unsafe fn clone_inner(oauth: *const COAuth) -> Option { } fn null_oauth_error() -> longbridge::Error { - longbridge::Error::OAuth("OAuth client is NULL".to_string()) + longbridge::Error::OAuth(longbridge::oauth::OAuthError::Other( + "OAuth client is NULL".to_string(), + )) } /// Asynchronously revoke the current token on the server (RFC 7009) @@ -110,7 +112,7 @@ pub unsafe extern "C" fn lb_oauth_revoke( .ok_or_else(null_oauth_error)? .revoke() .await - .map_err(|e| longbridge::Error::OAuth(e.to_string()))?; + .map_err(longbridge::Error::OAuth)?; Ok(()) }); } @@ -136,7 +138,7 @@ pub unsafe extern "C" fn lb_oauth_clear( .ok_or_else(null_oauth_error)? .clear() .await - .map_err(|e| longbridge::Error::OAuth(e.to_string()))?; + .map_err(longbridge::Error::OAuth)?; Ok(()) }); } diff --git a/java/javasrc/src/main/java/com/longbridge/OpenApiException.java b/java/javasrc/src/main/java/com/longbridge/OpenApiException.java index 7e040265d..cc5492de6 100644 --- a/java/javasrc/src/main/java/com/longbridge/OpenApiException.java +++ b/java/javasrc/src/main/java/com/longbridge/OpenApiException.java @@ -7,8 +7,9 @@ * pieces of diagnostic information: *
    *
  • {@link #getKind()} – broad category of the error (HTTP, OpenAPI, OAuth, Other)
  • - *
  • {@link #getCode()} – numeric error code returned by the server, or {@code null} for - * client-side errors
  • + *
  • {@link #getCode()} – numeric error code: the HTTP status or OpenAPI code returned + * by the server, a stable OAuth error code for {@link ErrorKind#OAuth} (see + * {@link #getCode()}), or {@code null} for other client-side errors
  • *
  • {@link #getMessage()} – human-readable error description
  • *
*/ @@ -40,9 +41,13 @@ public ErrorKind getKind() { } /** - * Returns the numeric error code returned by the server. + * Returns the numeric error code. * - * @return error code, or {@code null} for client-side errors + *

+ * For {@link ErrorKind#OAuth} errors this is a stable code identifying the + * failure: 1 no token available, 2 no refresh token, 3 refresh failed, 4 revoke failed, 5 authorization failed, 6 authorization timeout, 7 code exchange failed, 8 CSRF mismatch, 9 callback bind failed, 10 local address failed, 11 callback channel closed, 12 no home dir, 13 token file read, 14 token file parse, 15 token file write, 16 token file delete, 17 create dir failed, 18 serialize token, 0 other. + * + * @return error code, or {@code null} for client-side errors other than OAuth */ public Long getCode() { return code; diff --git a/java/javasrc/src/main/java/com/longbridge/fund/FundContext.java b/java/javasrc/src/main/java/com/longbridge/fund/FundContext.java index 14597d95e..040c36393 100644 --- a/java/javasrc/src/main/java/com/longbridge/fund/FundContext.java +++ b/java/javasrc/src/main/java/com/longbridge/fund/FundContext.java @@ -26,7 +26,7 @@ private long raw() { */ public static FundContext create(Config config) { FundContext ctx = new FundContext(); - ctx.raw = SdkNative.newFundContext(config.getRaw()); + synchronized (config) { ctx.raw = SdkNative.newFundContext(config.getRaw()); } return ctx; } diff --git a/java/javasrc/src/main/java/com/longbridge/grid/GridContext.java b/java/javasrc/src/main/java/com/longbridge/grid/GridContext.java index 42f29f37d..fd8445a10 100644 --- a/java/javasrc/src/main/java/com/longbridge/grid/GridContext.java +++ b/java/javasrc/src/main/java/com/longbridge/grid/GridContext.java @@ -26,7 +26,7 @@ private long raw() { */ public static GridContext create(Config config) { GridContext ctx = new GridContext(); - ctx.raw = SdkNative.newGridContext(config.getRaw()); + synchronized (config) { ctx.raw = SdkNative.newGridContext(config.getRaw()); } return ctx; } diff --git a/java/src/error.rs b/java/src/error.rs index 0e8b3d43b..3f0302c7e 100644 --- a/java/src/error.rs +++ b/java/src/error.rs @@ -29,7 +29,7 @@ impl From for JniError { impl From for JniError { fn from(e: longbridge::oauth::OAuthError) -> Self { - JniError::Other(e.to_string()) + JniError::OpenApi(Box::new(longbridge::Error::OAuth(e))) } } diff --git a/java/src/oauth.rs b/java/src/oauth.rs index 5668ada8c..a80a4d804 100644 --- a/java/src/oauth.rs +++ b/java/src/oauth.rs @@ -50,7 +50,7 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_oauthBuild( } }) .await - .map_err(|e| crate::error::JniError::Other(e.to_string()))?; + .map_err(crate::error::JniError::from)?; Ok(JavaLong::from(Box::into_raw(Box::new(oauth)) as jlong)) })?; Ok(()) @@ -81,7 +81,7 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_oauthRevoke( oauth .revoke() .await - .map_err(|e| longbridge::Error::OAuth(e.to_string()).into()) + .map_err(|e| longbridge::Error::OAuth(e).into()) })?; Ok(()) }) @@ -101,7 +101,7 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_oauthClear( oauth .clear() .await - .map_err(|e| longbridge::Error::OAuth(e.to_string()).into()) + .map_err(|e| longbridge::Error::OAuth(e).into()) })?; Ok(()) }) diff --git a/python/pysrc/longbridge/openapi.pyi b/python/pysrc/longbridge/openapi.pyi index 11e6258a3..49c39e8c1 100644 --- a/python/pysrc/longbridge/openapi.pyi +++ b/python/pysrc/longbridge/openapi.pyi @@ -45,7 +45,9 @@ class OpenApiException(Exception): code: int """ - Error code + Error code: the HTTP status or OpenAPI code returned by the server, or for + ``ErrorKind.OAuth`` a stable code identifying the failure (1 no token available, 2 no refresh token, 3 refresh failed, 4 revoke failed, 5 authorization failed, 6 authorization timeout, 7 code exchange failed, 8 CSRF mismatch, 9 callback bind failed, 10 local address failed, 11 callback channel closed, 12 no home dir, 13 token file read, 14 token file parse, 15 token file write, 16 token file delete, 17 create dir failed, 18 serialize token, 0 other). + `None` for other client-side errors. """ message: str diff --git a/python/src/oauth.rs b/python/src/oauth.rs index f29f7ea0a..e847a14e6 100644 --- a/python/src/oauth.rs +++ b/python/src/oauth.rs @@ -4,7 +4,7 @@ use pyo3::prelude::*; use crate::error::ErrorNewType; fn oauth_err(e: OAuthError) -> PyErr { - ErrorNewType(longbridge::Error::OAuth(e.to_string())).into() + ErrorNewType(longbridge::Error::OAuth(e)).into() } /// OAuth 2.0 client handle for Longbridge OpenAPI diff --git a/rust/crates/httpclient/src/error.rs b/rust/crates/httpclient/src/error.rs index e19a27ec1..cc12e2f43 100644 --- a/rust/crates/httpclient/src/error.rs +++ b/rust/crates/httpclient/src/error.rs @@ -106,9 +106,9 @@ pub enum HttpClientError { online: i32, }, - /// OAuth error + /// OAuth error (e.g. the access token could not be obtained) #[error("oauth error: {0}")] - OAuth(String), + OAuth(#[from] longbridge_oauth::OAuthError), /// Server-sent events stream error #[error("sse stream error: {0}")] diff --git a/rust/crates/httpclient/src/request.rs b/rust/crates/httpclient/src/request.rs index ae9db9289..dc71aadbd 100644 --- a/rust/crates/httpclient/src/request.rs +++ b/rust/crates/httpclient/src/request.rs @@ -314,10 +314,7 @@ where DcRegion::from_credentials(&[app_key, access_token, app_secret]), ), AuthConfig::OAuth(oauth) => { - let token = oauth - .access_token() - .await - .map_err(|e| HttpClientError::OAuth(e.to_string()))?; + let token = oauth.access_token().await.map_err(HttpClientError::OAuth)?; // Derive DC region from the token prefix (us_→US, others→AP). // The token is sent as-is (including any prefix); the gateway // accepts the full token and routes via the x-dc-region header. diff --git a/rust/crates/oauth/Cargo.toml b/rust/crates/oauth/Cargo.toml index 288dc19f6..0035e6efd 100644 --- a/rust/crates/oauth/Cargo.toml +++ b/rust/crates/oauth/Cargo.toml @@ -21,4 +21,4 @@ tokio = { workspace = true, features = ["rt", "time", "sync", "net"] } tracing.workspace = true [dev-dependencies] -tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] } diff --git a/rust/crates/oauth/src/client.rs b/rust/crates/oauth/src/client.rs index 657f408a7..5c838d954 100644 --- a/rust/crates/oauth/src/client.rs +++ b/rust/crates/oauth/src/client.rs @@ -133,7 +133,7 @@ impl OAuth { /// token between reading and revoking it (which would revoke a stale token /// while the new one stays valid). While it runs, `access_token` (and every /// request that needs a token) waits; the request is therefore bounded by a - /// 30-second timeout. + /// 30-second timeout, as is a token refresh it may have to wait for. pub async fn revoke(&self) -> OAuthResult<()> { // `from_token` clients have no client ID: the token was issued // elsewhere, so the server cannot revoke it on our behalf. @@ -182,12 +182,12 @@ impl OAuth { message: e.to_string(), })? .request_async(http_client); - tokio::time::timeout(REVOKE_TIMEOUT, request) + tokio::time::timeout(TOKEN_REQUEST_TIMEOUT, request) .await .map_err(|_| { tracing::error!(client_id = %self.0.client_id, "token revocation timed out"); OAuthError::RevokeFailed { - message: format!("timed out after {}s", REVOKE_TIMEOUT.as_secs()), + message: format!("timed out after {}s", TOKEN_REQUEST_TIMEOUT.as_secs()), } })? .map_err(|e| { @@ -293,10 +293,20 @@ impl OAuth { &format!("http://localhost:{}/callback", self.0.callback_port), ) .await; - let token_response = client - .exchange_refresh_token(&RefreshToken::new(refresh_token_str.to_string())) - .request_async(async_http_client) + // Refreshes run under the token lock (see `access_token`), so a hung + // request must not hold it forever. + let refresh_token = RefreshToken::new(refresh_token_str.to_string()); + let request = client + .exchange_refresh_token(&refresh_token) + .request_async(async_http_client); + let token_response = tokio::time::timeout(TOKEN_REQUEST_TIMEOUT, request) .await + .map_err(|_| { + tracing::error!(client_id = %self.0.client_id, "token refresh timed out"); + OAuthError::RefreshTokenFailed { + message: format!("timed out after {}s", TOKEN_REQUEST_TIMEOUT.as_secs()), + } + })? .map_err(|e| { tracing::error!(client_id = %self.0.client_id, error = %e, "failed to refresh token"); OAuthError::RefreshTokenFailed { @@ -317,8 +327,8 @@ impl OAuth { } } -/// Upper bound for a revocation request, which holds the token lock. -const REVOKE_TIMEOUT: Duration = Duration::from_secs(30); +/// Upper bound for a refresh or revocation request; both hold the token lock. +const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// Describe a revocation failure, including the server's `error` and /// `error_description` (the `Display` of `ServerResponse` omits them). @@ -537,6 +547,49 @@ mod tests { assert!(oauth.0.token.try_lock().is_ok()); } + #[tokio::test(start_paused = true)] + async fn revoke_times_out_and_releases_the_token_lock() { + let oauth = oauth_with_storage(Arc::new(LegacyStorage)); + let client = build_oauth_client( + OAUTH_BASE_URL, + "my-client", + "http://localhost:60355/callback", + ); + // The request never completes; the paused clock auto-advances to the + // timeout. + let http_client = + |_req: HttpRequest| std::future::pending::>(); + + match oauth.revoke_with(&client, http_client).await { + Err(OAuthError::RevokeFailed { message }) => { + assert_eq!(message, "timed out after 30s") + } + other => panic!("unexpected result: {other:?}"), + } + assert_eq!(oauth.access_token().await.unwrap(), "access"); + } + + #[test] + fn oauth_error_codes_are_stable() { + assert_eq!(OAuthError::Other(String::new()).code(), 0); + assert_eq!(OAuthError::NoTokenAvailable.code(), 1); + assert_eq!( + OAuthError::RevokeFailed { + message: String::new() + } + .code(), + 4 + ); + assert_eq!( + OAuthError::TokenFileDelete { + path: Default::default(), + source: std::io::Error::other("x"), + } + .code(), + 16 + ); + } + #[tokio::test] async fn clear_keeps_memory_token_when_storage_delete_fails() { let oauth = oauth_with_storage(Arc::new(FailingDelete)); diff --git a/rust/crates/oauth/src/error.rs b/rust/crates/oauth/src/error.rs index 22fcddb5e..e5567f565 100644 --- a/rust/crates/oauth/src/error.rs +++ b/rust/crates/oauth/src/error.rs @@ -134,5 +134,58 @@ pub enum OAuthError { Other(String), } +impl OAuthError { + /// Stable numeric code identifying the kind of error, so that callers of + /// the language bindings (where the error crosses the FFI boundary as a + /// message plus a code) can tell failures apart without parsing messages. + /// + /// | Code | Variant | + /// |------|---------| + /// | 1 | [`NoTokenAvailable`](Self::NoTokenAvailable) | + /// | 2 | [`NoRefreshToken`](Self::NoRefreshToken) | + /// | 3 | [`RefreshTokenFailed`](Self::RefreshTokenFailed) | + /// | 4 | [`RevokeFailed`](Self::RevokeFailed) | + /// | 5 | [`AuthorizationFailed`](Self::AuthorizationFailed) | + /// | 6 | [`AuthorizationTimeout`](Self::AuthorizationTimeout) | + /// | 7 | [`ExchangeCodeFailed`](Self::ExchangeCodeFailed) | + /// | 8 | [`CsrfTokenMismatch`](Self::CsrfTokenMismatch) | + /// | 9 | [`BindCallbackFailed`](Self::BindCallbackFailed) | + /// | 10 | [`LocalAddressFailed`](Self::LocalAddressFailed) | + /// | 11 | [`CallbackChannelClosed`](Self::CallbackChannelClosed) | + /// | 12 | [`NoHomeDir`](Self::NoHomeDir) | + /// | 13 | [`TokenFileRead`](Self::TokenFileRead) | + /// | 14 | [`TokenFileParse`](Self::TokenFileParse) | + /// | 15 | [`TokenFileWrite`](Self::TokenFileWrite) | + /// | 16 | [`TokenFileDelete`](Self::TokenFileDelete) | + /// | 17 | [`CreateDirFailed`](Self::CreateDirFailed) | + /// | 18 | [`SerializeToken`](Self::SerializeToken) | + /// | 0 | [`Other`](Self::Other) | + /// + /// Codes are never reused; a new variant gets a new code. + pub fn code(&self) -> i64 { + match self { + OAuthError::Other(_) => 0, + OAuthError::NoTokenAvailable => 1, + OAuthError::NoRefreshToken => 2, + OAuthError::RefreshTokenFailed { .. } => 3, + OAuthError::RevokeFailed { .. } => 4, + OAuthError::AuthorizationFailed { .. } => 5, + OAuthError::AuthorizationTimeout => 6, + OAuthError::ExchangeCodeFailed { .. } => 7, + OAuthError::CsrfTokenMismatch => 8, + OAuthError::BindCallbackFailed { .. } => 9, + OAuthError::LocalAddressFailed => 10, + OAuthError::CallbackChannelClosed => 11, + OAuthError::NoHomeDir => 12, + OAuthError::TokenFileRead { .. } => 13, + OAuthError::TokenFileParse { .. } => 14, + OAuthError::TokenFileWrite { .. } => 15, + OAuthError::TokenFileDelete { .. } => 16, + OAuthError::CreateDirFailed { .. } => 17, + OAuthError::SerializeToken { .. } => 18, + } + } +} + /// Result type for OAuth operations pub type OAuthResult = std::result::Result; diff --git a/rust/src/error.rs b/rust/src/error.rs index 84edabfd8..ced88eeba 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -70,7 +70,7 @@ pub enum Error { /// OAuth error #[error("oauth error: {0}")] - OAuth(String), + OAuth(#[from] longbridge_oauth::OAuthError), /// A conversation event stream ended before a final result was observed #[error("conversation stream ended before a final result was observed")] @@ -118,6 +118,12 @@ impl Error { SimpleError::Other(err.to_string()) } } + Error::HttpClient(HttpClientError::OAuth(err)) | Error::OAuth(err) => { + SimpleError::OAuth { + code: err.code(), + message: err.to_string(), + } + } Error::WsClient(WsClientError::ResponseError { detail: Some(detail), .. @@ -138,7 +144,6 @@ impl Error { | Error::ConversationStreamEnded => SimpleError::Other(self.to_string()), #[cfg(feature = "blocking")] Error::Blocking(_) => SimpleError::Other(self.to_string()), - Error::OAuth(msg) => SimpleError::OAuth(msg), } } } @@ -169,8 +174,14 @@ pub enum SimpleError { #[error("other error: {0}")] Other(String), /// OAuth error - #[error("oauth error: {0}")] - OAuth(String), + #[error("oauth error: {message}")] + OAuth { + /// Stable error code, see + /// [`OAuthError::code`](longbridge_oauth::OAuthError::code) + code: i64, + /// Error message + message: String, + }, } impl From for SimpleError { @@ -200,7 +211,7 @@ impl SimpleError { SimpleError::Http { .. } => SimpleErrorKind::Http, SimpleError::OpenApi { .. } => SimpleErrorKind::OpenApi, SimpleError::Other(_) => SimpleErrorKind::Other, - SimpleError::OAuth(_) => SimpleErrorKind::OAuth, + SimpleError::OAuth { .. } => SimpleErrorKind::OAuth, } } @@ -210,7 +221,7 @@ impl SimpleError { SimpleError::Http { status_code } => Some(*status_code as i64), SimpleError::OpenApi { code, .. } => Some(*code), SimpleError::Other(_) => None, - SimpleError::OAuth(_) => None, + SimpleError::OAuth { code, .. } => Some(*code), } } @@ -220,7 +231,7 @@ impl SimpleError { SimpleError::Http { .. } => None, SimpleError::OpenApi { trace_id, .. } => Some(trace_id), SimpleError::Other(_) => None, - SimpleError::OAuth(_) => None, + SimpleError::OAuth { .. } => None, } } @@ -230,7 +241,30 @@ impl SimpleError { SimpleError::Http { .. } => "bad status code", SimpleError::OpenApi { message, .. } => message.as_str(), SimpleError::Other(message) => message.as_str(), - SimpleError::OAuth(message) => message.as_str(), + SimpleError::OAuth { message, .. } => message.as_str(), } } } + +#[cfg(test)] +mod tests { + use longbridge_oauth::OAuthError; + + use super::*; + + #[test] + fn oauth_errors_keep_their_code() { + let err = Error::OAuth(OAuthError::NoTokenAvailable).into_simple_error(); + assert_eq!(err.kind(), SimpleErrorKind::OAuth); + assert_eq!(err.code(), Some(1)); + assert_eq!(err.message(), "no token available"); + + // A request that could not obtain a token reports the same code. + let err = Error::HttpClient(HttpClientError::OAuth(OAuthError::RevokeFailed { + message: "x".to_string(), + })) + .into_simple_error(); + assert_eq!(err.kind(), SimpleErrorKind::OAuth); + assert_eq!(err.code(), Some(4)); + } +} From 477f4f883f0e812568784d7c51aafd6bf474195c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 4 Nov 2026 17:00:54 +0800 Subject: [PATCH 4/5] docs(oauth): document OAuth error codes in C/C++/Python; fix changelog 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 --- CHANGELOG.md | 5 +- c/csrc/include/longbridge.h | 31 +++++++ c/src/error.rs | 29 +++++++ cpp/include/status.hpp | 4 +- .../java/com/longbridge/OAuthBuilder.java | 4 +- python/pysrc/longbridge/openapi.pyi | 7 +- rust/crates/oauth/src/client.rs | 81 +++++++++++++++---- 7 files changed, 139 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f74bc7127..f6ccde763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking changes - **Rust:** `Error::OAuth` now holds the typed `longbridge::oauth::OAuthError` instead of a `String` (it is also `From`), `SimpleError::OAuth` becomes `SimpleError::OAuth { code, message }`, and `HttpClientError::OAuth` holds an `OAuthError`. Code that constructs or destructures these variants needs updating; `Display` output is unchanged. -- **Java:** `OAuthBuilder.build()` now fails with `OpenApiException` (kind `OAuth`, with the code above) instead of a bare `RuntimeException`, like `revoke()` / `clear()` and other SDK calls. Code catching `RuntimeException` around it should catch `OpenApiException`. +- **Java:** the future returned by `OAuthBuilder.build()` now completes exceptionally with `OpenApiException` (kind `OAuth`, with the code above) instead of a bare `RuntimeException`, like `revoke()` / `clear()` and other SDK calls. `build().join()` / `get()` still throw `CompletionException` / `ExecutionException`; only code that inspects the cause (`getCause() instanceof RuntimeException`, a cast in `exceptionally`) needs updating. +- **Bindings:** because OAuth errors now carry a code, the exception text of the bindings changes where it includes the code: Python `str(OpenApiException)` shows `(kind=..., code=N, ...)` and Java `OpenApiException.toString()` shows `code=N` instead of `null`. A request that fails because the token cannot be obtained now reports kind `OAuth` with the bare OAuth message (e.g. `no token available`) instead of kind `Other` with `oauth error: no token available`. - **Rust:** `OAuthError` is now `#[non_exhaustive]` and gains `RevokeFailed` and `TokenFileDelete`. Exhaustive `match`es on `OAuthError` outside this crate need a wildcard arm; adding variants will no longer be a breaking change. ### Fixed -- **All SDKs:** an OAuth token refresh is bounded by a 30-second timeout (`RefreshTokenFailed: timed out after 30s`). It runs under the token lock, so a hung refresh previously blocked every request needing a token, and `revoke()` / `clear()`, indefinitely. +- **All SDKs:** an OAuth token refresh is bounded by a 30-second timeout (`RefreshTokenFailed: timed out after 30s`). It runs under the token lock, so a hung refresh previously blocked every request needing a token, and `revoke()` / `clear()`, indefinitely; a hung refresh while building the client now falls back to the browser authorization after 30 s instead of never returning. Trade-off: if the server rotated the refresh token but its response arrives after the timeout, the new token is lost and the next refresh fails with `invalid_grant`, requiring a new authorization. - **Java:** `GridContext.create` / `FundContext.create` read the `Config` native handle inside `synchronized (config)`, like every other context, so a concurrent `Config.close()` can no longer free it while it is being used. - **All SDKs:** the OAuth browser authorization flow now uses PKCE (RFC 7636, `S256`): the authorization request carries a `code_challenge` and the token exchange sends the matching `code_verifier`. The server requires PKCE for `public` clients, so previously such clients (e.g. those created through dynamic client registration) could not authorize through the SDK and failed with `OAuth authorization failed: server_error`. Clients that don't require PKCE are unaffected. diff --git a/c/csrc/include/longbridge.h b/c/csrc/include/longbridge.h index 8b35cf19b..76a7e6597 100644 --- a/c/csrc/include/longbridge.h +++ b/c/csrc/include/longbridge.h @@ -14762,6 +14762,37 @@ void lb_error_free(struct lb_error_t *error); const char *lb_error_message(const struct lb_error_t *error); +/** + * Returns the error code. + * + * For `ErrorKindHttp` it is the HTTP status, for + * `ErrorKindOpenApi` the OpenAPI error code. For `ErrorKindOAuth` + * it is a stable code identifying the failure: + * + * | Code | Meaning | + * |------|---------| + * | 0 | other (e.g. NULL OAuth handle) | + * | 1 | no token available | + * | 2 | no refresh token | + * | 3 | token refresh failed | + * | 4 | token revocation failed | + * | 5 | authorization failed | + * | 6 | authorization timeout | + * | 7 | authorization code exchange failed | + * | 8 | CSRF token mismatch | + * | 9 | failed to bind the callback server | + * | 10 | failed to get the local address | + * | 11 | callback channel closed | + * | 12 | cannot determine the home directory | + * | 13 | failed to read the token file | + * | 14 | failed to parse the token file | + * | 15 | failed to write the token file | + * | 16 | failed to delete the token file | + * | 17 | failed to create the token directory | + * | 18 | failed to serialize the token | + * + * It is 0 when the error carries no code; check `lb_error_kind` first. + */ int64_t lb_error_code(const struct lb_error_t *error); enum lb_error_kind_t lb_error_kind(const struct lb_error_t *error); diff --git a/c/src/error.rs b/c/src/error.rs index c4c5b0046..2d44d5d5b 100644 --- a/c/src/error.rs +++ b/c/src/error.rs @@ -62,6 +62,35 @@ pub unsafe extern "C" fn lb_error_message(error: *const CError) -> *const c_char (*error).message.to_ffi_type() } +/// Returns the error code. +/// +/// For `ErrorKindHttp` it is the HTTP status, for +/// `ErrorKindOpenApi` the OpenAPI error code. For `ErrorKindOAuth` +/// it is a stable code identifying the failure: +/// +/// | Code | Meaning | +/// |------|---------| +/// | 0 | other (e.g. NULL OAuth handle) | +/// | 1 | no token available | +/// | 2 | no refresh token | +/// | 3 | token refresh failed | +/// | 4 | token revocation failed | +/// | 5 | authorization failed | +/// | 6 | authorization timeout | +/// | 7 | authorization code exchange failed | +/// | 8 | CSRF token mismatch | +/// | 9 | failed to bind the callback server | +/// | 10 | failed to get the local address | +/// | 11 | callback channel closed | +/// | 12 | cannot determine the home directory | +/// | 13 | failed to read the token file | +/// | 14 | failed to parse the token file | +/// | 15 | failed to write the token file | +/// | 16 | failed to delete the token file | +/// | 17 | failed to create the token directory | +/// | 18 | failed to serialize the token | +/// +/// It is 0 when the error carries no code; check `lb_error_kind` first. #[unsafe(no_mangle)] pub unsafe extern "C" fn lb_error_code(error: *const CError) -> i64 { (*error).code diff --git a/cpp/include/status.hpp b/cpp/include/status.hpp index 6e6e1689b..8461f3edd 100644 --- a/cpp/include/status.hpp +++ b/cpp/include/status.hpp @@ -40,7 +40,9 @@ class Status /// Returns the error kind if an error occurs std::optional kind() const; - /// Returns the error code if an error occurs + /// Returns the error code if an error occurs: the HTTP status, the OpenAPI + /// error code, or for `ErrorKind::OAuth` a stable code identifying the + /// failure (see `lb_error_code` in longbridge.h for the table) std::optional code() const; /// Returns the error message if an error occurs diff --git a/java/javasrc/src/main/java/com/longbridge/OAuthBuilder.java b/java/javasrc/src/main/java/com/longbridge/OAuthBuilder.java index 696531095..ff34aa459 100644 --- a/java/javasrc/src/main/java/com/longbridge/OAuthBuilder.java +++ b/java/javasrc/src/main/java/com/longbridge/OAuthBuilder.java @@ -69,7 +69,9 @@ public OAuthBuilder setCallbackPort(int port) { * * @param onOpenUrl Called with the authorization URL; open it in a browser * or print it to the console - * @return CompletableFuture that resolves to a new {@link OAuth} handle + * @return CompletableFuture that resolves to a new {@link OAuth} handle; on + * failure it completes exceptionally with an + * {@link OpenApiException} of kind {@link ErrorKind#OAuth} */ public CompletableFuture build(Consumer onOpenUrl) { return AsyncCallback.executeTask((callback) -> { diff --git a/python/pysrc/longbridge/openapi.pyi b/python/pysrc/longbridge/openapi.pyi index 49c39e8c1..51f2144b1 100644 --- a/python/pysrc/longbridge/openapi.pyi +++ b/python/pysrc/longbridge/openapi.pyi @@ -33,6 +33,11 @@ class ErrorKind: Other error """ + class OAuth(ErrorKind): + """ + OAuth error + """ + class OpenApiException(Exception): """ OpenAPI exception @@ -43,7 +48,7 @@ class OpenApiException(Exception): Error kind """ - code: int + code: Optional[int] """ Error code: the HTTP status or OpenAPI code returned by the server, or for ``ErrorKind.OAuth`` a stable code identifying the failure (1 no token available, 2 no refresh token, 3 refresh failed, 4 revoke failed, 5 authorization failed, 6 authorization timeout, 7 code exchange failed, 8 CSRF mismatch, 9 callback bind failed, 10 local address failed, 11 callback channel closed, 12 no home dir, 13 token file read, 14 token file parse, 15 token file write, 16 token file delete, 17 create dir failed, 18 serialize token, 0 other). diff --git a/rust/crates/oauth/src/client.rs b/rust/crates/oauth/src/client.rs index 5c838d954..2d54f3b44 100644 --- a/rust/crates/oauth/src/client.rs +++ b/rust/crates/oauth/src/client.rs @@ -571,23 +571,70 @@ mod tests { #[test] fn oauth_error_codes_are_stable() { - assert_eq!(OAuthError::Other(String::new()).code(), 0); - assert_eq!(OAuthError::NoTokenAvailable.code(), 1); - assert_eq!( - OAuthError::RevokeFailed { - message: String::new() - } - .code(), - 4 - ); - assert_eq!( - OAuthError::TokenFileDelete { - path: Default::default(), - source: std::io::Error::other("x"), - } - .code(), - 16 - ); + let io = || std::io::Error::other("x"); + let json = || serde_json::from_str::<()>("x").unwrap_err(); + let path = std::path::PathBuf::new; + let msg = String::new; + let cases = [ + (OAuthError::Other(msg()), 0), + (OAuthError::NoTokenAvailable, 1), + (OAuthError::NoRefreshToken, 2), + (OAuthError::RefreshTokenFailed { message: msg() }, 3), + (OAuthError::RevokeFailed { message: msg() }, 4), + (OAuthError::AuthorizationFailed { message: msg() }, 5), + (OAuthError::AuthorizationTimeout, 6), + (OAuthError::ExchangeCodeFailed { message: msg() }, 7), + (OAuthError::CsrfTokenMismatch, 8), + ( + OAuthError::BindCallbackFailed { + port: 0, + message: msg(), + }, + 9, + ), + (OAuthError::LocalAddressFailed, 10), + (OAuthError::CallbackChannelClosed, 11), + (OAuthError::NoHomeDir, 12), + ( + OAuthError::TokenFileRead { + path: path(), + source: io(), + }, + 13, + ), + ( + OAuthError::TokenFileParse { + path: path(), + source: json(), + }, + 14, + ), + ( + OAuthError::TokenFileWrite { + path: path(), + source: io(), + }, + 15, + ), + ( + OAuthError::TokenFileDelete { + path: path(), + source: io(), + }, + 16, + ), + ( + OAuthError::CreateDirFailed { + path: path(), + source: io(), + }, + 17, + ), + (OAuthError::SerializeToken { source: json() }, 18), + ]; + for (err, code) in cases { + assert_eq!(err.code(), code, "{err:?}"); + } } #[tokio::test] From d5f778ad1dc1f1bd5baa48392116bf9058ce4463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 4 Nov 2026 18:59:48 +0800 Subject: [PATCH 5/5] docs(python): match OpenApiException stub to its real constructor The stub declared __init__(code, message) and no trace_id; the class in longbridge/__init__.py takes (kind, code, trace_id, message). --- python/pysrc/longbridge/openapi.pyi | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/python/pysrc/longbridge/openapi.pyi b/python/pysrc/longbridge/openapi.pyi index 51f2144b1..e4d583e59 100644 --- a/python/pysrc/longbridge/openapi.pyi +++ b/python/pysrc/longbridge/openapi.pyi @@ -55,12 +55,23 @@ class OpenApiException(Exception): `None` for other client-side errors. """ + trace_id: Optional[str] + """ + Trace ID of the request, if any + """ + message: str """ Error message """ - def __init__(self, code: int, message: str) -> None: ... + def __init__( + self, + kind: ErrorKind, + code: Optional[int], + trace_id: Optional[str], + message: str, + ) -> None: ... class HttpClient: """