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    /// Create a new PUT request
99    pub(crate) fn put(
100        http_client: HttpClient,
101        path: impl Into<String>,
102        auth: AuthMode,
103        chain_id: u64,
104    ) -> Self {
105        Self {
106            http_client,
107            path: path.into(),
108            method: Method::PUT,
109            query: Vec::new(),
110            body: None,
111            auth,
112            chain_id,
113            _marker: PhantomData,
114        }
115    }
116
117    /// Set request body
118    pub fn body<B: serde::Serialize + ?Sized>(mut self, body: &B) -> Result<Self, ClobError> {
119        self.body = Some(serde_json::to_value(body)?);
120        Ok(self)
121    }
122}
123
124impl<T> QueryBuilder for Request<T> {
125    fn add_query(&mut self, key: String, value: String) {
126        self.query.push((key, value));
127    }
128}
129
130impl<T: DeserializeOwned> Request<T> {
131    /// Execute the request and deserialize response
132    pub async fn send(self) -> Result<T, ClobError> {
133        let response = self.send_raw().await?;
134
135        let text = response.text().await?;
136
137        // Deserialize and provide better error context
138        serde_json::from_str(&text).map_err(|e| {
139            tracing::error!("Deserialization failed: {}", e);
140            tracing::error!(
141                "Failed to deserialize: {}",
142                polyoxide_core::truncate_for_log(&text)
143            );
144            e.into()
145        })
146    }
147
148    /// Execute the request and return raw response
149    pub async fn send_raw(self) -> Result<Response, ClobError> {
150        let url = self.http_client.base_url.join(&self.path)?;
151
152        let http_client = self.http_client;
153        let path = self.path;
154        let method = self.method;
155        let query = self.query;
156        let body = self.body;
157        let auth = self.auth;
158        let chain_id = self.chain_id;
159        let mut attempt = 0u32;
160
161        loop {
162            let _permit = http_client.acquire_concurrency().await;
163            http_client.acquire_rate_limit(&path, Some(&method)).await;
164
165            // Build the base request — rebuilt each iteration so auth timestamps are fresh
166            let mut request = match method {
167                Method::GET => http_client.client.get(url.clone()),
168                Method::POST => {
169                    let mut req = http_client.client.post(url.clone());
170                    if let Some(ref body) = body {
171                        req = req.header("Content-Type", "application/json").json(body);
172                    }
173                    req
174                }
175                Method::DELETE => {
176                    let mut req = http_client.client.delete(url.clone());
177                    if let Some(ref body) = body {
178                        req = req.header("Content-Type", "application/json").json(body);
179                    }
180                    req
181                }
182                Method::PUT => {
183                    let mut req = http_client.client.put(url.clone());
184                    if let Some(ref body) = body {
185                        req = req.header("Content-Type", "application/json").json(body);
186                    }
187                    req
188                }
189                _ => return Err(ClobError::validation("Unsupported HTTP method")),
190            };
191
192            // Add query parameters
193            if !query.is_empty() {
194                request = request.query(&query);
195            }
196
197            // Add authentication headers (fresh timestamp on each attempt)
198            request = add_auth_headers(request, &auth, &path, &method, &body, chain_id).await?;
199
200            // Execute request
201            let response = request.send().await?;
202            let status = response.status();
203            let retry_after = retry_after_header(&response);
204
205            if let Some(backoff) = http_client.should_retry(status, attempt, retry_after.as_deref())
206            {
207                attempt += 1;
208                tracing::warn!(
209                    "Rate limited (429) on {}, retry {} after {}ms",
210                    path,
211                    attempt,
212                    backoff.as_millis()
213                );
214                drop(_permit);
215                tokio::time::sleep(backoff).await;
216                continue;
217            }
218
219            tracing::debug!("Response status: {}", status);
220
221            if !status.is_success() {
222                let error = ClobError::from_response(response).await;
223                tracing::error!("Request failed: {:?}", error);
224                return Err(error);
225            }
226
227            return Ok(response);
228        }
229    }
230}
231
232/// Add authentication headers based on auth mode (free function for retry loop)
233async fn add_auth_headers(
234    mut request: reqwest::RequestBuilder,
235    auth: &AuthMode,
236    path: &str,
237    method: &Method,
238    body: &Option<serde_json::Value>,
239    chain_id: u64,
240) -> Result<reqwest::RequestBuilder, ClobError> {
241    match auth {
242        AuthMode::None => Ok(request),
243        AuthMode::L1 { wallet, nonce } => {
244            use crate::core::eip712::sign_clob_auth;
245
246            // Fresh timestamp on each attempt to avoid staleness after retry backoff
247            let timestamp = current_timestamp();
248            let signature = sign_clob_auth(wallet.signer(), chain_id, timestamp, *nonce).await?;
249
250            request = request
251                .header("POLY_ADDRESS", format!("{:?}", wallet.address()))
252                .header("POLY_SIGNATURE", signature)
253                .header("POLY_TIMESTAMP", timestamp.to_string())
254                .header("POLY_NONCE", nonce.to_string());
255
256            Ok(request)
257        }
258        AuthMode::L2 {
259            address,
260            credentials,
261            signer,
262        } => {
263            let timestamp = current_timestamp();
264            let body_str = body.as_ref().map(|b| b.to_string());
265            let message =
266                Signer::create_message(timestamp, method.as_str(), path, body_str.as_deref());
267            let signature = signer.sign(&message)?;
268
269            request = request
270                .header("POLY_ADDRESS", format!("{:?}", address))
271                .header("POLY_SIGNATURE", signature)
272                .header("POLY_TIMESTAMP", timestamp.to_string())
273                .header("POLY_API_KEY", &credentials.key)
274                .header("POLY_PASSPHRASE", &credentials.passphrase);
275
276            Ok(request)
277        }
278    }
279}