Skip to main content

hooksmith_core/
client.rs

1use crate::retry::RetryPolicy;
2use reqwest::Client;
3use serde::Serialize;
4use std::time::Duration;
5
6/// A thin wrapper around [`reqwest::Client`] shared by all hooksmith service crates.
7///
8/// Service crates (e.g. `discord_hook`, `slack_hook`) hold one of these,
9/// configure it at construction time, and call [`HttpClient::post_json`] to
10/// fire requests.
11///
12/// **TLS configuration** is the responsibility of the service crate — build a
13/// [`reqwest::Client`] with your chosen TLS backend and pass it in via
14/// [`HttpClient::with_reqwest`].
15pub struct HttpClient {
16    inner: Client,
17}
18
19impl HttpClient {
20    /// Create a client backed by a freshly-constructed [`reqwest::Client`].
21    ///
22    /// A 30-second request timeout is applied by default so that a slow or
23    /// unresponsive endpoint can never hang your application indefinitely.
24    /// Override this by building your own [`reqwest::Client`] and passing it
25    /// to [`HttpClient::with_reqwest`].
26    pub fn new() -> Self {
27        let inner = Client::builder()
28            .timeout(Duration::from_secs(30))
29            .build()
30            .expect("failed to initialise reqwest client — TLS backend unavailable");
31        Self { inner }
32    }
33
34    /// Wrap an existing [`reqwest::Client`].
35    ///
36    /// Use this to share a connection pool or inject custom configuration
37    /// (timeouts, proxies, etc.) across your application.
38    pub fn with_reqwest(client: Client) -> Self {
39        Self { inner: client }
40    }
41
42    /// POST `body` serialized as JSON to `url` and return the raw response.
43    ///
44    /// When the **`tracing`** feature is enabled this method emits an
45    /// `info_span` named `hooksmith.post_json` capturing the request URL,
46    /// HTTP status, and wall-clock latency.
47    pub async fn post_json(
48        &self,
49        url: &str,
50        body: &impl Serialize,
51    ) -> Result<reqwest::Response, reqwest::Error> {
52        #[cfg(not(feature = "tracing"))]
53        {
54            return self.inner.post(url).json(body).send().await;
55        }
56
57        #[cfg(feature = "tracing")]
58        {
59            use tracing::Instrument;
60            let span = tracing::info_span!("hooksmith.post_json", url = %url);
61            let start = std::time::Instant::now();
62            let result = self
63                .inner
64                .post(url)
65                .json(body)
66                .send()
67                .instrument(span.clone())
68                .await;
69            let latency_ms = start.elapsed().as_millis();
70            let _enter = span.enter();
71            match &result {
72                Ok(resp) => tracing::info!(status = resp.status().as_u16(), latency_ms),
73                Err(err) => tracing::error!(error = %err, latency_ms),
74            }
75            result
76        }
77    }
78
79    /// POST `body` serialized as JSON to `url`, retrying on failure according
80    /// to the supplied [`RetryPolicy`].
81    ///
82    /// Each retry is separated by an exponentially increasing delay
83    /// (`base_delay × 2ⁿ`).  When `policy.jitter` is `true` a random
84    /// fraction of the current step is added to the delay.
85    ///
86    /// Returns the first successful [`reqwest::Response`], or the error from
87    /// the final attempt if all attempts are exhausted.
88    ///
89    /// # Example
90    ///
91    /// ```rust,ignore
92    /// use hooksmith_core::RetryPolicy;
93    ///
94    /// let policy = RetryPolicy { max_attempts: 4, ..Default::default() };
95    /// let resp = client.post_json_with_retry(url, &payload, &policy).await?;
96    /// ```
97    pub async fn post_json_with_retry(
98        &self,
99        url: &str,
100        body: &impl Serialize,
101        policy: &RetryPolicy,
102    ) -> Result<reqwest::Response, reqwest::Error> {
103        let max = policy.max_attempts.max(1);
104        let mut last_err: Option<reqwest::Error> = None;
105
106        for attempt in 0..max {
107            match self.post_json(url, body).await {
108                Ok(resp) => return Ok(resp),
109                Err(err) => {
110                    let is_last = attempt + 1 >= max;
111                    if !is_last {
112                        let factor = 1u32 << attempt; // 2^attempt
113                        let base = policy.base_delay * factor;
114                        let delay = if policy.jitter {
115                            let jitter: f64 = rand::random();
116                            base + Duration::from_secs_f64(base.as_secs_f64() * jitter)
117                        } else {
118                            base
119                        };
120                        tokio::time::sleep(delay).await;
121                    }
122                    last_err = Some(err);
123                }
124            }
125        }
126
127        Err(last_err.expect("max_attempts is at least 1"))
128    }
129
130    /// Access the underlying [`reqwest::Client`] for advanced use-cases.
131    pub fn inner(&self) -> &Client {
132        &self.inner
133    }
134}
135
136impl Default for HttpClient {
137    fn default() -> Self {
138        Self::new()
139    }
140}