Skip to main content

mesa_dev/
client.rs

1//! Mesa API client with retry logic.
2//!
3//! This module contains the core client types: [`MesaClient`], [`ClientBuilder`],
4//! and [`ClientConfig`]. Most users will interact with the [`Mesa`] type alias
5//! (available with the `reqwest-client` feature).
6
7use std::sync::Arc;
8use std::time::Duration;
9
10use bytes::Bytes;
11use http::{HeaderMap, HeaderValue, Method, StatusCode};
12use serde::Serialize;
13use serde::de::DeserializeOwned;
14
15use crate::error::{ApiErrorCode, ApiErrorResponse, HttpClientError, MesaError};
16use crate::http_client::{HttpClient, HttpRequest, HttpResponse};
17
18/// Default base URL for the Mesa API.
19const DEFAULT_BASE_URL: &str = "https://depot.mesa.dev/api/v1";
20
21/// Default request timeout.
22const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
23
24/// Default maximum number of retry attempts.
25const DEFAULT_MAX_RETRIES: u32 = 3;
26
27/// Default initial backoff duration for retries.
28const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(500);
29
30/// Default maximum backoff duration.
31const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
32
33/// Configuration for a [`MesaClient`].
34///
35/// Typically constructed via [`ClientBuilder`] rather than directly. All fields
36/// have sensible defaults.
37#[derive(Debug, Clone)]
38pub struct ClientConfig {
39    /// Base URL for the Mesa API.
40    pub base_url: String,
41    /// API key for authentication.
42    pub api_key: String,
43    /// Request timeout (passed to the HTTP backend).
44    pub timeout: Duration,
45    /// Maximum number of retry attempts.
46    pub max_retries: u32,
47    /// Initial backoff duration for retries.
48    pub initial_backoff: Duration,
49    /// Maximum backoff duration.
50    pub max_backoff: Duration,
51    /// Default headers included in every request.
52    pub default_headers: HeaderMap,
53}
54
55/// Builder for constructing a [`MesaClient`] with custom configuration.
56///
57/// # Example
58///
59/// ```rust,no_run
60/// use std::time::Duration;
61/// use mesa_dev::ClientBuilder;
62///
63/// let client = ClientBuilder::new("my-api-key")
64///     .base_url("https://custom.mesa.dev/api/v1")
65///     .timeout(Duration::from_secs(60))
66///     .max_retries(5)
67///     .build();
68/// ```
69///
70/// To use a custom HTTP backend, call [`build_with`](Self::build_with) instead
71/// of [`build`](Self::build).
72#[derive(Debug)]
73pub struct ClientBuilder {
74    config: ClientConfig,
75}
76
77impl ClientBuilder {
78    /// Create a new builder with the given API key.
79    pub fn new(api_key: impl Into<String>) -> Self {
80        Self {
81            config: ClientConfig {
82                base_url: DEFAULT_BASE_URL.to_owned(),
83                api_key: api_key.into(),
84                timeout: DEFAULT_TIMEOUT,
85                max_retries: DEFAULT_MAX_RETRIES,
86                initial_backoff: DEFAULT_INITIAL_BACKOFF,
87                max_backoff: DEFAULT_MAX_BACKOFF,
88                default_headers: HeaderMap::new(),
89            },
90        }
91    }
92
93    /// Set the base URL.
94    #[must_use]
95    pub fn base_url(mut self, url: impl Into<String>) -> Self {
96        self.config.base_url = url.into();
97        self
98    }
99
100    /// Set the request timeout.
101    #[must_use]
102    pub fn timeout(mut self, timeout: Duration) -> Self {
103        self.config.timeout = timeout;
104        self
105    }
106
107    /// Set the maximum number of retries.
108    #[must_use]
109    pub fn max_retries(mut self, n: u32) -> Self {
110        self.config.max_retries = n;
111        self
112    }
113
114    /// Set the initial backoff duration.
115    #[must_use]
116    pub fn initial_backoff(mut self, d: Duration) -> Self {
117        self.config.initial_backoff = d;
118        self
119    }
120
121    /// Set the maximum backoff duration.
122    #[must_use]
123    pub fn max_backoff(mut self, d: Duration) -> Self {
124        self.config.max_backoff = d;
125        self
126    }
127
128    /// Add a default header to every request.
129    #[must_use]
130    pub fn default_header(mut self, name: http::HeaderName, value: HeaderValue) -> Self {
131        self.config.default_headers.insert(name, value);
132        self
133    }
134
135    /// Build a [`MesaClient`] with the default reqwest backend.
136    #[cfg(feature = "reqwest-client")]
137    #[must_use]
138    pub fn build(self) -> MesaClient<crate::backends::ReqwestClient> {
139        let http = crate::backends::ReqwestClient::new(self.config.timeout);
140        self.build_with(http)
141    }
142
143    /// Build a [`MesaClient`] with a custom HTTP client backend.
144    #[must_use]
145    pub fn build_with<C: HttpClient>(self, http_client: C) -> MesaClient<C> {
146        MesaClient {
147            inner: Arc::new(ClientInner {
148                config: self.config,
149                http: http_client,
150            }),
151        }
152    }
153}
154
155/// The Mesa API client, generic over an HTTP backend `C`.
156///
157/// This is the main entry point for the SDK. Use the resource accessor methods
158/// ([`repos`](Self::repos), [`branches`](Self::branches), etc.) to interact
159/// with different parts of the API.
160///
161/// Cloning is cheap — the inner state is shared via `Arc`.
162///
163/// # Type aliases
164///
165/// When using the default `reqwest-client` feature, [`Mesa`] is a type alias
166/// for `MesaClient<ReqwestClient>` with a convenient [`Mesa::new`] constructor.
167#[derive(Debug, Clone)]
168pub struct MesaClient<C: HttpClient> {
169    pub(crate) inner: Arc<ClientInner<C>>,
170}
171
172/// Internal shared state for the client.
173#[derive(Debug)]
174#[expect(unreachable_pub)]
175pub struct ClientInner<C: HttpClient> {
176    pub(crate) config: ClientConfig,
177    pub(crate) http: C,
178}
179
180impl<C: HttpClient> ClientInner<C> {
181    /// Send an API request, deserializing the response as JSON.
182    pub(crate) async fn request<T: DeserializeOwned>(
183        &self,
184        method: Method,
185        path: &str,
186        query: &[(&str, &str)],
187        body: Option<Bytes>,
188    ) -> Result<T, MesaError> {
189        let url = build_url(&self.config.base_url, path, query);
190        let response = self.send_with_retry(method, &url, body).await?;
191        serde_json::from_slice(&response.body).map_err(MesaError::from)
192    }
193
194    /// Send a request with retry logic (exponential backoff + jitter).
195    async fn send_with_retry(
196        &self,
197        method: Method,
198        url: &str,
199        body: Option<Bytes>,
200    ) -> Result<HttpResponse, MesaError> {
201        let max_attempts = self.config.max_retries + 1;
202        let mut last_error: Option<MesaError> = None;
203
204        for attempt in 0..max_attempts {
205            if attempt > 0 {
206                if let Some(ref err) = last_error
207                    && !err.is_retryable()
208                {
209                    break;
210                }
211
212                let backoff = compute_backoff(
213                    attempt,
214                    self.config.initial_backoff,
215                    self.config.max_backoff,
216                );
217                std::thread::sleep(backoff);
218            }
219
220            let request = self.build_request(method.clone(), url, body.clone());
221            match self.http.send(request).await {
222                Ok(response) if response.status.is_success() => return Ok(response),
223                Ok(response) => {
224                    let err = parse_api_error(response.status, &response.body);
225                    last_error = Some(err);
226                }
227                Err(http_err) => {
228                    last_error = Some(MesaError::HttpClient(http_err));
229                }
230            }
231        }
232
233        match last_error {
234            Some(err) if max_attempts > 1 && err.is_retryable() => {
235                Err(MesaError::RetriesExhausted {
236                    attempts: max_attempts,
237                    last_error: Box::new(err),
238                })
239            }
240            Some(err) => Err(err),
241            None => Err(MesaError::HttpClient(HttpClientError::Connection(
242                "no attempts made".to_owned(),
243            ))),
244        }
245    }
246
247    /// Build an [`HttpRequest`] with the configured headers.
248    fn build_request(&self, method: Method, url: &str, body: Option<Bytes>) -> HttpRequest {
249        let mut headers = self.config.default_headers.clone();
250
251        if let Ok(auth) = HeaderValue::from_str(&format!("Bearer {}", self.config.api_key)) {
252            headers.insert(http::header::AUTHORIZATION, auth);
253        }
254
255        if body.is_some() {
256            headers.insert(
257                http::header::CONTENT_TYPE,
258                HeaderValue::from_static("application/json"),
259            );
260        }
261
262        headers.insert(
263            http::header::ACCEPT,
264            HeaderValue::from_static("application/json"),
265        );
266
267        HttpRequest {
268            method,
269            url: url.to_owned(),
270            headers,
271            body,
272        }
273    }
274}
275
276/// Convenience type alias when using the default reqwest backend.
277#[cfg(feature = "reqwest-client")]
278pub type Mesa = MesaClient<crate::backends::ReqwestClient>;
279
280#[cfg(feature = "reqwest-client")]
281impl Mesa {
282    /// Create a new client with the default reqwest backend.
283    pub fn new(api_key: impl Into<String>) -> Self {
284        ClientBuilder::new(api_key).build()
285    }
286}
287
288impl<C: HttpClient> MesaClient<C> {
289    /// Create a new builder for configuring a client.
290    pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
291        ClientBuilder::new(api_key)
292    }
293
294    /// Send an API request with a JSON body, deserializing the response.
295    pub(crate) async fn request<T: DeserializeOwned>(
296        &self,
297        method: Method,
298        path: &str,
299        query: &[(&str, &str)],
300        body: Option<&(impl Serialize + Sync)>,
301    ) -> Result<T, MesaError> {
302        let json_body = match body {
303            Some(b) => Some(Bytes::from(serde_json::to_vec(b)?)),
304            None => None,
305        };
306        self.inner.request(method, path, query, json_body).await
307    }
308
309    // ── Resource namespace accessors ──
310
311    /// Access repository operations for the given organization.
312    #[must_use]
313    pub fn repos(&self, org: &str) -> crate::resources::ReposResource<'_, C> {
314        crate::resources::ReposResource::new(self, org.to_owned())
315    }
316
317    /// Access branch operations for the given repository.
318    #[must_use]
319    pub fn branches(&self, org: &str, repo: &str) -> crate::resources::BranchesResource<'_, C> {
320        crate::resources::BranchesResource::new(self, org.to_owned(), repo.to_owned())
321    }
322
323    /// Access commit operations for the given repository.
324    #[must_use]
325    pub fn commits(&self, org: &str, repo: &str) -> crate::resources::CommitsResource<'_, C> {
326        crate::resources::CommitsResource::new(self, org.to_owned(), repo.to_owned())
327    }
328
329    /// Access content operations for the given repository.
330    #[must_use]
331    pub fn content(&self, org: &str, repo: &str) -> crate::resources::ContentResource<'_, C> {
332        crate::resources::ContentResource::new(self, org.to_owned(), repo.to_owned())
333    }
334
335    /// Access diff operations for the given repository.
336    #[must_use]
337    pub fn diffs(&self, org: &str, repo: &str) -> crate::resources::DiffsResource<'_, C> {
338        crate::resources::DiffsResource::new(self, org.to_owned(), repo.to_owned())
339    }
340
341    /// Access admin operations (API keys) for the given organization.
342    #[must_use]
343    pub fn admin(&self, org: &str) -> crate::resources::AdminResource<'_, C> {
344        crate::resources::AdminResource::new(self, org.to_owned())
345    }
346}
347
348/// Build a full URL from base, path, and query parameters.
349fn build_url(base: &str, path: &str, query: &[(&str, &str)]) -> String {
350    let mut url = format!("{base}{path}");
351    if !query.is_empty() {
352        url.push('?');
353        for (i, (key, value)) in query.iter().enumerate() {
354            if i > 0 {
355                url.push('&');
356            }
357            url.push_str(key);
358            url.push('=');
359            url.push_str(&url_encode(value));
360        }
361    }
362    url
363}
364
365/// Minimal percent-encoding for query parameter values.
366fn url_encode(s: &str) -> String {
367    let mut out = String::with_capacity(s.len());
368    for byte in s.bytes() {
369        match byte {
370            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
371                out.push(byte as char);
372            }
373            _ => {
374                out.push('%');
375                // Format each byte as two uppercase hex digits.
376                let high = byte >> 4;
377                let low = byte & 0x0F;
378                out.push(hex_digit(high));
379                out.push(hex_digit(low));
380            }
381        }
382    }
383    out
384}
385
386/// Convert a nibble (0–15) to a hex character.
387const fn hex_digit(nibble: u8) -> char {
388    match nibble {
389        0..=9 => (b'0' + nibble) as char,
390        _ => (b'A' + nibble - 10) as char,
391    }
392}
393
394/// Compute exponential backoff with jitter for the given attempt number.
395#[expect(clippy::cast_possible_truncation)] // millis fits in u64 for any reasonable backoff
396fn compute_backoff(attempt: u32, initial: Duration, max: Duration) -> Duration {
397    let base = initial.saturating_mul(1 << attempt.min(16));
398    let capped = base.min(max);
399    // Simple jitter: use between 50% and 100% of capped value.
400    let millis = capped.as_millis() as u64;
401    let jitter_millis = millis / 2 + simple_random_u64() % (millis / 2 + 1);
402    Duration::from_millis(jitter_millis)
403}
404
405/// Produce a simple pseudo-random u64 using the current time.
406/// Not cryptographically secure — only used for retry jitter.
407#[expect(clippy::cast_possible_truncation)] // intentional wrapping for jitter
408fn simple_random_u64() -> u64 {
409    use std::time::SystemTime;
410    SystemTime::now()
411        .duration_since(SystemTime::UNIX_EPOCH)
412        .map_or(0, |d| d.as_nanos() as u64)
413}
414
415/// Parse an API error response body into a [`MesaError`].
416#[expect(unreachable_pub)]
417pub fn parse_api_error(status: StatusCode, body: &[u8]) -> MesaError {
418    match serde_json::from_slice::<ApiErrorResponse>(body) {
419        Ok(resp) => MesaError::Api {
420            status,
421            code: ApiErrorCode::from_code(&resp.error.code),
422            message: resp.error.message,
423            details: resp.error.details,
424        },
425        Err(_) => MesaError::Api {
426            status,
427            code: ApiErrorCode::Unknown(status.to_string()),
428            message: String::from_utf8_lossy(body).into_owned(),
429            details: serde_json::Value::Null,
430        },
431    }
432}