1pub mod catalog;
15pub mod content_block;
16pub mod error;
17pub mod rate_limit;
18
19use crate::braze::error::BrazeApiError;
20use crate::braze::rate_limit::RateLimiter;
21use reqwest::{Client, RequestBuilder, StatusCode};
22use secrecy::{ExposeSecret, SecretString};
23use std::sync::Arc;
24use std::time::Duration;
25use url::Url;
26
27const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
28const MAX_RETRIES: u32 = 3;
29const DEFAULT_RETRY_AFTER: Duration = Duration::from_secs(2);
30
31#[derive(Clone)]
35pub struct BrazeClient {
36 http: Client,
37 base_url: Url,
38 api_key: Arc<SecretString>,
39 limiter: Arc<RateLimiter>,
40}
41
42impl std::fmt::Debug for BrazeClient {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct("BrazeClient")
47 .field("base_url", &self.base_url)
48 .field("api_key", &"<redacted>")
49 .finish()
50 }
51}
52
53impl BrazeClient {
54 pub fn from_resolved(resolved: &crate::config::ResolvedConfig) -> Self {
55 Self::new(
56 resolved.api_endpoint.clone(),
57 resolved.api_key.clone(),
58 resolved.rate_limit_per_minute,
59 )
60 }
61
62 pub fn new(base_url: Url, api_key: SecretString, rpm: u32) -> Self {
63 let http = Client::builder()
64 .user_agent(concat!("braze-sync/", env!("CARGO_PKG_VERSION")))
65 .timeout(REQUEST_TIMEOUT)
66 .build()
67 .expect("reqwest client builds with default features");
68 Self {
69 http,
70 base_url,
71 api_key: Arc::new(api_key),
72 limiter: Arc::new(RateLimiter::new(rpm)),
73 }
74 }
75
76 pub(crate) fn url_for(&self, segments: &[&str]) -> Url {
85 let mut url = self.base_url.clone();
86 {
87 let mut seg = url
88 .path_segments_mut()
89 .expect("base url must be hierarchical (http/https)");
90 seg.clear();
91 for s in segments {
92 seg.push(s);
93 }
94 }
95 url
96 }
97
98 pub(crate) fn get(&self, segments: &[&str]) -> RequestBuilder {
100 let url = self.url_for(segments);
101 self.http
102 .get(url)
103 .bearer_auth(self.api_key.expose_secret())
104 .header(reqwest::header::ACCEPT, "application/json")
105 }
106
107 pub(crate) fn post(&self, segments: &[&str]) -> RequestBuilder {
108 let url = self.url_for(segments);
109 self.http
110 .post(url)
111 .bearer_auth(self.api_key.expose_secret())
112 .header(reqwest::header::ACCEPT, "application/json")
113 }
114
115 pub(crate) fn delete(&self, segments: &[&str]) -> RequestBuilder {
116 let url = self.url_for(segments);
117 self.http
118 .delete(url)
119 .bearer_auth(self.api_key.expose_secret())
120 .header(reqwest::header::ACCEPT, "application/json")
121 }
122
123 async fn send_with_retry(
129 &self,
130 builder: RequestBuilder,
131 ) -> Result<reqwest::Response, BrazeApiError> {
132 let mut attempt: u32 = 0;
133 loop {
134 self.limiter.acquire().await;
135 let req = builder
136 .try_clone()
137 .expect("non-streaming requests are cloneable");
138 let resp = req.send().await?;
139 let status = resp.status();
140
141 if status.is_success() {
142 return Ok(resp);
143 }
144 match status {
145 StatusCode::TOO_MANY_REQUESTS if attempt < MAX_RETRIES => {
146 let wait = parse_retry_after(&resp).unwrap_or(DEFAULT_RETRY_AFTER);
147 tracing::warn!(?wait, attempt, "429 received, backing off");
148 tokio::time::sleep(wait).await;
149 attempt += 1;
150 }
151 StatusCode::TOO_MANY_REQUESTS => {
152 return Err(BrazeApiError::RateLimitExhausted);
153 }
154 StatusCode::UNAUTHORIZED => return Err(BrazeApiError::Unauthorized),
155 _ => {
156 let body = resp.text().await.unwrap_or_default();
157 return Err(BrazeApiError::Http { status, body });
158 }
159 }
160 }
161 }
162
163 pub(crate) async fn send_json<T: serde::de::DeserializeOwned>(
165 &self,
166 builder: RequestBuilder,
167 ) -> Result<T, BrazeApiError> {
168 let resp = self.send_with_retry(builder).await?;
169 Ok(resp.json::<T>().await?)
170 }
171
172 pub(crate) async fn send_ok(&self, builder: RequestBuilder) -> Result<(), BrazeApiError> {
177 let resp = self.send_with_retry(builder).await?;
178 let _ = resp.bytes().await;
179 Ok(())
180 }
181}
182
183fn parse_retry_after(resp: &reqwest::Response) -> Option<Duration> {
189 resp.headers()
190 .get(reqwest::header::RETRY_AFTER)?
191 .to_str()
192 .ok()?
193 .parse::<u64>()
194 .ok()
195 .map(Duration::from_secs)
196}
197
198#[cfg(test)]
199pub(crate) fn test_client(server: &wiremock::MockServer) -> BrazeClient {
200 BrazeClient::new(
201 Url::parse(&server.uri()).unwrap(),
202 SecretString::from("test-key".to_string()),
203 10_000,
205 )
206}