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