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