Skip to main content

braze_sync/braze/
mod.rs

1//! Braze REST API client.
2//!
3//! Layered:
4//! - [`rate_limit`]: token-bucket throttle (governor)
5//! - [`error`]: typed [`error::BrazeApiError`] variants
6//! - [`catalog`] (and sibling modules per resource):
7//!   per-endpoint async methods written as `impl BrazeClient { ... }`
8//!   blocks
9//!
10//! Every request goes through [`BrazeClient::send_json`] so authentication,
11//! `User-Agent`, rate limiting, and 429 retry behavior are defined exactly
12//! once. See IMPLEMENTATION.md §8.
13
14pub 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/// Cheap-to-clone Braze API client. Internally Arc-shares the API key,
32/// the rate limiter, and `reqwest::Client`'s connection pool, so cloning
33/// for a parallel batch is essentially free.
34#[derive(Clone)]
35pub struct BrazeClient {
36    http: Client,
37    base_url: Url,
38    api_key: Arc<SecretString>,
39    limiter: Arc<RateLimiter>,
40}
41
42// Hand-written Debug to be 100% certain the api key never lands in
43// tracing output, even if SecretString's own Debug impl ever changes.
44impl 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    /// Build a URL by appending each `segment` to the base URL as a
77    /// separately percent-encoded path segment.
78    ///
79    /// User-controlled segments cannot inject path traversal or query
80    /// strings because the URL crate encodes `/`, `?`, `#`, and so on
81    /// inside each segment. Any path that the base URL itself carried is
82    /// dropped, so the layout is predictable regardless of how the user
83    /// wrote `api_endpoint` in their config.
84    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    /// Pre-authenticated GET builder for the given path segments.
99    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    /// Execute `builder` with rate-limit acquire + 429 retry, returning
124    /// the raw response on success or a typed error on failure. Shared
125    /// transport layer used by both [`Self::send_json`] and
126    /// [`Self::send_ok`] so the retry / auth-mapping policy lives in
127    /// exactly one place.
128    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    /// Send `builder` and decode the JSON body as `T` on success.
164    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    /// Send `builder` and discard the response body. Used for endpoints
173    /// whose only meaningful output is the HTTP status (POST add field,
174    /// DELETE field). Drains the body so the connection can return to
175    /// the reqwest pool cleanly even when the response is 204 No Content.
176    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
183/// Parse a `Retry-After` header as integer seconds. HTTP-date format
184/// (RFC 7231 §7.1.3) is not handled and falls through to `None`, which
185/// the caller maps to `DEFAULT_RETRY_AFTER`. Braze only sends seconds
186/// in practice; if that changes, extend this function rather than adding
187/// a full HTTP-date parser.
188fn 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        // Very high rpm so the limiter is effectively a no-op in tests.
204        10_000,
205    )
206}