Skip to main content

longbridge_httpcli/
request.rs

1use std::{
2    convert::Infallible,
3    error::Error,
4    fmt::Debug,
5    marker::PhantomData,
6    time::{Duration, Instant},
7};
8
9use longbridge_geo::{DC_REGION_HEADER, DcRegion, is_cn};
10use reqwest::{
11    Method, StatusCode,
12    header::{HeaderMap, HeaderName, HeaderValue},
13};
14use serde::{Deserialize, Serialize, de::DeserializeOwned};
15
16use crate::{
17    AuthConfig, HttpClient, HttpClientError, HttpClientResult,
18    signature::{SignatureParams, signature},
19    timestamp::Timestamp,
20};
21
22const HTTP_URL: &str = "https://openapi.longbridge.com";
23const HTTP_URL_CN: &str = "https://openapi.longbridge.cn";
24
25const USER_AGENT: &str = "openapi-sdk";
26const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
27const RETRY_COUNT: usize = 5;
28const RETRY_INITIAL_DELAY: Duration = Duration::from_millis(100);
29const RETRY_FACTOR: f32 = 2.0;
30
31/// A JSON payload
32#[derive(Debug)]
33pub struct Json<T>(pub T);
34
35/// Represents a type that can parse from payload
36pub trait FromPayload: Sized + Send + Sync + 'static {
37    /// A error type
38    type Err: Error;
39
40    /// Parse the payload to this object
41    fn parse_from_bytes(data: &[u8]) -> Result<Self, Self::Err>;
42}
43
44/// Represents a type that can convert to payload
45pub trait ToPayload: Debug + Sized + Send + Sync + 'static {
46    /// A error type
47    type Err: Error;
48
49    /// Convert this object to the payload
50    fn to_bytes(&self) -> Result<Vec<u8>, Self::Err>;
51}
52
53impl<T> FromPayload for Json<T>
54where
55    T: DeserializeOwned + Send + Sync + 'static,
56{
57    type Err = serde_json::Error;
58
59    #[inline]
60    fn parse_from_bytes(data: &[u8]) -> Result<Self, Self::Err> {
61        Ok(Json(serde_json::from_slice(data)?))
62    }
63}
64
65impl<T> ToPayload for Json<T>
66where
67    T: Debug + Serialize + Send + Sync + 'static,
68{
69    type Err = serde_json::Error;
70
71    #[inline]
72    fn to_bytes(&self) -> Result<Vec<u8>, Self::Err> {
73        serde_json::to_vec(&self.0)
74    }
75}
76
77impl FromPayload for String {
78    type Err = std::string::FromUtf8Error;
79
80    #[inline]
81    fn parse_from_bytes(data: &[u8]) -> Result<Self, Self::Err> {
82        String::from_utf8(data.to_vec())
83    }
84}
85
86impl ToPayload for String {
87    type Err = std::string::FromUtf8Error;
88
89    #[inline]
90    fn to_bytes(&self) -> Result<Vec<u8>, Self::Err> {
91        Ok(self.clone().into_bytes())
92    }
93}
94
95impl FromPayload for () {
96    type Err = Infallible;
97
98    #[inline]
99    fn parse_from_bytes(_data: &[u8]) -> Result<Self, Self::Err> {
100        Ok(())
101    }
102}
103
104impl ToPayload for () {
105    type Err = Infallible;
106
107    #[inline]
108    fn to_bytes(&self) -> Result<Vec<u8>, Self::Err> {
109        Ok(vec![])
110    }
111}
112
113#[derive(Deserialize)]
114struct OpenApiResponse {
115    code: i32,
116    message: String,
117    data: Option<Box<serde_json::value::RawValue>>,
118}
119
120/// A request builder
121pub struct RequestBuilder<'a, T, Q, R> {
122    client: &'a HttpClient,
123    method: Method,
124    path: String,
125    headers: HeaderMap,
126    body: Option<T>,
127    query_params: Option<Q>,
128    dc_restrict: Option<DcRegion>,
129    mark_resp: PhantomData<R>,
130}
131
132impl<'a> RequestBuilder<'a, (), (), ()> {
133    pub(crate) fn new(client: &'a HttpClient, method: Method, path: impl Into<String>) -> Self {
134        Self {
135            client,
136            method,
137            path: path.into(),
138            headers: Default::default(),
139            body: None,
140            query_params: None,
141            dc_restrict: None,
142            mark_resp: PhantomData,
143        }
144    }
145}
146
147impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> {
148    /// Set the request body
149    #[must_use]
150    pub fn body<T2>(self, body: T2) -> RequestBuilder<'a, T2, Q, R>
151    where
152        T2: ToPayload,
153    {
154        RequestBuilder {
155            client: self.client,
156            method: self.method,
157            path: self.path,
158            headers: self.headers,
159            body: Some(body),
160            query_params: self.query_params,
161            dc_restrict: self.dc_restrict,
162            mark_resp: self.mark_resp,
163        }
164    }
165
166    /// Set the header
167    #[must_use]
168    pub fn header<K, V>(mut self, key: K, value: V) -> Self
169    where
170        K: TryInto<HeaderName>,
171        V: TryInto<HeaderValue>,
172    {
173        let key = key.try_into();
174        let value = value.try_into();
175        if let (Ok(key), Ok(value)) = (key, value) {
176            self.headers.insert(key, value);
177        }
178        self
179    }
180
181    /// Restrict this request to a single data center.
182    ///
183    /// When set, [`do_send`](Self::do_send) short-circuits with
184    /// [`HttpClientError::DcRegionRestricted`] if the session's region differs,
185    /// instead of forwarding a request the target data center cannot serve.
186    /// Call sites for region-limited endpoints declare their region here —
187    /// `Ap` for AP-only APIs, `Us` for US-only ones.
188    #[must_use]
189    pub fn dc_restrict(mut self, region: DcRegion) -> Self {
190        self.dc_restrict = Some(region);
191        self
192    }
193
194    /// Set the query string
195    #[must_use]
196    pub fn query_params<Q2>(self, params: Q2) -> RequestBuilder<'a, T, Q2, R>
197    where
198        Q2: Serialize + Send + Sync,
199    {
200        RequestBuilder {
201            client: self.client,
202            method: self.method,
203            path: self.path,
204            headers: self.headers,
205            body: self.body,
206            query_params: Some(params),
207            dc_restrict: self.dc_restrict,
208            mark_resp: self.mark_resp,
209        }
210    }
211
212    /// Set the response body type
213    #[must_use]
214    pub fn response<R2>(self) -> RequestBuilder<'a, T, Q, R2>
215    where
216        R2: FromPayload,
217    {
218        RequestBuilder {
219            client: self.client,
220            method: self.method,
221            path: self.path,
222            headers: self.headers,
223            body: self.body,
224            query_params: self.query_params,
225            dc_restrict: self.dc_restrict,
226            mark_resp: PhantomData,
227        }
228    }
229}
230
231impl<T, Q, R> RequestBuilder<'_, T, Q, R>
232where
233    T: ToPayload,
234    Q: Serialize + Send,
235    R: FromPayload,
236{
237    async fn http_url(&self) -> &str {
238        if let Some(url) = self.client.config.http_url.as_deref() {
239            return url;
240        }
241
242        if is_cn().await { HTTP_URL_CN } else { HTTP_URL }
243    }
244
245    async fn do_send(&self) -> HttpClientResult<R> {
246        let HttpClient {
247            http_cli,
248            config,
249            default_headers,
250        } = &self.client;
251        let timestamp = self
252            .headers
253            .get("X-Timestamp")
254            .and_then(|value| value.to_str().ok())
255            .and_then(|value| value.parse().ok())
256            .unwrap_or_else(Timestamp::now);
257
258        // Resolve app_key, access_token, optional app_secret, and the data-center
259        // region from the auth config.
260        let (app_key, access_token, app_secret, dc_region) = match &config.auth {
261            AuthConfig::ApiKey {
262                app_key,
263                app_secret,
264                access_token,
265            } => (
266                app_key.clone(),
267                access_token.clone(),
268                Some(app_secret.clone()),
269                DcRegion::from_credentials(&[app_key, access_token, app_secret]),
270            ),
271            AuthConfig::OAuth(oauth) => {
272                let token = oauth
273                    .access_token()
274                    .await
275                    .map_err(|e| HttpClientError::OAuth(e.to_string()))?;
276                // Derive DC region from the token prefix (us_→US, others→AP).
277                // The token is sent as-is (including any prefix); the gateway
278                // accepts the full token and routes via the x-dc-region header.
279                let region = DcRegion::from_credential(&token);
280                (
281                    oauth.client_id().to_string(),
282                    format!("Bearer {token}"),
283                    None,
284                    region,
285                )
286            }
287        };
288
289        // Short-circuit region-limited endpoints with a single unified error,
290        // instead of forwarding a request the target data center cannot serve.
291        if let Some(required) = self.dc_restrict
292            && !dc_region.allows(required)
293        {
294            return Err(HttpClientError::DcRegionRestricted {
295                path: self.path.clone(),
296                required,
297                current: dc_region,
298            });
299        }
300
301        let app_key_value =
302            HeaderValue::from_str(&app_key).map_err(|_| HttpClientError::InvalidApiKey)?;
303        let access_token_value = HeaderValue::from_str(&access_token)
304            .map_err(|_| HttpClientError::InvalidAccessToken)?;
305
306        let url = self.http_url().await;
307        let mut request_builder = http_cli
308            .request(self.method.clone(), format!("{}{}", url, self.path))
309            .headers(default_headers.clone())
310            .headers(self.headers.clone())
311            .header("User-Agent", USER_AGENT)
312            .header("X-Api-Key", app_key_value)
313            .header("Authorization", access_token_value)
314            .header("X-Timestamp", timestamp.to_string())
315            .header("Content-Type", "application/json; charset=utf-8");
316
317        // Route to the data center matching the credential's region (us/ap),
318        // unless the caller already set the header explicitly (e.g. via custom
319        // headers).
320        let region_already_set = default_headers.contains_key(DC_REGION_HEADER)
321            || self.headers.contains_key(DC_REGION_HEADER);
322        if !region_already_set {
323            request_builder = request_builder.header(DC_REGION_HEADER, dc_region.as_str());
324        }
325
326        // set the request body
327        if let Some(body) = &self.body {
328            let body = body
329                .to_bytes()
330                .map_err(|err| HttpClientError::SerializeRequestBody(err.to_string()))?;
331            request_builder = request_builder.body(body);
332        }
333
334        let mut request = request_builder.build().expect("invalid request");
335
336        // set the query string
337        if let Some(query_params) = &self.query_params {
338            let query_string = crate::qs::to_string(&query_params)?;
339            request.url_mut().set_query(Some(&query_string));
340        }
341
342        // Generate HMAC-SHA256 signature only for ApiKey mode
343        if let Some(secret) = app_secret {
344            let sign = signature(SignatureParams {
345                request: &request,
346                app_key: &app_key,
347                access_token: Some(&access_token),
348                app_secret: &secret,
349                timestamp,
350            });
351            if let Some(signature_value) = sign {
352                request.headers_mut().insert(
353                    "X-Api-Signature",
354                    HeaderValue::from_maybe_shared(signature_value).expect("valid signature"),
355                );
356            }
357        }
358
359        if let Some(body) = &self.body {
360            tracing::info!(method = %request.method(), url = %request.url(), body = ?body, "http request");
361        } else {
362            tracing::info!(method = %request.method(), url = %request.url(), "http request");
363        }
364
365        let s = Instant::now();
366
367        // send request
368        let (status, trace_id, headers, text) = tokio::time::timeout(REQUEST_TIMEOUT, async move {
369            let resp = http_cli
370                .execute(request)
371                .await
372                .map_err(|err| HttpClientError::Http(err.into()))?;
373            let status = resp.status();
374            let headers = resp.headers().clone();
375            let trace_id = resp
376                .headers()
377                .get("x-trace-id")
378                .and_then(|value| value.to_str().ok())
379                .unwrap_or_default()
380                .to_string();
381            let text = resp
382                .text()
383                .await
384                .map_err(|err| HttpClientError::Http(err.into()))?;
385            Ok::<_, HttpClientError>((status, trace_id, headers, text))
386        })
387        .await
388        .map_err(|_| HttpClientError::RequestTimeout)??;
389
390        tracing::info!(duration = ?s.elapsed(), body = %text.as_str(), "http response");
391
392        let resp = match serde_json::from_str::<OpenApiResponse>(&text) {
393            Ok(resp) if resp.code == 0 => resp.data.ok_or(HttpClientError::UnexpectedResponse),
394            Ok(resp) => Err(HttpClientError::OpenApi {
395                code: resp.code,
396                message: resp.message,
397                trace_id,
398            }),
399            Err(err) if status == StatusCode::OK => {
400                Err(HttpClientError::DeserializeResponseBody(err.to_string()))
401            }
402            Err(_) => Err(HttpClientError::UnexpectedHttpResponse {
403                status,
404                trace_id,
405                headers: Box::new(headers),
406                body: text,
407            }),
408        }?;
409
410        R::parse_from_bytes(resp.get().as_bytes())
411            .map_err(|err| HttpClientError::DeserializeResponseBody(err.to_string()))
412    }
413
414    /// Send request and get the response
415    pub async fn send(self) -> HttpClientResult<R> {
416        match self.do_send().await {
417            Ok(resp) => Ok(resp),
418            Err(err) if is_too_many_requests(&err) => {
419                let mut last_error = err;
420                let mut retry_delay = RETRY_INITIAL_DELAY;
421
422                for _ in 0..RETRY_COUNT {
423                    tokio::time::sleep(retry_delay).await;
424
425                    match self.do_send().await {
426                        Ok(resp) => return Ok(resp),
427                        Err(err) if is_too_many_requests(&err) => {
428                            last_error = err;
429                            retry_delay =
430                                Duration::from_secs_f32(retry_delay.as_secs_f32() * RETRY_FACTOR);
431                            continue;
432                        }
433                        Err(err) => return Err(err),
434                    }
435                }
436
437                Err(last_error)
438            }
439            Err(err) => Err(err),
440        }
441    }
442}
443
444fn is_too_many_requests(err: &HttpClientError) -> bool {
445    matches!(
446        err,
447        HttpClientError::BadStatus(StatusCode::TOO_MANY_REQUESTS)
448            | HttpClientError::UnexpectedHttpResponse {
449                status: StatusCode::TOO_MANY_REQUESTS,
450                ..
451            }
452    )
453}
454
455#[cfg(test)]
456mod tests {
457    use reqwest::{StatusCode, header::HeaderMap};
458
459    use super::is_too_many_requests;
460    use crate::HttpClientError;
461
462    #[test]
463    fn unexpected_http_response_preserves_original_context() {
464        let mut headers = HeaderMap::new();
465        headers.insert("server", "awselb/2.0".parse().unwrap());
466        let body = "<html><body>Too many IPs in X-Forwarded-For header.</body></html>";
467        let err = HttpClientError::UnexpectedHttpResponse {
468            status: StatusCode::from_u16(463).unwrap(),
469            trace_id: "trace-463".to_string(),
470            headers: Box::new(headers),
471            body: body.to_string(),
472        };
473
474        let HttpClientError::UnexpectedHttpResponse {
475            status,
476            trace_id,
477            headers,
478            body: preserved_body,
479        } = &err
480        else {
481            panic!("unexpected error variant");
482        };
483        assert_eq!(status.as_u16(), 463);
484        assert_eq!(trace_id, "trace-463");
485        assert_eq!(headers["server"], "awselb/2.0");
486        assert_eq!(preserved_body, body);
487        assert_eq!(
488            err.to_string(),
489            format!(
490                "unexpected HTTP response: status=463 <unknown status code>, trace_id=trace-463, body={body}"
491            )
492        );
493    }
494
495    #[test]
496    fn rich_rate_limit_response_remains_retryable() {
497        let err = HttpClientError::UnexpectedHttpResponse {
498            status: StatusCode::TOO_MANY_REQUESTS,
499            trace_id: String::new(),
500            headers: Box::new(HeaderMap::new()),
501            body: "rate limited".to_string(),
502        };
503
504        assert!(is_too_many_requests(&err));
505    }
506}