Skip to main content

longbridge_httpcli/
request.rs

1use std::{
2    convert::Infallible,
3    error::Error,
4    fmt::Debug,
5    marker::PhantomData,
6    pin::Pin,
7    time::{Duration, Instant},
8};
9
10use eventsource_stream::{Event as SseEvent, Eventsource};
11use futures_util::{Stream, StreamExt};
12use longbridge_geo::{DC_REGION_HEADER, DcRegion, is_cn};
13use reqwest::{
14    Method, StatusCode,
15    header::{ACCEPT, HeaderMap, HeaderName, HeaderValue},
16};
17use serde::{Deserialize, Serialize, de::DeserializeOwned};
18
19use crate::{
20    AuthConfig, HttpClient, HttpClientError, HttpClientResult,
21    signature::{SignatureParams, signature},
22    timestamp::Timestamp,
23};
24
25const HTTP_URL: &str = "https://openapi.longbridge.com";
26const HTTP_URL_CN: &str = "https://openapi.longbridge.cn";
27
28const USER_AGENT: &str = "openapi-sdk";
29const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
30const RETRY_COUNT: usize = 5;
31const RETRY_INITIAL_DELAY: Duration = Duration::from_millis(100);
32const RETRY_FACTOR: f32 = 2.0;
33
34/// A JSON payload
35#[derive(Debug)]
36pub struct Json<T>(pub T);
37
38/// Represents a type that can parse from payload
39pub trait FromPayload: Sized + Send + Sync + 'static {
40    /// A error type
41    type Err: Error;
42
43    /// Parse the payload to this object
44    fn parse_from_bytes(data: &[u8]) -> Result<Self, Self::Err>;
45}
46
47/// Represents a type that can convert to payload
48pub trait ToPayload: Debug + Sized + Send + Sync + 'static {
49    /// A error type
50    type Err: Error;
51
52    /// Convert this object to the payload
53    fn to_bytes(&self) -> Result<Vec<u8>, Self::Err>;
54}
55
56impl<T> FromPayload for Json<T>
57where
58    T: DeserializeOwned + Send + Sync + 'static,
59{
60    type Err = serde_json::Error;
61
62    #[inline]
63    fn parse_from_bytes(data: &[u8]) -> Result<Self, Self::Err> {
64        Ok(Json(serde_json::from_slice(data)?))
65    }
66}
67
68impl<T> ToPayload for Json<T>
69where
70    T: Debug + Serialize + Send + Sync + 'static,
71{
72    type Err = serde_json::Error;
73
74    #[inline]
75    fn to_bytes(&self) -> Result<Vec<u8>, Self::Err> {
76        serde_json::to_vec(&self.0)
77    }
78}
79
80impl FromPayload for String {
81    type Err = std::string::FromUtf8Error;
82
83    #[inline]
84    fn parse_from_bytes(data: &[u8]) -> Result<Self, Self::Err> {
85        String::from_utf8(data.to_vec())
86    }
87}
88
89impl ToPayload for String {
90    type Err = std::string::FromUtf8Error;
91
92    #[inline]
93    fn to_bytes(&self) -> Result<Vec<u8>, Self::Err> {
94        Ok(self.clone().into_bytes())
95    }
96}
97
98impl FromPayload for () {
99    type Err = Infallible;
100
101    #[inline]
102    fn parse_from_bytes(_data: &[u8]) -> Result<Self, Self::Err> {
103        Ok(())
104    }
105}
106
107impl ToPayload for () {
108    type Err = Infallible;
109
110    #[inline]
111    fn to_bytes(&self) -> Result<Vec<u8>, Self::Err> {
112        Ok(vec![])
113    }
114}
115
116#[derive(Deserialize)]
117struct OpenApiResponse {
118    code: i32,
119    message: String,
120    data: Option<Box<serde_json::value::RawValue>>,
121}
122
123/// A request builder
124pub struct RequestBuilder<'a, T, Q, R> {
125    client: &'a HttpClient,
126    method: Method,
127    path: String,
128    headers: HeaderMap,
129    body: Option<T>,
130    query_params: Option<Q>,
131    dc_restrict: Option<DcRegion>,
132    timeout: Option<Duration>,
133    mark_resp: PhantomData<R>,
134}
135
136impl<'a> RequestBuilder<'a, (), (), ()> {
137    pub(crate) fn new(client: &'a HttpClient, method: Method, path: impl Into<String>) -> Self {
138        Self {
139            client,
140            method,
141            path: path.into(),
142            headers: Default::default(),
143            body: None,
144            query_params: None,
145            dc_restrict: None,
146            timeout: None,
147            mark_resp: PhantomData,
148        }
149    }
150}
151
152impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> {
153    /// Set the request body
154    #[must_use]
155    pub fn body<T2>(self, body: T2) -> RequestBuilder<'a, T2, Q, R>
156    where
157        T2: ToPayload,
158    {
159        RequestBuilder {
160            client: self.client,
161            method: self.method,
162            path: self.path,
163            headers: self.headers,
164            body: Some(body),
165            query_params: self.query_params,
166            dc_restrict: self.dc_restrict,
167            timeout: self.timeout,
168            mark_resp: self.mark_resp,
169        }
170    }
171
172    /// Set the header
173    #[must_use]
174    pub fn header<K, V>(mut self, key: K, value: V) -> Self
175    where
176        K: TryInto<HeaderName>,
177        V: TryInto<HeaderValue>,
178    {
179        let key = key.try_into();
180        let value = value.try_into();
181        if let (Ok(key), Ok(value)) = (key, value) {
182            self.headers.insert(key, value);
183        }
184        self
185    }
186
187    /// Restrict this request to a single data center.
188    ///
189    /// When set, [`do_send`](Self::do_send) short-circuits with
190    /// [`HttpClientError::DcRegionRestricted`] if the session's region differs,
191    /// instead of forwarding a request the target data center cannot serve.
192    /// Call sites for region-limited endpoints declare their region here —
193    /// `Ap` for AP-only APIs, `Us` for US-only ones.
194    #[must_use]
195    pub fn dc_restrict(mut self, region: DcRegion) -> Self {
196        self.dc_restrict = Some(region);
197        self
198    }
199
200    /// Override the default request timeout ([`REQUEST_TIMEOUT`], 30s) for
201    /// this call. Most endpoints respond quickly and should stick with the
202    /// default; this exists for the rare domain where a slower backend (e.g.
203    /// an LLM-backed one) makes the shared default too tight, without
204    /// changing that default for every other endpoint.
205    #[must_use]
206    pub fn timeout(mut self, timeout: Duration) -> Self {
207        self.timeout = Some(timeout);
208        self
209    }
210
211    /// Set the query string
212    #[must_use]
213    pub fn query_params<Q2>(self, params: Q2) -> RequestBuilder<'a, T, Q2, R>
214    where
215        Q2: Serialize + Send + Sync,
216    {
217        RequestBuilder {
218            client: self.client,
219            method: self.method,
220            path: self.path,
221            headers: self.headers,
222            body: self.body,
223            query_params: Some(params),
224            dc_restrict: self.dc_restrict,
225            timeout: self.timeout,
226            mark_resp: self.mark_resp,
227        }
228    }
229
230    /// Set the response body type
231    #[must_use]
232    pub fn response<R2>(self) -> RequestBuilder<'a, T, Q, R2>
233    where
234        R2: FromPayload,
235    {
236        RequestBuilder {
237            client: self.client,
238            method: self.method,
239            path: self.path,
240            headers: self.headers,
241            body: self.body,
242            query_params: self.query_params,
243            dc_restrict: self.dc_restrict,
244            timeout: self.timeout,
245            mark_resp: PhantomData,
246        }
247    }
248}
249
250/// Parse the `{code, message, data}` OpenAPI response envelope, given the HTTP
251/// status and trace id already extracted from the response. Shared by the
252/// blocking (`do_send`) and streaming (`send_events`) request paths, since both
253/// can receive this envelope as an error body (streaming responses only use SSE
254/// framing once the server has committed to a 200 status).
255fn parse_response_envelope(
256    status: StatusCode,
257    trace_id: &str,
258    text: &str,
259) -> HttpClientResult<Box<serde_json::value::RawValue>> {
260    match serde_json::from_str::<OpenApiResponse>(text) {
261        Ok(resp) if resp.code == 0 => resp.data.ok_or(HttpClientError::UnexpectedResponse),
262        Ok(resp) => Err(HttpClientError::OpenApi {
263            code: resp.code,
264            message: resp.message,
265            trace_id: trace_id.to_string(),
266        }),
267        Err(err) if status == StatusCode::OK => {
268            Err(HttpClientError::DeserializeResponseBody(err.to_string()))
269        }
270        Err(_) => Err(HttpClientError::BadStatus(status)),
271    }
272}
273
274impl<T, Q, R> RequestBuilder<'_, T, Q, R>
275where
276    T: ToPayload,
277    Q: Serialize + Send,
278{
279    async fn http_url(&self) -> &str {
280        if let Some(url) = self.client.config.http_url.as_deref() {
281            return url;
282        }
283
284        if is_cn().await { HTTP_URL_CN } else { HTTP_URL }
285    }
286
287    /// Resolve auth/dc-region, build and sign the underlying
288    /// [`reqwest::Request`]. Shared by both the blocking (`do_send`) and
289    /// streaming (`send_events`) request paths.
290    async fn build_request(&self) -> HttpClientResult<reqwest::Request> {
291        let HttpClient {
292            http_cli,
293            config,
294            default_headers,
295        } = &self.client;
296        let timestamp = self
297            .headers
298            .get("X-Timestamp")
299            .and_then(|value| value.to_str().ok())
300            .and_then(|value| value.parse().ok())
301            .unwrap_or_else(Timestamp::now);
302
303        // Resolve app_key, access_token, optional app_secret, and the data-center
304        // region from the auth config.
305        let (app_key, access_token, app_secret, dc_region) = match &config.auth {
306            AuthConfig::ApiKey {
307                app_key,
308                app_secret,
309                access_token,
310            } => (
311                app_key.clone(),
312                access_token.clone(),
313                Some(app_secret.clone()),
314                DcRegion::from_credentials(&[app_key, access_token, app_secret]),
315            ),
316            AuthConfig::OAuth(oauth) => {
317                let token = oauth
318                    .access_token()
319                    .await
320                    .map_err(|e| HttpClientError::OAuth(e.to_string()))?;
321                // Derive DC region from the token prefix (us_→US, others→AP).
322                // The token is sent as-is (including any prefix); the gateway
323                // accepts the full token and routes via the x-dc-region header.
324                let region = DcRegion::from_credential(&token);
325                (
326                    oauth.client_id().to_string(),
327                    format!("Bearer {token}"),
328                    None,
329                    region,
330                )
331            }
332        };
333
334        // Short-circuit region-limited endpoints with a single unified error,
335        // instead of forwarding a request the target data center cannot serve.
336        if let Some(required) = self.dc_restrict
337            && !dc_region.allows(required)
338        {
339            return Err(HttpClientError::DcRegionRestricted {
340                path: self.path.clone(),
341                required,
342                current: dc_region,
343            });
344        }
345
346        let app_key_value =
347            HeaderValue::from_str(&app_key).map_err(|_| HttpClientError::InvalidApiKey)?;
348        let access_token_value = HeaderValue::from_str(&access_token)
349            .map_err(|_| HttpClientError::InvalidAccessToken)?;
350
351        let url = self.http_url().await;
352        let mut request_builder = http_cli
353            .request(self.method.clone(), format!("{}{}", url, self.path))
354            .headers(default_headers.clone())
355            .headers(self.headers.clone())
356            .header("User-Agent", USER_AGENT)
357            .header("X-Api-Key", app_key_value)
358            .header("Authorization", access_token_value)
359            .header("X-Timestamp", timestamp.to_string())
360            .header("Content-Type", "application/json; charset=utf-8");
361
362        // Route to the data center matching the credential's region (us/ap),
363        // unless the caller already set the header explicitly (e.g. via custom
364        // headers).
365        let region_already_set = default_headers.contains_key(DC_REGION_HEADER)
366            || self.headers.contains_key(DC_REGION_HEADER);
367        if !region_already_set {
368            request_builder = request_builder.header(DC_REGION_HEADER, dc_region.as_str());
369        }
370
371        // set the request body
372        if let Some(body) = &self.body {
373            let body = body
374                .to_bytes()
375                .map_err(|err| HttpClientError::SerializeRequestBody(err.to_string()))?;
376            request_builder = request_builder.body(body);
377        }
378
379        let mut request = request_builder.build().expect("invalid request");
380
381        // set the query string
382        if let Some(query_params) = &self.query_params {
383            let query_string = crate::qs::to_string(&query_params)?;
384            request.url_mut().set_query(Some(&query_string));
385        }
386
387        // Generate HMAC-SHA256 signature only for ApiKey mode
388        if let Some(secret) = app_secret {
389            let sign = signature(SignatureParams {
390                request: &request,
391                app_key: &app_key,
392                access_token: Some(&access_token),
393                app_secret: &secret,
394                timestamp,
395            });
396            if let Some(signature_value) = sign {
397                request.headers_mut().insert(
398                    "X-Api-Signature",
399                    HeaderValue::from_maybe_shared(signature_value).expect("valid signature"),
400                );
401            }
402        }
403
404        if let Some(body) = &self.body {
405            tracing::info!(method = %request.method(), url = %request.url(), body = ?body, "http request");
406        } else {
407            tracing::info!(method = %request.method(), url = %request.url(), "http request");
408        }
409
410        Ok(request)
411    }
412}
413
414impl<T, Q, R> RequestBuilder<'_, T, Q, R>
415where
416    T: ToPayload,
417    Q: Serialize + Send,
418    R: FromPayload,
419{
420    async fn do_send(&self) -> HttpClientResult<R> {
421        let http_cli = &self.client.http_cli;
422        let request = self.build_request().await?;
423
424        let s = Instant::now();
425        let timeout = self.timeout.unwrap_or(REQUEST_TIMEOUT);
426
427        // send request
428        let (status, trace_id, headers, text) = tokio::time::timeout(timeout, async move {
429            let resp = http_cli
430                .execute(request)
431                .await
432                .map_err(|err| HttpClientError::Http(err.into()))?;
433            let status = resp.status();
434            let headers = resp.headers().clone();
435            let trace_id = resp
436                .headers()
437                .get("x-trace-id")
438                .and_then(|value| value.to_str().ok())
439                .unwrap_or_default()
440                .to_string();
441            let text = resp
442                .text()
443                .await
444                .map_err(|err| HttpClientError::Http(err.into()))?;
445            Ok::<_, HttpClientError>((status, trace_id, headers, text))
446        })
447        .await
448        .map_err(|_| HttpClientError::RequestTimeout)??;
449
450        tracing::info!(duration = ?s.elapsed(), body = %text.as_str(), "http response");
451
452        let data = match serde_json::from_str::<OpenApiResponse>(&text) {
453            Ok(resp) if resp.code == 0 => resp.data.ok_or(HttpClientError::UnexpectedResponse),
454            Ok(resp) => Err(HttpClientError::OpenApi {
455                code: resp.code,
456                message: resp.message,
457                trace_id,
458            }),
459            Err(err) if status == StatusCode::OK => {
460                Err(HttpClientError::DeserializeResponseBody(err.to_string()))
461            }
462            Err(_) => Err(HttpClientError::UnexpectedHttpResponse {
463                status,
464                trace_id,
465                headers: Box::new(headers),
466                body: text,
467            }),
468        }?;
469
470        R::parse_from_bytes(data.get().as_bytes())
471            .map_err(|err| HttpClientError::DeserializeResponseBody(err.to_string()))
472    }
473
474    /// Send request and get the response
475    pub async fn send(self) -> HttpClientResult<R> {
476        match self.do_send().await {
477            Ok(resp) => Ok(resp),
478            Err(err) if is_too_many_requests(&err) => {
479                let mut last_error = err;
480                let mut retry_delay = RETRY_INITIAL_DELAY;
481
482                for _ in 0..RETRY_COUNT {
483                    tokio::time::sleep(retry_delay).await;
484
485                    match self.do_send().await {
486                        Ok(resp) => return Ok(resp),
487                        Err(err) if is_too_many_requests(&err) => {
488                            last_error = err;
489                            retry_delay =
490                                Duration::from_secs_f32(retry_delay.as_secs_f32() * RETRY_FACTOR);
491                            continue;
492                        }
493                        Err(err) => return Err(err),
494                    }
495                }
496
497                Err(last_error)
498            }
499            Err(err) => Err(err),
500        }
501    }
502}
503
504fn is_too_many_requests(err: &HttpClientError) -> bool {
505    matches!(
506        err,
507        HttpClientError::BadStatus(StatusCode::TOO_MANY_REQUESTS)
508            | HttpClientError::UnexpectedHttpResponse {
509                status: StatusCode::TOO_MANY_REQUESTS,
510                ..
511            }
512    )
513}
514
515impl<T, Q> RequestBuilder<'_, T, Q, ()>
516where
517    T: ToPayload,
518    Q: Serialize + Send,
519{
520    /// Send the request with `Accept: text/event-stream` and return a stream of
521    /// parsed SSE events, instead of buffering the full response body like
522    /// [`send`](RequestBuilder::send) does. There's no automatic 429 retry here
523    /// — once a stream starts delivering events it can't be replayed as a
524    /// whole; a failure is handed back to the caller to decide whether to
525    /// start a new call.
526    pub async fn send_events(
527        self,
528    ) -> HttpClientResult<Pin<Box<dyn Stream<Item = HttpClientResult<SseEvent>> + Send>>> {
529        let http_cli = self.client.http_cli.clone();
530        let timeout = self.timeout.unwrap_or(REQUEST_TIMEOUT);
531        let mut request = self.build_request().await?;
532        request
533            .headers_mut()
534            .insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
535
536        // Only bounds establishing the connection (getting a status/headers
537        // back), not the subsequent event-by-event reads below — those can
538        // legitimately take as long as the agent takes to answer.
539        let resp = tokio::time::timeout(timeout, http_cli.execute(request))
540            .await
541            .map_err(|_| HttpClientError::RequestTimeout)?
542            .map_err(|err| HttpClientError::Http(err.into()))?;
543        let status = resp.status();
544
545        if status != StatusCode::OK {
546            // Error responses are still a one-shot JSON body ({code, message}), not SSE.
547            let trace_id = resp
548                .headers()
549                .get("x-trace-id")
550                .and_then(|value| value.to_str().ok())
551                .unwrap_or_default()
552                .to_string();
553            let text = resp
554                .text()
555                .await
556                .map_err(|err| HttpClientError::Http(err.into()))?;
557            return Err(match parse_response_envelope(status, &trace_id, &text) {
558                Ok(_) => HttpClientError::UnexpectedResponse,
559                Err(err) => err,
560            });
561        }
562
563        let stream = resp.bytes_stream().eventsource().map(|item| {
564            item.map_err(|err| match err {
565                eventsource_stream::EventStreamError::Transport(err) => {
566                    HttpClientError::Http(err.into())
567                }
568                err => HttpClientError::Sse(err.to_string()),
569            })
570        });
571        Ok(Box::pin(stream))
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use reqwest::{StatusCode, header::HeaderMap};
578
579    use super::is_too_many_requests;
580    use crate::HttpClientError;
581
582    #[test]
583    fn unexpected_http_response_preserves_original_context() {
584        let mut headers = HeaderMap::new();
585        headers.insert("server", "awselb/2.0".parse().unwrap());
586        let body = "<html><body>Too many IPs in X-Forwarded-For header.</body></html>";
587        let err = HttpClientError::UnexpectedHttpResponse {
588            status: StatusCode::from_u16(463).unwrap(),
589            trace_id: "trace-463".to_string(),
590            headers: Box::new(headers),
591            body: body.to_string(),
592        };
593
594        let HttpClientError::UnexpectedHttpResponse {
595            status,
596            trace_id,
597            headers,
598            body: preserved_body,
599        } = &err
600        else {
601            panic!("unexpected error variant");
602        };
603        assert_eq!(status.as_u16(), 463);
604        assert_eq!(trace_id, "trace-463");
605        assert_eq!(headers["server"], "awselb/2.0");
606        assert_eq!(preserved_body, body);
607        assert_eq!(
608            err.to_string(),
609            format!(
610                "unexpected HTTP response: status=463 <unknown status code>, trace_id=trace-463, body={body}"
611            )
612        );
613    }
614
615    #[test]
616    fn rich_rate_limit_response_remains_retryable() {
617        let err = HttpClientError::UnexpectedHttpResponse {
618            status: StatusCode::TOO_MANY_REQUESTS,
619            trace_id: String::new(),
620            headers: Box::new(HeaderMap::new()),
621            body: "rate limited".to_string(),
622        };
623
624        assert!(is_too_many_requests(&err));
625    }
626}