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 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/// Cheap-to-clone Braze API client. Internally Arc-shares the API key,
31/// the rate limiter, and `reqwest::Client`'s connection pool, so cloning
32/// for a parallel batch is essentially free.
33#[derive(Clone)]
34pub struct BrazeClient {
35    http: Client,
36    base_url: Url,
37    api_key: Arc<SecretString>,
38    limiter: Arc<RateLimiter>,
39}
40
41// Hand-written Debug to be 100% certain the api key never lands in
42// tracing output, even if SecretString's own Debug impl ever changes.
43impl 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    /// Build a URL by appending each `segment` to the base URL as a
76    /// separately percent-encoded path segment.
77    ///
78    /// User-controlled segments cannot inject path traversal or query
79    /// strings because the URL crate encodes `/`, `?`, `#`, and so on
80    /// inside each segment. Any path that the base URL itself carried is
81    /// dropped, so the layout is predictable regardless of how the user
82    /// wrote `api_endpoint` in their config.
83    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    /// Pre-authenticated GET builder for the given path segments.
98    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    /// Execute `builder` with rate-limit acquire + 429 retry, returning
123    /// the raw response on success or a typed error on failure. Shared
124    /// transport layer used by both [`Self::send_json`] and
125    /// [`Self::send_ok`] so the retry / auth-mapping policy lives in
126    /// exactly one place.
127    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    /// Send `builder` and decode the JSON body as `T` on success.
163    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    /// Send `builder` and discard the response body. Used for endpoints
172    /// whose only meaningful output is the HTTP status (POST add field,
173    /// DELETE field). Drains the body so the connection can return to
174    /// the reqwest pool cleanly even when the response is 204 No Content.
175    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
182/// Parse a `Retry-After` header as integer seconds. HTTP-date format
183/// (RFC 7231 §7.1.3) is not handled and falls through to `None`, which
184/// the caller maps to `DEFAULT_RETRY_AFTER`. Braze only sends seconds
185/// in practice; if that changes, extend this function rather than adding
186/// a full HTTP-date parser.
187fn 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}