diff --git a/rust/crates/geo/src/lib.rs b/rust/crates/geo/src/lib.rs index 930f45ec63..dad4436056 100644 --- a/rust/crates/geo/src/lib.rs +++ b/rust/crates/geo/src/lib.rs @@ -62,3 +62,160 @@ 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", + } + } + + /// 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 + /// 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 { + credential.strip_prefix("Bearer ").unwrap_or(credential) + } +} + +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::*; + + #[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"); + } + + #[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. + 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/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/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..8477a7fc3e 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}, @@ -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, } } @@ -237,8 +255,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,20 +266,38 @@ 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()))?; + // 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); ( oauth.client_id().to_string(), format!("Bearer {token}"), None, + region, ) } }; + // 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) @@ -277,6 +314,15 @@ 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), + // 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 { + 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/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") 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/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/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; 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,