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}