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            http_client.acquire_rate_limit(&path, Some(&method)).await;
144
145            // Build the base request — rebuilt each iteration so auth timestamps are fresh
146            let mut request = match method {
147                Method::GET => http_client.client.get(url.clone()),
148                Method::POST => {
149                    let mut req = http_client.client.post(url.clone());
150                    if let Some(ref body) = body {
151                        req = req.header("Content-Type", "application/json").json(body);
152                    }
153                    req
154                }
155                Method::DELETE => {
156                    let mut req = http_client.client.delete(url.clone());
157                    if let Some(ref body) = body {
158                        req = req.header("Content-Type", "application/json").json(body);
159                    }
160                    req
161                }
162                _ => return Err(ClobError::validation("Unsupported HTTP method")),
163            };
164
165            // Add query parameters
166            if !query.is_empty() {
167                request = request.query(&query);
168            }
169
170            // Add authentication headers (fresh timestamp on each attempt)
171            request = add_auth_headers(request, &auth, &path, &method, &body, chain_id).await?;
172
173            // Execute request
174            let response = request.send().await?;
175            let status = response.status();
176            let retry_after = retry_after_header(&response);
177
178            if let Some(backoff) = http_client.should_retry(status, attempt, retry_after.as_deref())
179            {
180                attempt += 1;
181                tracing::warn!(
182                    "Rate limited (429) on {}, retry {} after {}ms",
183                    path,
184                    attempt,
185                    backoff.as_millis()
186                );
187                tokio::time::sleep(backoff).await;
188                continue;
189            }
190
191            tracing::debug!("Response status: {}", status);
192
193            if !status.is_success() {
194                let error = ClobError::from_response(response).await;
195                tracing::error!("Request failed: {:?}", error);
196                return Err(error);
197            }
198
199            return Ok(response);
200        }
201    }
202}
203
204/// Add authentication headers based on auth mode (free function for retry loop)
205async fn add_auth_headers(
206    mut request: reqwest::RequestBuilder,
207    auth: &AuthMode,
208    path: &str,
209    method: &Method,
210    body: &Option<serde_json::Value>,
211    chain_id: u64,
212) -> Result<reqwest::RequestBuilder, ClobError> {
213    match auth {
214        AuthMode::None => Ok(request),
215        AuthMode::L1 { wallet, nonce } => {
216            use crate::core::eip712::sign_clob_auth;
217
218            // Fresh timestamp on each attempt to avoid staleness after retry backoff
219            let timestamp = current_timestamp();
220            let signature = sign_clob_auth(wallet.signer(), chain_id, timestamp, *nonce).await?;
221
222            request = request
223                .header("POLY_ADDRESS", format!("{:?}", wallet.address()))
224                .header("POLY_SIGNATURE", signature)
225                .header("POLY_TIMESTAMP", timestamp.to_string())
226                .header("POLY_NONCE", nonce.to_string());
227
228            Ok(request)
229        }
230        AuthMode::L2 {
231            address,
232            credentials,
233            signer,
234        } => {
235            let timestamp = current_timestamp();
236            let body_str = body.as_ref().map(|b| b.to_string());
237            let message =
238                Signer::create_message(timestamp, method.as_str(), path, body_str.as_deref());
239            let signature = signer.sign(&message)?;
240
241            request = request
242                .header("POLY_ADDRESS", format!("{:?}", address))
243                .header("POLY_SIGNATURE", signature)
244                .header("POLY_TIMESTAMP", timestamp.to_string())
245                .header("POLY_API_KEY", &credentials.key)
246                .header("POLY_PASSPHRASE", &credentials.passphrase);
247
248            Ok(request)
249        }
250    }
251}