From d35f63416550729113aedd6d281aaf4492f578cc Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 30 Jun 2026 11:39:40 +0800 Subject: [PATCH 1/8] feat(config): add x-dc-region data-center routing from credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Longbridge credentials are prefixed with their data center: `us_…` for the US data center and `ap_…` for Asia-Pacific. This applies to the OAuth access token and, in legacy API-key mode, to the `app_key`, `app_secret`, and `access_token`. The API gateway routes a request to the matching data center via the `x-dc-region` header, defaulting to `ap` when absent. - `longbridge-geo`: add `DcRegion` (`from_credential` / `from_credentials` / `as_str`) and the `DC_REGION_HEADER` constant, re-exported through `longbridge-httpcli` and the crate root. - The HTTP client and the quote/trade WebSocket upgrade now auto-inject `x-dc-region` derived from the auth credentials, so US-region tokens reach the US data center with no caller changes. An explicitly-set header (e.g. via a custom header or `Config::dc_region`) is preserved. - `Config::dc_region()` remains as an explicit override. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/crates/geo/src/lib.rs | 98 +++++++++++++++++++++++++++ rust/crates/httpclient/src/lib.rs | 2 +- rust/crates/httpclient/src/request.rs | 20 +++++- rust/src/config.rs | 61 +++++++++++++++-- rust/src/lib.rs | 1 + 5 files changed, 174 insertions(+), 8 deletions(-) diff --git a/rust/crates/geo/src/lib.rs b/rust/crates/geo/src/lib.rs index 930f45ec63..839fc5dff9 100644 --- a/rust/crates/geo/src/lib.rs +++ b/rust/crates/geo/src/lib.rs @@ -62,3 +62,101 @@ pub async fn is_cn() -> bool { IS_CN_DONE.get().copied().unwrap_or(false) } } + +/// HTTP and WebSocket header that selects the data center serving a request. +/// +/// An absent header is treated as [`DcRegion::Ap`] by the API gateway. +pub const DC_REGION_HEADER: &str = "x-dc-region"; + +/// Data center region used for API gateway routing. +/// +/// Independent of [`is_cn`]: that picks the `*.longbridge.cn` vs +/// `*.longbridge.com` host (mainland acceleration), while this selects which +/// data center (`us`/`ap`) the gateway sources data from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DcRegion { + /// Asia-Pacific data center (`ap`). The gateway default. + Ap, + /// US data center (`us`). + Us, +} + +impl DcRegion { + /// Derive the region from a single credential's prefix. + /// + /// Longbridge credentials — the OAuth access token, and the legacy API-key + /// `app_key` / `app_secret` / `access_token` — are prefixed with their data + /// center: `us_…` for the US data center, `ap_…` for Asia-Pacific. A `us_` + /// prefix maps to [`DcRegion::Us`]; everything else — including + /// `ap_`-prefixed and unprefixed credentials — maps to + /// [`DcRegion::Ap`], matching the gateway default. A leading `Bearer ` + /// is tolerated so an `Authorization` value can be passed directly. + pub fn from_credential(credential: &str) -> Self { + let credential = credential.strip_prefix("Bearer ").unwrap_or(credential); + if credential.starts_with("us_") { + DcRegion::Us + } else { + DcRegion::Ap + } + } + + /// Derive the region from a set of credentials, returning [`DcRegion::Us`] + /// if any of them carries the `us_` prefix. + /// + /// Used for legacy API-key auth, where the `app_key`, `app_secret`, and + /// `access_token` all carry the region prefix. + pub fn from_credentials(credentials: &[&str]) -> Self { + if credentials + .iter() + .any(|c| DcRegion::from_credential(c) == DcRegion::Us) + { + DcRegion::Us + } else { + DcRegion::Ap + } + } + + /// The [`DC_REGION_HEADER`] value for this region (`"us"` or `"ap"`). + pub fn as_str(self) -> &'static str { + match self { + DcRegion::Us => "us", + DcRegion::Ap => "ap", + } + } +} + +#[cfg(test)] +mod dc_region_tests { + use super::*; + + #[test] + fn from_credential_detects_region() { + assert_eq!(DcRegion::from_credential("us_abc"), DcRegion::Us); + assert_eq!(DcRegion::from_credential("ap_abc"), DcRegion::Ap); + // Unprefixed and unknown prefixes fall back to the AP default. + assert_eq!(DcRegion::from_credential("abc"), DcRegion::Ap); + assert_eq!(DcRegion::from_credential(""), DcRegion::Ap); + // A `Bearer ` prefix is tolerated. + assert_eq!(DcRegion::from_credential("Bearer us_x"), DcRegion::Us); + assert_eq!(DcRegion::from_credential("Bearer ap_x"), DcRegion::Ap); + } + + #[test] + fn from_credentials_is_us_if_any_is_us() { + assert_eq!( + DcRegion::from_credentials(&["ap_key", "us_secret", "ap_token"]), + DcRegion::Us + ); + assert_eq!( + DcRegion::from_credentials(&["ap_key", "ap_secret", "ap_token"]), + DcRegion::Ap + ); + assert_eq!(DcRegion::from_credentials(&[]), DcRegion::Ap); + } + + #[test] + fn as_str_matches_header_value() { + assert_eq!(DcRegion::Us.as_str(), "us"); + assert_eq!(DcRegion::Ap.as_str(), "ap"); + } +} diff --git a/rust/crates/httpclient/src/lib.rs b/rust/crates/httpclient/src/lib.rs index 9a495ebe87..544c3050bf 100644 --- a/rust/crates/httpclient/src/lib.rs +++ b/rust/crates/httpclient/src/lib.rs @@ -16,7 +16,7 @@ mod timestamp; pub use client::HttpClient; pub use config::{AuthConfig, HttpClientConfig}; pub use error::{HttpClientError, HttpClientResult, HttpError}; -pub use longbridge_geo::is_cn; +pub use longbridge_geo::{DC_REGION_HEADER, DcRegion, is_cn}; pub use qs::QsError; pub use request::{FromPayload, Json, RequestBuilder, ToPayload}; pub use reqwest::Method; diff --git a/rust/crates/httpclient/src/request.rs b/rust/crates/httpclient/src/request.rs index aee7182578..8d7c942189 100644 --- a/rust/crates/httpclient/src/request.rs +++ b/rust/crates/httpclient/src/request.rs @@ -6,7 +6,7 @@ use std::{ time::{Duration, Instant}, }; -use longbridge_geo::is_cn; +use longbridge_geo::{DC_REGION_HEADER, DcRegion, is_cn}; use reqwest::{ Method, StatusCode, header::{HeaderMap, HeaderName, HeaderValue}, @@ -277,6 +277,24 @@ where .header("X-Timestamp", timestamp.to_string()) .header("Content-Type", "application/json; charset=utf-8"); + // Route to the data center matching the credential's region (us/ap), + // derived from the `us_`/`ap_` prefix on the OAuth token or — in legacy + // API-key mode — the app_key/app_secret/access_token. Skip when the + // caller already set the header explicitly (e.g. via custom headers). + let region_already_set = default_headers.contains_key(DC_REGION_HEADER) + || self.headers.contains_key(DC_REGION_HEADER); + if !region_already_set { + let dc_region = match &config.auth { + AuthConfig::ApiKey { + app_key, + app_secret, + access_token, + } => DcRegion::from_credentials(&[app_key, access_token, app_secret]), + AuthConfig::OAuth(_) => DcRegion::from_credential(&access_token), + }; + request_builder = request_builder.header(DC_REGION_HEADER, dc_region.as_str()); + } + // set the request body if let Some(body) = &self.body { let body = body diff --git a/rust/src/config.rs b/rust/src/config.rs index c2f0c49c56..94c839a1eb 100644 --- a/rust/src/config.rs +++ b/rust/src/config.rs @@ -7,7 +7,9 @@ use std::{ }; pub(crate) use http::{HeaderName, HeaderValue, Request, header}; -use longbridge_httpcli::{HttpClient, HttpClientConfig, Json, Method, is_cn}; +use longbridge_httpcli::{ + DC_REGION_HEADER, DcRegion, HttpClient, HttpClientConfig, Json, Method, is_cn, +}; use longbridge_oauth::OAuth; use num_enum::IntoPrimitive; use serde::{Deserialize, Serialize}; @@ -498,7 +500,29 @@ impl Config { .block_on(self.refresh_access_token(expired_at)) } - fn create_ws_request(&self, url: &str) -> tokio_tungstenite::tungstenite::Result> { + /// Resolve the data-center region from the auth credentials. For OAuth the + /// access token is resolved (and refreshed if needed); for legacy API-key + /// mode the `app_key`/`app_secret`/`access_token` prefixes are inspected. + async fn auth_dc_region(&self) -> DcRegion { + match &self.auth { + AuthMode::ApiKey { + app_key, + app_secret, + access_token, + } => DcRegion::from_credentials(&[app_key, access_token, app_secret]), + AuthMode::OAuth(oauth) => oauth + .access_token() + .await + .map(|token| DcRegion::from_credential(&token)) + .unwrap_or(DcRegion::Ap), + } + } + + fn create_ws_request( + &self, + url: &str, + dc_region: DcRegion, + ) -> tokio_tungstenite::tungstenite::Result> { let mut request = url.into_client_request()?; request.headers_mut().append( header::ACCEPT_LANGUAGE, @@ -512,21 +536,34 @@ impl Config { request.headers_mut().append(name, val); } } + // Route the upgrade to the data center matching the credential's region, + // unless the caller already set the header via a custom header. + if !self + .custom_headers + .keys() + .any(|key| key.eq_ignore_ascii_case(DC_REGION_HEADER)) + { + request.headers_mut().append( + HeaderName::from_static(DC_REGION_HEADER), + HeaderValue::from_static(dc_region.as_str()), + ); + } Ok(request) } pub(crate) async fn create_quote_ws_request( &self, ) -> (&str, tokio_tungstenite::tungstenite::Result>) { + let dc_region = self.auth_dc_region().await; match self.quote_ws_url.as_deref() { - Some(url) => (url, self.create_ws_request(url)), + Some(url) => (url, self.create_ws_request(url, dc_region)), None => { let url = if is_cn().await { DEFAULT_QUOTE_WS_URL_CN } else { DEFAULT_QUOTE_WS_URL }; - (url, self.create_ws_request(url)) + (url, self.create_ws_request(url, dc_region)) } } } @@ -534,15 +571,16 @@ impl Config { pub(crate) async fn create_trade_ws_request( &self, ) -> (&str, tokio_tungstenite::tungstenite::Result>) { + let dc_region = self.auth_dc_region().await; match self.trade_ws_url.as_deref() { - Some(url) => (url, self.create_ws_request(url)), + Some(url) => (url, self.create_ws_request(url, dc_region)), None => { let url = if is_cn().await { DEFAULT_TRADE_WS_URL_CN } else { DEFAULT_TRADE_WS_URL }; - (url, self.create_ws_request(url)) + (url, self.create_ws_request(url, dc_region)) } } } @@ -562,6 +600,17 @@ impl Config { self } + /// Override the data-center region for every HTTP and WebSocket request by + /// setting the [`DC_REGION_HEADER`](crate::DC_REGION_HEADER) explicitly. + /// + /// This is rarely needed: the region is otherwise derived automatically + /// from the auth credentials' prefix (`us_`/`ap_`). Use this only to + /// force a specific data center regardless of the credential. + #[must_use] + pub fn dc_region(self, region: DcRegion) -> Self { + self.header(DC_REGION_HEADER, region.as_str()) + } + /// Set the HTTP endpoint URL in place. pub fn set_http_url(&mut self, url: impl Into) { self.http_url = Some(url.into()); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index df996bbd33..e2fb096b47 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -45,6 +45,7 @@ pub use dca::DCAContext; pub use error::{Error, Result, SimpleError, SimpleErrorKind}; pub use fundamental::FundamentalContext; pub use longbridge_httpcli as httpclient; +pub use longbridge_httpcli::{DC_REGION_HEADER, DcRegion}; pub use longbridge_wscli as wsclient; pub use market::MarketContext; pub use portfolio::PortfolioContext; From 4e60df83ab4893415546bf6df1b20cb249cb9ef1 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 30 Jun 2026 20:43:16 +0800 Subject: [PATCH 2/8] fix(httpcli): strip us_/ap_ region prefix from OAuth bearer token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `us_`/`ap_` prefix on an access token is region metadata used to derive the `x-dc-region` routing header — it is not part of the verifiable bearer credential. Sending the full `us_…` token in `Authorization: Bearer` makes the gateway reject it (401102 token verification failed). Strip the region prefix before building the `Authorization` header (deriving the region from the prefix first), so the gateway verifies the bare token and routes by `x-dc-region`. WebSocket auth is unaffected: it uses an OTP fetched over this same HTTP path. Add `DcRegion::strip_region_prefix`. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/crates/geo/src/lib.rs | 26 +++++++++++++++++++++++++ rust/crates/httpclient/src/request.rs | 28 +++++++++++++-------------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/rust/crates/geo/src/lib.rs b/rust/crates/geo/src/lib.rs index 839fc5dff9..dfb3687143 100644 --- a/rust/crates/geo/src/lib.rs +++ b/rust/crates/geo/src/lib.rs @@ -123,6 +123,22 @@ impl DcRegion { DcRegion::Ap => "ap", } } + + /// Strip the region prefix (`us_` / `ap_`), and any leading `Bearer `, from + /// a credential, returning the bare token to transmit. + /// + /// The prefix is region metadata for [`from_credential`] / the + /// [`DC_REGION_HEADER`] — it is **not** part of the verifiable credential. + /// The gateway verifies the bare token and routes by the region header, so + /// the prefix must be removed before the token is sent (e.g. in an + /// `Authorization: Bearer …` header or a WebSocket auth handshake). + pub fn strip_region_prefix(credential: &str) -> &str { + let credential = credential.strip_prefix("Bearer ").unwrap_or(credential); + credential + .strip_prefix("us_") + .or_else(|| credential.strip_prefix("ap_")) + .unwrap_or(credential) + } } #[cfg(test)] @@ -159,4 +175,14 @@ mod dc_region_tests { assert_eq!(DcRegion::Us.as_str(), "us"); assert_eq!(DcRegion::Ap.as_str(), "ap"); } + + #[test] + fn strip_region_prefix_removes_region_and_bearer() { + assert_eq!(DcRegion::strip_region_prefix("us_eyJabc"), "eyJabc"); + assert_eq!(DcRegion::strip_region_prefix("ap_eyJabc"), "eyJabc"); + assert_eq!(DcRegion::strip_region_prefix("Bearer us_eyJabc"), "eyJabc"); + // Unprefixed tokens pass through unchanged. + assert_eq!(DcRegion::strip_region_prefix("eyJabc"), "eyJabc"); + assert_eq!(DcRegion::strip_region_prefix("Bearer eyJabc"), "eyJabc"); + } } diff --git a/rust/crates/httpclient/src/request.rs b/rust/crates/httpclient/src/request.rs index 8d7c942189..862bb40521 100644 --- a/rust/crates/httpclient/src/request.rs +++ b/rust/crates/httpclient/src/request.rs @@ -237,8 +237,9 @@ where .and_then(|value| value.parse().ok()) .unwrap_or_else(Timestamp::now); - // Resolve app_key, access_token, and optional app_secret from auth config - let (app_key, access_token, app_secret) = match &config.auth { + // Resolve app_key, access_token, optional app_secret, and the data-center + // region from the auth config. + let (app_key, access_token, app_secret, dc_region) = match &config.auth { AuthConfig::ApiKey { app_key, app_secret, @@ -247,16 +248,24 @@ where app_key.clone(), access_token.clone(), Some(app_secret.clone()), + 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()))?; + // The `us_`/`ap_` prefix is region metadata, not part of the + // verifiable bearer credential: derive the region from it, then + // strip it so the gateway verifies the bare token and routes by + // the `x-dc-region` header. + let region = DcRegion::from_credential(&token); + let bare = DcRegion::strip_region_prefix(&token); ( oauth.client_id().to_string(), - format!("Bearer {token}"), + format!("Bearer {bare}"), None, + region, ) } }; @@ -278,20 +287,11 @@ where .header("Content-Type", "application/json; charset=utf-8"); // Route to the data center matching the credential's region (us/ap), - // derived from the `us_`/`ap_` prefix on the OAuth token or — in legacy - // API-key mode — the app_key/app_secret/access_token. Skip when the - // caller already set the header explicitly (e.g. via custom headers). + // unless the caller already set the header explicitly (e.g. via custom + // headers). let region_already_set = default_headers.contains_key(DC_REGION_HEADER) || self.headers.contains_key(DC_REGION_HEADER); if !region_already_set { - let dc_region = match &config.auth { - AuthConfig::ApiKey { - app_key, - app_secret, - access_token, - } => DcRegion::from_credentials(&[app_key, access_token, app_secret]), - AuthConfig::OAuth(_) => DcRegion::from_credential(&access_token), - }; request_builder = request_builder.header(DC_REGION_HEADER, dc_region.as_str()); } From b4495539e72d5cec8c937407b092735128217d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 1 Jul 2026 22:36:18 +0800 Subject: [PATCH 3/8] fix(dc-region): don't strip region prefix from tokens; add staging WS URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on live testing: - Tokens with region prefixes (hk_m_, us_m_, ap_m_) are sent as-is to the gateway. The server accepts the full prefixed token and routes via the x-dc-region header — no prefix stripping is needed. - strip_region_prefix now only removes a leading "Bearer " prefix. - Add http_url_staging, quote_ws_url_staging, trade_ws_url_staging helpers: US: openapi-global.longbridge.xyz openapi-global-quote.longbridge.xyz openapi-global-trade.longbridge.xyz AP: openapi.longbridge.xyz / openapi-quote.longbridge.xyz / ... - Update strip_region_prefix test to reflect new behavior. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- rust/crates/geo/src/lib.rs | 54 ++++++++++++++++++--------- rust/crates/httpclient/src/request.rs | 10 ++--- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/rust/crates/geo/src/lib.rs b/rust/crates/geo/src/lib.rs index dfb3687143..2a20912e86 100644 --- a/rust/crates/geo/src/lib.rs +++ b/rust/crates/geo/src/lib.rs @@ -124,20 +124,38 @@ impl DcRegion { } } - /// Strip the region prefix (`us_` / `ap_`), and any leading `Bearer `, from - /// a credential, returning the bare token to transmit. + /// Strip any leading `Bearer ` from a credential. /// - /// The prefix is region metadata for [`from_credential`] / the - /// [`DC_REGION_HEADER`] — it is **not** part of the verifiable credential. - /// The gateway verifies the bare token and routes by the region header, so - /// the prefix must be removed before the token is sent (e.g. in an - /// `Authorization: Bearer …` header or a WebSocket auth handshake). + /// Region prefixes (`hk_m_`, `us_m_`, `ap_m_`, …) are routing metadata + /// consumed by [`from_credential`] to derive the [`DC_REGION_HEADER`]. + /// The gateway accepts the full prefixed token and routes via the header, + /// so **no region prefix is stripped** — only `Bearer ` is removed. pub fn strip_region_prefix(credential: &str) -> &str { - let credential = credential.strip_prefix("Bearer ").unwrap_or(credential); - credential - .strip_prefix("us_") - .or_else(|| credential.strip_prefix("ap_")) - .unwrap_or(credential) + credential.strip_prefix("Bearer ").unwrap_or(credential) + } + + /// HTTP base URL for this region (staging environment). + pub fn http_url_staging(self) -> &'static str { + match self { + DcRegion::Us => "https://openapi-global.longbridge.xyz", + DcRegion::Ap => "https://openapi.longbridge.xyz", + } + } + + /// Quote WebSocket URL for this region (staging environment). + pub fn quote_ws_url_staging(self) -> &'static str { + match self { + DcRegion::Us => "wss://openapi-global-quote.longbridge.xyz", + DcRegion::Ap => "wss://openapi-quote.longbridge.xyz", + } + } + + /// Trade WebSocket URL for this region (staging environment). + pub fn trade_ws_url_staging(self) -> &'static str { + match self { + DcRegion::Us => "wss://openapi-global-trade.longbridge.xyz", + DcRegion::Ap => "wss://openapi-trade.longbridge.xyz", + } } } @@ -177,12 +195,12 @@ mod dc_region_tests { } #[test] - fn strip_region_prefix_removes_region_and_bearer() { - assert_eq!(DcRegion::strip_region_prefix("us_eyJabc"), "eyJabc"); - assert_eq!(DcRegion::strip_region_prefix("ap_eyJabc"), "eyJabc"); - assert_eq!(DcRegion::strip_region_prefix("Bearer us_eyJabc"), "eyJabc"); - // Unprefixed tokens pass through unchanged. - assert_eq!(DcRegion::strip_region_prefix("eyJabc"), "eyJabc"); + fn strip_region_prefix_only_removes_bearer() { + // Region prefixes are kept as-is; only "Bearer " is stripped. + assert_eq!(DcRegion::strip_region_prefix("us_m_eyJabc"), "us_m_eyJabc"); + assert_eq!(DcRegion::strip_region_prefix("hk_m_eyJabc"), "hk_m_eyJabc"); + assert_eq!(DcRegion::strip_region_prefix("Bearer us_m_eyJabc"), "us_m_eyJabc"); assert_eq!(DcRegion::strip_region_prefix("Bearer eyJabc"), "eyJabc"); + assert_eq!(DcRegion::strip_region_prefix("eyJabc"), "eyJabc"); } } diff --git a/rust/crates/httpclient/src/request.rs b/rust/crates/httpclient/src/request.rs index 862bb40521..c656656098 100644 --- a/rust/crates/httpclient/src/request.rs +++ b/rust/crates/httpclient/src/request.rs @@ -255,15 +255,13 @@ where .access_token() .await .map_err(|e| HttpClientError::OAuth(e.to_string()))?; - // The `us_`/`ap_` prefix is region metadata, not part of the - // verifiable bearer credential: derive the region from it, then - // strip it so the gateway verifies the bare token and routes by - // the `x-dc-region` header. + // 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. let region = DcRegion::from_credential(&token); - let bare = DcRegion::strip_region_prefix(&token); ( oauth.client_id().to_string(), - format!("Bearer {bare}"), + format!("Bearer {token}"), None, region, ) From 005e0c6b1a37b7988a3c8bb428a0387c9858769d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 1 Jul 2026 22:50:55 +0800 Subject: [PATCH 4/8] chore: apply cargo fmt --- rust/crates/geo/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rust/crates/geo/src/lib.rs b/rust/crates/geo/src/lib.rs index 2a20912e86..dcaad469a4 100644 --- a/rust/crates/geo/src/lib.rs +++ b/rust/crates/geo/src/lib.rs @@ -199,7 +199,10 @@ mod dc_region_tests { // Region prefixes are kept as-is; only "Bearer " is stripped. assert_eq!(DcRegion::strip_region_prefix("us_m_eyJabc"), "us_m_eyJabc"); assert_eq!(DcRegion::strip_region_prefix("hk_m_eyJabc"), "hk_m_eyJabc"); - assert_eq!(DcRegion::strip_region_prefix("Bearer us_m_eyJabc"), "us_m_eyJabc"); + assert_eq!( + DcRegion::strip_region_prefix("Bearer us_m_eyJabc"), + "us_m_eyJabc" + ); assert_eq!(DcRegion::strip_region_prefix("Bearer eyJabc"), "eyJabc"); assert_eq!(DcRegion::strip_region_prefix("eyJabc"), "eyJabc"); } From 7ad89e7d52ee209f785a0ea1a850821d4bb5249c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 1 Jul 2026 22:54:51 +0800 Subject: [PATCH 5/8] fix(dc-region): staging always uses openapi-global endpoints; dc routing via x-dc-region header --- rust/crates/geo/src/lib.rs | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/rust/crates/geo/src/lib.rs b/rust/crates/geo/src/lib.rs index dcaad469a4..effee01d13 100644 --- a/rust/crates/geo/src/lib.rs +++ b/rust/crates/geo/src/lib.rs @@ -134,28 +134,22 @@ impl DcRegion { credential.strip_prefix("Bearer ").unwrap_or(credential) } - /// HTTP base URL for this region (staging environment). - pub fn http_url_staging(self) -> &'static str { - match self { - DcRegion::Us => "https://openapi-global.longbridge.xyz", - DcRegion::Ap => "https://openapi.longbridge.xyz", - } + /// HTTP base URL for the staging environment. + /// + /// Staging always uses the global endpoint; the data-center is selected + /// by the [`DC_REGION_HEADER`] (`ap` or `us`), not the hostname. + pub fn http_url_staging(_self: DcRegion) -> &'static str { + "https://openapi-global.longbridge.xyz" } - /// Quote WebSocket URL for this region (staging environment). - pub fn quote_ws_url_staging(self) -> &'static str { - match self { - DcRegion::Us => "wss://openapi-global-quote.longbridge.xyz", - DcRegion::Ap => "wss://openapi-quote.longbridge.xyz", - } + /// Quote WebSocket URL for the staging environment. + pub fn quote_ws_url_staging(_self: DcRegion) -> &'static str { + "wss://openapi-global-quote.longbridge.xyz" } - /// Trade WebSocket URL for this region (staging environment). - pub fn trade_ws_url_staging(self) -> &'static str { - match self { - DcRegion::Us => "wss://openapi-global-trade.longbridge.xyz", - DcRegion::Ap => "wss://openapi-trade.longbridge.xyz", - } + /// Trade WebSocket URL for the staging environment. + pub fn trade_ws_url_staging(_self: DcRegion) -> &'static str { + "wss://openapi-global-trade.longbridge.xyz" } } From b1194278fd5b7f43ae2a2abf9ec6a962b2937c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 1 Jul 2026 22:56:34 +0800 Subject: [PATCH 6/8] chore: remove http_url_staging/quote_ws_url_staging/trade_ws_url_staging methods --- rust/crates/geo/src/lib.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/rust/crates/geo/src/lib.rs b/rust/crates/geo/src/lib.rs index effee01d13..f6e319569a 100644 --- a/rust/crates/geo/src/lib.rs +++ b/rust/crates/geo/src/lib.rs @@ -133,24 +133,6 @@ impl DcRegion { pub fn strip_region_prefix(credential: &str) -> &str { credential.strip_prefix("Bearer ").unwrap_or(credential) } - - /// HTTP base URL for the staging environment. - /// - /// Staging always uses the global endpoint; the data-center is selected - /// by the [`DC_REGION_HEADER`] (`ap` or `us`), not the hostname. - pub fn http_url_staging(_self: DcRegion) -> &'static str { - "https://openapi-global.longbridge.xyz" - } - - /// Quote WebSocket URL for the staging environment. - pub fn quote_ws_url_staging(_self: DcRegion) -> &'static str { - "wss://openapi-global-quote.longbridge.xyz" - } - - /// Trade WebSocket URL for the staging environment. - pub fn trade_ws_url_staging(_self: DcRegion) -> &'static str { - "wss://openapi-global-trade.longbridge.xyz" - } } #[cfg(test)] From 99321466ad051803de52896d66c19a1fb69b866c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E7=AB=A0=E6=B4=AA?= Date: Wed, 1 Jul 2026 23:00:45 +0800 Subject: [PATCH 7/8] fix(oauth): update staging URL to use openapi-global.longbridge.xyz Co-Authored-By: Claude Sonnet 4.6 (1M context) --- rust/crates/oauth/src/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/oauth/src/client.rs b/rust/crates/oauth/src/client.rs index 2b4682d834..1872ecf6a8 100644 --- a/rust/crates/oauth/src/client.rs +++ b/rust/crates/oauth/src/client.rs @@ -16,7 +16,7 @@ use crate::{ const OAUTH_BASE_URL: &str = "https://openapi.longbridge.com/oauth2"; const OAUTH_BASE_URL_CN: &str = "https://openapi.longbridge.cn/oauth2"; -const OAUTH_BASE_URL_TEST: &str = "https://openapi.longbridge.xyz/oauth2"; +const OAUTH_BASE_URL_TEST: &str = "https://openapi-global.longbridge.xyz/oauth2"; async fn oauth_base_url() -> &'static str { if std::env::var("LONGBRIDGE_ENV") From bc9d3badcd380097d1797d21a976d6064a8a3dac Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 2 Jul 2026 14:44:30 +0800 Subject: [PATCH 8/8] feat: Add per-request dc_restrict API for region-limited endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a declarative `RequestBuilder::dc_restrict(region)` API so a call site can restrict a request to a single data center. When the session's region differs, `do_send` short-circuits with a unified `HttpClientError::DcRegionRestricted { path, required, current }` instead of forwarding a request the target data center cannot serve. - geo: `DcRegion::allows()` + uppercase `Display` (AP/US) for messages, while `as_str()` keeps the lowercase `x-dc-region` header value (us/ap). - httpcli: `RequestBuilder::dc_restrict()`, `HttpClient::dc_region()`, and the `DcRegionRestricted` error variant. - Declare AP-only endpoints via the API: recurring investment (DCA, whole module), operating reviews, broker holdings, and the broker-queue WebSocket command. Supports future US-only endpoints by declaring `DcRegion::Us` at their call sites — no path-prefix heuristics. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/crates/geo/src/lib.rs | 36 +++++++++++++++++++++++++++ rust/crates/httpclient/src/client.rs | 23 ++++++++++++++++- rust/crates/httpclient/src/error.rs | 15 +++++++++++ rust/crates/httpclient/src/request.rs | 30 ++++++++++++++++++++++ rust/src/dca/context.rs | 6 ++++- rust/src/fundamental/context.rs | 25 +++++++++++++++++-- rust/src/market/context.rs | 31 ++++++++++++++++++++--- rust/src/quote/context.rs | 11 ++++++++ 8 files changed, 169 insertions(+), 8 deletions(-) diff --git a/rust/crates/geo/src/lib.rs b/rust/crates/geo/src/lib.rs index f6e319569a..dad4436056 100644 --- a/rust/crates/geo/src/lib.rs +++ b/rust/crates/geo/src/lib.rs @@ -124,6 +124,14 @@ impl DcRegion { } } + /// Whether this session may reach an API limited to `required`. + /// + /// `true` when the session's region matches the API's required region; + /// callers short-circuit with a unified error when it is `false`. + pub fn allows(self, required: DcRegion) -> bool { + self == required + } + /// Strip any leading `Bearer ` from a credential. /// /// Region prefixes (`hk_m_`, `us_m_`, `ap_m_`, …) are routing metadata @@ -135,6 +143,17 @@ impl DcRegion { } } +impl std::fmt::Display for DcRegion { + /// Human-facing uppercase name (`AP`/`US`), for error messages and display. + /// The lowercase [`DC_REGION_HEADER`] value is [`as_str`](Self::as_str). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + DcRegion::Us => "US", + DcRegion::Ap => "AP", + }) + } +} + #[cfg(test)] mod dc_region_tests { use super::*; @@ -170,6 +189,23 @@ mod dc_region_tests { assert_eq!(DcRegion::Ap.as_str(), "ap"); } + #[test] + fn allows_matches_same_region() { + assert!(DcRegion::Ap.allows(DcRegion::Ap)); + assert!(DcRegion::Us.allows(DcRegion::Us)); + assert!(!DcRegion::Us.allows(DcRegion::Ap)); + assert!(!DcRegion::Ap.allows(DcRegion::Us)); + } + + #[test] + fn display_is_uppercase() { + // Human-facing display is uppercase; the header value stays lowercase. + assert_eq!(DcRegion::Us.to_string(), "US"); + assert_eq!(DcRegion::Ap.to_string(), "AP"); + assert_eq!(DcRegion::Us.as_str(), "us"); + assert_eq!(DcRegion::Ap.as_str(), "ap"); + } + #[test] fn strip_region_prefix_only_removes_bearer() { // Region prefixes are kept as-is; only "Bearer " is stripped. diff --git a/rust/crates/httpclient/src/client.rs b/rust/crates/httpclient/src/client.rs index 30f59f6ea9..57ca30b39e 100644 --- a/rust/crates/httpclient/src/client.rs +++ b/rust/crates/httpclient/src/client.rs @@ -1,12 +1,15 @@ use std::sync::Arc; +use longbridge_geo::DcRegion; use reqwest::{ Client, Method, header::{HeaderMap, HeaderName, HeaderValue}, }; use serde::Deserialize; -use crate::{HttpClientConfig, HttpClientError, HttpClientResult, Json, RequestBuilder}; +use crate::{ + AuthConfig, HttpClientConfig, HttpClientError, HttpClientResult, Json, RequestBuilder, +}; /// Longbridge HTTP client #[derive(Clone)] @@ -40,6 +43,24 @@ impl HttpClient { self } + /// The data-center region (`us`/`ap`) derived from this client's auth + /// credentials. Used by non-HTTP call sites (e.g. WebSocket quote commands) + /// to apply the same AP-only routing guard the HTTP path enforces. + pub async fn dc_region(&self) -> DcRegion { + match &self.config.auth { + AuthConfig::ApiKey { + app_key, + app_secret, + access_token, + } => DcRegion::from_credentials(&[app_key, access_token, app_secret]), + AuthConfig::OAuth(oauth) => oauth + .access_token() + .await + .map(|token| DcRegion::from_credential(&token)) + .unwrap_or(DcRegion::Ap), + } + } + /// Create a new request builder #[inline] pub fn request( diff --git a/rust/crates/httpclient/src/error.rs b/rust/crates/httpclient/src/error.rs index 9eb87c72ad..ddf57047a6 100644 --- a/rust/crates/httpclient/src/error.rs +++ b/rust/crates/httpclient/src/error.rs @@ -1,5 +1,6 @@ use std::error::Error; +use longbridge_geo::DcRegion; use reqwest::StatusCode; use crate::qs::QsError; @@ -34,6 +35,20 @@ pub enum HttpClientError { #[error("request timeout")] RequestTimeout, + /// The requested API is restricted to one data center and cannot be reached + /// from a session in a different region. + #[error( + "this API ({path}) is only available in the {required} data center and is not supported for your {current}-region account" + )] + DcRegionRestricted { + /// The restricted API path (or WebSocket command) that was requested. + path: String, + /// The data center this API is limited to. + required: DcRegion, + /// The session's current data-center region. + current: DcRegion, + }, + /// OpenAPI error #[error("openapi error: code={code}: {message}")] OpenApi { diff --git a/rust/crates/httpclient/src/request.rs b/rust/crates/httpclient/src/request.rs index c656656098..8477a7fc3e 100644 --- a/rust/crates/httpclient/src/request.rs +++ b/rust/crates/httpclient/src/request.rs @@ -125,6 +125,7 @@ pub struct RequestBuilder<'a, T, Q, R> { headers: HeaderMap, body: Option, query_params: Option, + dc_restrict: Option, mark_resp: PhantomData, } @@ -137,6 +138,7 @@ impl<'a> RequestBuilder<'a, (), (), ()> { headers: Default::default(), body: None, query_params: None, + dc_restrict: None, mark_resp: PhantomData, } } @@ -156,6 +158,7 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> { headers: self.headers, body: Some(body), query_params: self.query_params, + dc_restrict: self.dc_restrict, mark_resp: self.mark_resp, } } @@ -175,6 +178,19 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> { self } + /// Restrict this request to a single data center. + /// + /// When set, [`do_send`](Self::do_send) short-circuits with + /// [`HttpClientError::DcRegionRestricted`] if the session's region differs, + /// instead of forwarding a request the target data center cannot serve. + /// Call sites for region-limited endpoints declare their region here — + /// `Ap` for AP-only APIs, `Us` for US-only ones. + #[must_use] + pub fn dc_restrict(mut self, region: DcRegion) -> Self { + self.dc_restrict = Some(region); + self + } + /// Set the query string #[must_use] pub fn query_params(self, params: Q2) -> RequestBuilder<'a, T, Q2, R> @@ -188,6 +204,7 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> { headers: self.headers, body: self.body, query_params: Some(params), + dc_restrict: self.dc_restrict, mark_resp: self.mark_resp, } } @@ -205,6 +222,7 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> { headers: self.headers, body: self.body, query_params: self.query_params, + dc_restrict: self.dc_restrict, mark_resp: PhantomData, } } @@ -268,6 +286,18 @@ where } }; + // Short-circuit region-limited endpoints with a single unified error, + // instead of forwarding a request the target data center cannot serve. + if let Some(required) = self.dc_restrict + && !dc_region.allows(required) + { + return Err(HttpClientError::DcRegionRestricted { + path: self.path.clone(), + required, + current: dc_region, + }); + } + let app_key_value = HeaderValue::from_str(&app_key).map_err(|_| HttpClientError::InvalidApiKey)?; let access_token_value = HeaderValue::from_str(&access_token) diff --git a/rust/src/dca/context.rs b/rust/src/dca/context.rs index d9eaaf028e..8c96f16922 100644 --- a/rust/src/dca/context.rs +++ b/rust/src/dca/context.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use longbridge_httpcli::{HttpClient, Json, Method}; +use longbridge_httpcli::{DcRegion, HttpClient, Json, Method}; use serde::{Serialize, de::DeserializeOwned}; use tracing::{Subscriber, dispatcher, instrument::WithSubscriber}; @@ -55,6 +55,8 @@ impl DCAContext { .0 .http_cli .request(Method::GET, path) + // Recurring investment (DCA) is served only by the AP data center. + .dc_restrict(DcRegion::Ap) .query_params(query) .response::>() .send() @@ -72,6 +74,8 @@ impl DCAContext { .0 .http_cli .request(Method::POST, path) + // Recurring investment (DCA) is served only by the AP data center. + .dc_restrict(DcRegion::Ap) .body(Json(body)) .response::>() .send() diff --git a/rust/src/fundamental/context.rs b/rust/src/fundamental/context.rs index 450848147e..4987453db4 100644 --- a/rust/src/fundamental/context.rs +++ b/rust/src/fundamental/context.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use longbridge_httpcli::{HttpClient, Json, Method}; +use longbridge_httpcli::{DcRegion, HttpClient, Json, Method}; use serde::{Serialize, de::DeserializeOwned}; use tracing::{Subscriber, dispatcher, instrument::WithSubscriber}; @@ -80,6 +80,26 @@ impl FundamentalContext { .0) } + /// Like [`get`](Self::get), but restricted to a single data center. Used by + /// region-limited endpoints (e.g. AP-only fundamentals). + async fn get_dc(&self, path: &'static str, query: Q, dc_restrict: DcRegion) -> Result + where + R: DeserializeOwned + Send + Sync + 'static, + Q: Serialize + Send + Sync, + { + Ok(self + .0 + .http_cli + .request(Method::GET, path) + .dc_restrict(dc_restrict) + .query_params(query) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } + // ── financial_report ───────────────────────────────────────── /// Get financial reports for a security. @@ -466,11 +486,12 @@ impl FundamentalContext { struct Query { counter_id: String, } - self.get( + self.get_dc( "/v1/quote/operatings", Query { counter_id: symbol_to_counter_id(&symbol.into()), }, + DcRegion::Ap, ) .await } diff --git a/rust/src/market/context.rs b/rust/src/market/context.rs index e0a27b4701..c3512cb030 100644 --- a/rust/src/market/context.rs +++ b/rust/src/market/context.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use longbridge_httpcli::{HttpClient, Json, Method}; +use longbridge_httpcli::{DcRegion, HttpClient, Json, Method}; use serde::{Serialize, de::DeserializeOwned}; use tracing::{Subscriber, dispatcher, instrument::WithSubscriber}; @@ -85,6 +85,26 @@ impl MarketContext { .0) } + /// Like [`get`](Self::get), but restricted to a single data center. Used by + /// region-limited endpoints (e.g. AP-only broker holdings). + async fn get_dc(&self, path: &'static str, query: Q, dc_restrict: DcRegion) -> Result + where + R: DeserializeOwned + Send + Sync + 'static, + Q: Serialize + Send + Sync, + { + Ok(self + .0 + .http_cli + .request(Method::GET, path) + .dc_restrict(dc_restrict) + .query_params(query) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } + async fn post(&self, path: &'static str, body: B) -> Result where R: DeserializeOwned + Send + Sync + 'static, @@ -135,12 +155,13 @@ impl MarketContext { #[serde(rename = "type")] period: &'static str, } - self.get( + self.get_dc( "/v1/quote/broker-holding", Query { counter_id: symbol_to_counter_id(&symbol.into()), period: period_str, }, + DcRegion::Ap, ) .await } @@ -156,11 +177,12 @@ impl MarketContext { struct Query { counter_id: String, } - self.get( + self.get_dc( "/v1/quote/broker-holding/detail", Query { counter_id: symbol_to_counter_id(&symbol.into()), }, + DcRegion::Ap, ) .await } @@ -178,12 +200,13 @@ impl MarketContext { counter_id: String, parti_number: String, } - self.get( + self.get_dc( "/v1/quote/broker-holding/daily", Query { counter_id: symbol_to_counter_id(&symbol.into()), parti_number: broker_id.into(), }, + DcRegion::Ap, ) .await } diff --git a/rust/src/quote/context.rs b/rust/src/quote/context.rs index 663142c2fc..a3de649c0d 100644 --- a/rust/src/quote/context.rs +++ b/rust/src/quote/context.rs @@ -668,6 +668,17 @@ impl QuoteContext { /// # }); /// ``` pub async fn brokers(&self, symbol: impl Into) -> Result { + // Broker queue is served only by the AP data center; short-circuit a + // non-AP session with the same unified error the HTTP path returns. + let current = self.0.http_cli.dc_region().await; + if !current.allows(longbridge_httpcli::DcRegion::Ap) { + return Err(longbridge_httpcli::HttpClientError::DcRegionRestricted { + path: "quote/brokers (WebSocket)".to_string(), + required: longbridge_httpcli::DcRegion::Ap, + current, + } + .into()); + } let resp: quote::SecurityBrokersResponse = self .request( cmd_code::GET_SECURITY_BROKERS,