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 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/// Cheap-to-clone Braze API client. Internally Arc-shares the API key,
33/// the rate limiter, and `reqwest::Client`'s connection pool, so cloning
34/// for a parallel batch is essentially free.
35#[derive(Clone)]
36pub struct BrazeClient {
37    http: Client,
38    base_url: Url,
39    api_key: Arc<SecretString>,
40    limiter: Arc<RateLimiter>,
41}
42
43// Hand-written Debug to be 100% certain the api key never lands in
44// tracing output, even if SecretString's own Debug impl ever changes.
45impl 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    /// Build a URL by appending each `segment` to the base URL as a
78    /// separately percent-encoded path segment.
79    ///
80    /// User-controlled segments cannot inject path traversal or query
81    /// strings because the URL crate encodes `/`, `?`, `#`, and so on
82    /// inside each segment. Any path that the base URL itself carried is
83    /// dropped, so the layout is predictable regardless of how the user
84    /// wrote `api_endpoint` in their config.
85    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    /// Pre-authenticated GET builder for the given path segments.
100    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    /// Execute `builder` with rate-limit acquire + 429 retry, returning
125    /// the raw response on success or a typed error on failure. Shared
126    /// transport layer used by both [`Self::send_json`] and
127    /// [`Self::send_ok`] so the retry / auth-mapping policy lives in
128    /// exactly one place.
129    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    /// Send `builder` and decode the JSON body as `T` on success.
165    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    /// Send `builder` and discard the response body. Used for endpoints
174    /// whose only meaningful output is the HTTP status (POST add field,
175    /// DELETE field). Drains the body so the connection can return to
176    /// the reqwest pool cleanly even when the response is 204 No Content.
177    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
184/// Parse a `Retry-After` header as integer seconds. HTTP-date format
185/// (RFC 7231 §7.1.3) is not handled and falls through to `None`, which
186/// the caller maps to `DEFAULT_RETRY_AFTER`. Braze only sends seconds
187/// in practice; if that changes, extend this function rather than adding
188/// a full HTTP-date parser.
189fn 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
199/// Outcome of classifying the `message` field on a Braze `/info`
200/// response. Shared by content_block and email_template — the only
201/// difference is the resource-specific "not found" phrase.
202pub(crate) enum InfoMessageClass {
203    Success,
204    NotFound,
205    Unexpected(String),
206}
207
208/// Classify the `message` field returned by Braze `/info` endpoints.
209/// `resource_phrase` is a resource-specific not-found indicator
210/// (e.g. `"no content block"`, `"no email template"`).
211pub(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        // Very high rpm so the limiter is effectively a no-op in tests.
243        10_000,
244    )
245}