Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,14 @@ jobs:
cache-bin: false

- name: Check with clippy
run: cargo clippy -p longbridge --all-features
run: |
cargo clippy -p longbridge --all-features
cargo clippy -p longbridge-oauth --all-features --all-targets -- -D warnings

- name: Test
run: cargo test -p longbridge --all-features
run: |
cargo test -p longbridge --all-features
cargo test -p longbridge-oauth --all-features

check-python-sdk:
needs:
Expand Down
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,28 @@ 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 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/<client_id>`; 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<Void>`. 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<Void>`, 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.

### Changed

- **Rust:** the internal OAuth dependency is upgraded from `oauth2` 4.4 to 5, which uses the workspace's `reqwest` 0.12 stack instead of a separate `reqwest` 0.11 / `hyper` 0.14 / `rustls` 0.21 chain. The public OAuth API, token persistence and redirect protection (token requests do not follow redirects) are unchanged; error text coming from the HTTP layer may differ.

### Breaking changes

- **Rust:** `Error::OAuth` now holds the typed `longbridge::oauth::OAuthError` instead of a `String` (it is also `From<OAuthError>`), `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:** 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; 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. The authorization code exchange is bounded too (10 s to connect, 35 s in total).
- **All SDKs:** OAuth transport failures report their cause (e.g. `Request failed: client error: error sending request ...: tcp connect error: ...`) instead of a bare `Request failed`.
- **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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,6 @@ darling = "0.21.2"
convert_case = "0.8.0"
Inflector = "0.11.4"
cbindgen = "0.29.2"
oauth2 = "4.4"
oauth2 = "5"
open = "5"
dirs = "6"
31 changes: 31 additions & 0 deletions c/csrc/include/longbridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions c/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions c/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 })))
});
}
Expand Down Expand Up @@ -86,7 +86,9 @@ unsafe fn clone_inner(oauth: *const COAuth) -> Option<OAuth> {
}

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)
Expand All @@ -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(())
});
}
Expand All @@ -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(())
});
}
4 changes: 3 additions & 1 deletion cpp/include/status.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ class Status
/// Returns the error kind if an error occurs
std::optional<ErrorKind> 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<int64_t> code() const;

/// Returns the error message if an error occurs
Expand Down
6 changes: 4 additions & 2 deletions java/javasrc/src/main/java/com/longbridge/OAuth.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void> revoke() {
public synchronized CompletableFuture<Void> revoke() {
long r = getRaw();
return AsyncCallback.executeTask((callback) -> {
SdkNative.oauthRevoke(r, callback);
Expand All @@ -74,8 +75,9 @@ public CompletableFuture<Void> 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<Void> clear() {
public synchronized CompletableFuture<Void> clear() {
long r = getRaw();
return AsyncCallback.executeTask((callback) -> {
SdkNative.oauthClear(r, callback);
Expand Down
4 changes: 3 additions & 1 deletion java/javasrc/src/main/java/com/longbridge/OAuthBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<OAuth> build(Consumer<String> onOpenUrl) {
return AsyncCallback.<Long>executeTask((callback) -> {
Expand Down
13 changes: 9 additions & 4 deletions java/javasrc/src/main/java/com/longbridge/OpenApiException.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
* pieces of diagnostic information:
* <ul>
* <li>{@link #getKind()} – broad category of the error (HTTP, OpenAPI, OAuth, Other)</li>
* <li>{@link #getCode()} – numeric error code returned by the server, or {@code null} for
* client-side errors</li>
* <li>{@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</li>
* <li>{@link #getMessage()} – human-readable error description</li>
* </ul>
*/
Expand Down Expand Up @@ -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
* <p>
* 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion java/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ impl From<longbridge::Error> for JniError {

impl From<longbridge::oauth::OAuthError> for JniError {
fn from(e: longbridge::oauth::OAuthError) -> Self {
JniError::Other(e.to_string())
JniError::OpenApi(Box::new(longbridge::Error::OAuth(e)))
}
}

Expand Down
6 changes: 3 additions & 3 deletions java/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down Expand Up @@ -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).into())
})?;
Ok(())
})
Expand All @@ -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).into())
})?;
Ok(())
})
Expand Down
24 changes: 21 additions & 3 deletions python/pysrc/longbridge/openapi.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ class ErrorKind:
Other error
"""

class OAuth(ErrorKind):
"""
OAuth error
"""

class OpenApiException(Exception):
"""
OpenAPI exception
Expand All @@ -43,17 +48,30 @@ 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).
`None` for other client-side errors.
"""
Error code

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:
"""
Expand Down
2 changes: 1 addition & 1 deletion python/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions rust/crates/httpclient/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")]
Expand Down
Loading
Loading