Skip to main content

polyoxide_clob/
request.rs

1use std::marker::PhantomData;
2
3use alloy::primitives::Address;
4use polyoxide_core::{current_timestamp, request::QueryBuilder, retry_after_header, HttpClient};
5use reqwest::{Method, Response};
6use serde::de::DeserializeOwned;
7
8use crate::{
9    account::{Credentials, Signer, Wallet},
10    error::ClobError,
11};
12
13/// Authentication mode for requests
14#[derive(Debug, Clone)]
15pub enum AuthMode {
16    None,
17    L1 {
18        wallet: Wallet,
19        nonce: u32,
20    },
21    L2 {
22        address: Address,
23        credentials: Credentials,
24        signer: Signer,
25    },
26}
27
28/// Generic request builder for CLOB API
29pub struct Request<T> {
30    pub(crate) http_client: HttpClient,
31    pub(crate) path: String,
32    pub(crate) method: Method,
33    pub(crate) query: Vec<(String, String)>,
34    pub(crate) body: Option<serde_json::Value>,
35    pub(crate) auth: AuthMode,
36    pub(crate) chain_id: u64,
37    pub(crate) _marker: PhantomData<T>,
38}
39
40impl<T> Request<T> {
41    /// Create a new GET request
42    pub(crate) fn get(
43        http_client: HttpClient,
44        path: impl Into<String>,
45        auth: AuthMode,
46        chain_id: u64,
47    ) -> Self {
48        Self {
49            http_client,
50            path: path.into(),
51            method: Method::GET,
52            query: Vec::new(),
53            body: None,
54            auth,
55            chain_id,
56            _marker: PhantomData,
57        }
58    }
59
60    /// Create a new POST request
61    pub(crate) fn post(
62        http_client: HttpClient,
63        path: String,
64        auth: AuthMode,
65        chain_id: u64,
66    ) -> Self {
67        Self {
68            http_client,
69            path,
70            method: Method::POST,
71            query: Vec::new(),
72            body: None,
73            auth,
74            chain_id,
75            _marker: PhantomData,
76        }
77    }
78
79    /// Create a new DELETE request
80    pub(crate) fn delete(
81        http_client: HttpClient,
82        path: impl Into<String>,
83        auth: AuthMode,
84        chain_id: u64,
85    ) -> Self {
86        Self {
87            http_client,
88            path: path.into(),
89            method: Method::DELETE,
90            query: Vec::new(),
91            body: None,
92            auth,
93            chain_id,
94            _marker: PhantomData,
95        }
96    }
97
98    /// Set request body
99    pub fn body<B: serde::Serialize + ?Sized>(mut self, body: &B) -> Result<Self, ClobError> {
100        self.body = Some(serde_json::to_value(body)?);
101        Ok(self)
102    }
103}
104
105impl<T> QueryBuilder for Request<T> {
106    fn add_query(&mut self, key: String, value: String) {
107        self.query.push((key, value));
108    }
109}
110
111impl<T: DeserializeOwned> Request<T> {
112    /// Execute the request and deserialize response
113    pub async fn send(self) -> Result<T, ClobError> {
114        let response = self.send_raw().await?;
115
116        let text = response.text().await?;
117
118        // Deserialize and provide better error context
119        serde_json::from_str(&text).map_err(|e| {
120            tracing::error!("Deserialization failed: {}", e);
121            tracing::error!(
122                "Failed to deserialize: {}",
123                polyoxide_core::truncate_for_log(&text)
124            );
125            e.into()
126        })
127    }
128
129    /// Execute the request and return raw response
130    pub async fn send_raw(self) -> Result<Response, ClobError> {
131        let url = self.http_client.base_url.join(&self.path)?;
132
133        let http_client = self.http_client;
134        let path = self.path;
135        let method = self.method;
136        let query = self.query;
137        let body = self.body;
138        let auth = self.auth;
139        let chain_id = self.chain_id;
140        let mut attempt = 0u32;
141
142        loop {
143            let _permit = http_client.acquire_concurrency().await;
144            http_client.acquire_rate_limit(&path, Some(&method)).await;
145
146            // Build the base request — rebuilt each iteration so auth timestamps are fresh
147            let mut request = match method {
148                Method::GET => http_client.client.get(url.clone()),
149                Method::POST => {
150                    let mut req = http_client.client.post(url.clone());
151                    if let Some(ref body) = body {
152                        req = req.header("Content-Type", "application/json").json(body);
153                    }
154                    req
155                }
156                Method::DELETE => {
157                    let mut req = http_client.client.delete(url.clone());
158                    if let Some(ref body) = body {
159                        req = req.header("Content-Type", "application/json").json(body);
160                    }
161                    req
162                }
163                _ => return Err(ClobError::validation("Unsupported HTTP method")),
164            };
165
166            // Add query parameters
167            if !query.is_empty() {
168                request = request.query(&query);
169            }
170
171            // Add authentication headers (fresh timestamp on each attempt)
172            request = add_auth_headers(request, &auth, &path, &method, &body, chain_id).await?;
173
174            // Execute request
175            let response = request.send().await?;
176            let status = response.status();
177            let retry_after = retry_after_header(&response);
178
179            if let Some(backoff) = http_client.should_retry(status, attempt, retry_after.as_deref())
180            {
181                attempt += 1;
182                tracing::warn!(
183                    "Rate limited (429) on {}, retry {} after {}ms",
184                    path,
185                    attempt,
186                    backoff.as_millis()
187                );
188                drop(_permit);
189                tokio::time::sleep(backoff).await;
190                continue;
191            }
192
193            tracing::debug!("Response status: {}", status);
194
195            if !status.is_success() {
196                let error = ClobError::from_response(response).await;
197                tracing::error!("Request failed: {:?}", error);
198                return Err(error);
199            }
200
201            return Ok(response);
202        }
203    }
204}
205
206/// Add authentication headers based on auth mode (free function for retry loop)
207async fn add_auth_headers(
208    mut request: reqwest::RequestBuilder,
209    auth: &AuthMode,
210    path: &str,
211    method: &Method,
212    body: &Option<serde_json::Value>,
213    chain_id: u64,
214) -> Result<reqwest::RequestBuilder, ClobError> {
215    match auth {
216        AuthMode::None => Ok(request),
217        AuthMode::L1 { wallet, nonce } => {
218            use crate::core::eip712::sign_clob_auth;
219
220            // Fresh timestamp on each attempt to avoid staleness after retry backoff
221            let timestamp = current_timestamp();
222            let signature = sign_clob_auth(wallet.signer(), chain_id, timestamp, *nonce).await?;
223
224            request = request
225                .header("POLY_ADDRESS", format!("{:?}", wallet.address()))
226                .header("POLY_SIGNATURE", signature)
227                .header("POLY_TIMESTAMP", timestamp.to_string())
228                .header("POLY_NONCE", nonce.to_string());
229
230            Ok(request)
231        }
232        AuthMode::L2 {
233            address,
234            credentials,
235            signer,
236        } => {
237            let timestamp = current_timestamp();
238            let body_str = body.as_ref().map(|b| b.to_string());
239            let message =
240                Signer::create_message(timestamp, method.as_str(), path, body_str.as_deref());
241            let signature = signer.sign(&message)?;
242
243            request = request
244                .header("POLY_ADDRESS", format!("{:?}", address))
245                .header("POLY_SIGNATURE", signature)
246                .header("POLY_TIMESTAMP", timestamp.to_string())
247                .header("POLY_API_KEY", &credentials.key)
248                .header("POLY_PASSPHRASE", &credentials.passphrase);
249
250            Ok(request)
251        }
252    }
253}