rovenue/transport/
http_client.rs

1use std::time::Duration;
2
3use reqwest::blocking::Client;
4use serde::de::DeserializeOwned;
5use serde::Serialize;
6
7use crate::error::{ErrorKind, RovenueError, RovenueResult};
8
9use super::retry::{backoff, classify, RetryDecision};
10use super::types::HttpResponse;
11
12pub use super::types::HttpPostRequest;
13pub use super::types::HttpRequest;
14
15/// The backend `error.code` for a device whose merge chain is corrupt
16/// (`packages/shared/src/error-catalog.ts`). It gets its own [`ErrorKind`]
17/// because no status alone says it: it rides a 409 like other refusals.
18pub const SUBSCRIBER_UNRESOLVABLE_CODE: &str = "SUBSCRIBER_UNRESOLVABLE";
19
20/// Backend `error.code`s that name a store refusal, mapped to the store
21/// [`ErrorKind`] the façades switch on. A status alone cannot say these: a
22/// bad receipt and a malformed request are both 400, a product the receipt
23/// names and an unknown route are both 404, and a store outage is a 503
24/// like any server fault. Consulted after [`SUBSCRIBER_UNRESOLVABLE_CODE`]
25/// and before the status table; an unlisted code falls back to the status.
26/// `RECEIPT_IN_USE` (409) is deliberately absent: its status kind,
27/// `Conflict`, is already the right one and the code is the detail.
28pub const STORE_CODE_KINDS: &[(&str, ErrorKind)] = &[
29    ("RECEIPT_INVALID", ErrorKind::ReceiptInvalid),
30    ("RECEIPT_PRODUCT_UNKNOWN", ErrorKind::ProductNotAvailable),
31    ("STORE_NOT_CONFIGURED", ErrorKind::StoreProblem),
32    ("STORE_UNAVAILABLE", ErrorKind::StoreServiceUnavailable),
33];
34
35/// Map an HTTP error status + response body to a [`RovenueError`], preserving
36/// the backend `{"error":{"code","message"}}` envelope when present.
37pub fn error_from_status(status: u16, body: &str) -> RovenueError {
38    use super::api::ApiErrorBody;
39    let status_kind = match status {
40        401 => ErrorKind::InvalidApiKey,
41        402 => ErrorKind::InsufficientCredits,
42        403 => ErrorKind::Forbidden,
43        404 => ErrorKind::NotFound,
44        409 => ErrorKind::Conflict,
45        422 | 400 => ErrorKind::InvalidRequest,
46        429 => ErrorKind::RateLimited,
47        405..=499 => ErrorKind::InvalidRequest, // other 4xx are client-side
48        500..=599 => ErrorKind::ServerError,
49        _ => ErrorKind::Internal,
50    };
51    match serde_json::from_str::<ApiErrorBody>(body) {
52        Ok(parsed) => {
53            let code = parsed.error.code.as_str();
54            let kind = if code == SUBSCRIBER_UNRESOLVABLE_CODE {
55                ErrorKind::SubscriberUnresolvable
56            } else {
57                STORE_CODE_KINDS
58                    .iter()
59                    .find(|(store_code, _)| *store_code == code)
60                    .map_or(status_kind, |(_, kind)| *kind)
61            };
62            RovenueError::http(kind, status, Some(parsed.error.code), parsed.error.message)
63        }
64        Err(_) => RovenueError::http(status_kind, status, None, String::new()),
65    }
66}
67
68/// The error a retried 5xx leaves behind once attempts run out. It goes
69/// through [`error_from_status`] like a fatal answer does, so a typed code
70/// (`STORE_UNAVAILABLE` → `StoreServiceUnavailable`), the status and the
71/// server code survive the retries; an envelope-less 5xx stays `ServerError`
72/// with its status. Only the surfaced error is decided here — whether to
73/// retry stays `retry::classify`'s call, by status.
74fn retryable_server_error(resp: reqwest::blocking::Response) -> RovenueError {
75    let status = resp.status().as_u16();
76    let body = resp.text().unwrap_or_default();
77    error_from_status(status, &body)
78}
79
80pub struct HttpClient {
81    base_url: String,
82    api_key: String,
83    inner: Client,
84    max_attempts: u32,
85    min_backoff: Duration,
86    request_timeout: Duration,
87    /// Runtime platform (ios/android/web), sent as `X-Rovenue-Platform` on
88    /// every request when set. `None` omits the header.
89    platform: Option<String>,
90    /// Remote Config environment (prod/staging/development), sent as
91    /// `X-Rovenue-Env` on every request when set. `None` omits the header so
92    /// the backend falls back to `prod`.
93    environment: Option<String>,
94    /// Optional structured logger; when set, emits Debug + Error records per
95    /// logical request. Authorization / api key are never included in records.
96    logger: Option<std::sync::Arc<crate::logging::Logger>>,
97    /// Monotonic counter for correlation ids (req-0, req-1, …). Counts per
98    /// HttpClient instance; does NOT use wall-clock or random.
99    corr_counter: std::sync::atomic::AtomicU64,
100}
101
102impl HttpClient {
103    pub fn new(base_url: String, api_key: String) -> Self {
104        Self {
105            base_url,
106            api_key,
107            inner: Client::builder()
108                .timeout(Duration::from_secs(10))
109                .build()
110                .expect("reqwest blocking client"),
111            max_attempts: 3,
112            min_backoff: Duration::from_millis(50),
113            request_timeout: Duration::from_secs(10),
114            platform: None,
115            environment: None,
116            logger: None,
117            corr_counter: std::sync::atomic::AtomicU64::new(0),
118        }
119    }
120
121    /// Builder-style setter for the runtime platform header. A blank/whitespace
122    /// value is treated as absent so the header is never sent empty.
123    pub fn with_platform(mut self, platform: Option<String>) -> Self {
124        self.platform = platform
125            .map(|p| p.trim().to_string())
126            .filter(|p| !p.is_empty());
127        self
128    }
129
130    /// Builder-style setter for the Remote Config environment header. A
131    /// blank/whitespace value is treated as absent so the header is never sent
132    /// empty (the backend then defaults to `prod`).
133    pub fn with_environment(mut self, environment: Option<String>) -> Self {
134        self.environment = environment
135            .map(|e| e.trim().to_string())
136            .filter(|e| !e.is_empty());
137        self
138    }
139
140    pub fn with_max_attempts(mut self, n: u32) -> Self {
141        self.max_attempts = n.max(1);
142        self
143    }
144
145    pub fn with_min_backoff(mut self, d: Duration) -> Self {
146        self.min_backoff = d;
147        self
148    }
149
150    pub fn with_request_timeout(mut self, d: Duration) -> Self {
151        self.request_timeout = d;
152        self.inner = Client::builder()
153            .timeout(d)
154            .build()
155            .expect("reqwest blocking client");
156        self
157    }
158
159    /// Attach a structured logger. When set, each logical request emits a
160    /// `Debug` record (path, method, status, attempt, correlation_id) and an
161    /// `Error` record on terminal failure.  `Authorization` and the api key
162    /// are never included in any emitted record.
163    pub fn with_logger(mut self, logger: std::sync::Arc<crate::logging::Logger>) -> Self {
164        self.logger = Some(logger);
165        self
166    }
167
168    /// Monotonically increasing correlation id for each logical request.
169    /// Format: `req-{n}` where n starts at 0 and increments per call.
170    fn next_correlation_id(&self) -> String {
171        let n = self
172            .corr_counter
173            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
174        format!("req-{n}")
175    }
176
177    pub fn get_json<T: DeserializeOwned>(
178        &self,
179        req: HttpRequest<'_>,
180    ) -> RovenueResult<HttpResponse<T>> {
181        let url = format!("{}{}", self.base_url, req.path);
182        let mut rng = rand::thread_rng();
183        let mut last_err = RovenueError::NetworkUnavailable();
184        let corr = self.next_correlation_id();
185
186        for attempt in 0..self.max_attempts {
187            let mut builder = self
188                .inner
189                .get(&url)
190                .header("Authorization", format!("Bearer {}", self.api_key));
191            if let Some(scope) = req.user_scope {
192                builder = builder.header("X-Rovenue-App-User-Id", scope);
193            }
194            if let Some(subscriber) = req.subscriber_id {
195                builder = builder.header("X-Rovenue-User-Id", subscriber);
196            }
197            if let Some(platform) = &self.platform {
198                builder = builder.header("X-Rovenue-Platform", platform);
199            }
200            if let Some(environment) = &self.environment {
201                builder = builder.header("X-Rovenue-Env", environment);
202            }
203            if let Some(etag) = req.etag {
204                builder = builder.header("If-None-Match", etag);
205            }
206
207            match builder.send() {
208                Ok(resp) => {
209                    let status = resp.status().as_u16();
210                    if let Some(l) = &self.logger {
211                        // Logged verbatim. A `redact_path` helper used to
212                        // sit here, scrubbing the opaque token that rode in
213                        // `GET /v1/preview/paywalls/{token}`'s own path —
214                        // the one `/v1` path segment that WAS a credential.
215                        // That endpoint went with the paywall builder in
216                        // sub-project 5, and no current path carries one, so
217                        // the key-based `redact_fields` below covers what is
218                        // left. An endpoint that puts a secret in its path
219                        // must bring the scrubbing back with it.
220                        let path = req.path;
221                        let corr_c = corr.clone();
222                        l.log(
223                            crate::logging::LogLevel::Debug,
224                            || format!("http GET {path}"),
225                            || {
226                                let mut f = std::collections::HashMap::new();
227                                f.insert("method".to_string(), "GET".to_string());
228                                f.insert("path".to_string(), path.to_string());
229                                f.insert("status".to_string(), status.to_string());
230                                f.insert("attempt".to_string(), attempt.to_string());
231                                f.insert("correlation_id".to_string(), corr_c);
232                                crate::logging::redact::redact_fields(f)
233                            },
234                        );
235                    }
236                    let retry_after = resp
237                        .headers()
238                        .get("Retry-After")
239                        .and_then(|v| v.to_str().ok())
240                        .and_then(|s| s.parse::<u64>().ok())
241                        .map(Duration::from_secs);
242                    let etag_out = resp
243                        .headers()
244                        .get("ETag")
245                        .and_then(|v| v.to_str().ok())
246                        .map(str::to_owned);
247
248                    match classify(Some(status), retry_after) {
249                        RetryDecision::Success => {
250                            let body = if status == 304 || status == 204 {
251                                None
252                            } else {
253                                Some(resp.json::<T>().map_err(|_| RovenueError::Internal())?)
254                            };
255                            return Ok(HttpResponse {
256                                status,
257                                etag: etag_out,
258                                body,
259                            });
260                        }
261                        RetryDecision::Retryable => {
262                            last_err = if (500..600).contains(&status) {
263                                retryable_server_error(resp)
264                            } else {
265                                RovenueError::NetworkUnavailable()
266                            };
267                            if attempt + 1 < self.max_attempts {
268                                let d = backoff(attempt, &mut rng).max(self.min_backoff);
269                                std::thread::sleep(d);
270                            }
271                        }
272                        RetryDecision::RetryAfter(d) => {
273                            use super::retry::RETRY_AFTER_MAX;
274                            if d > RETRY_AFTER_MAX {
275                                return Err(RovenueError::RateLimited());
276                            }
277                            last_err = RovenueError::RateLimited();
278                            if attempt + 1 < self.max_attempts {
279                                std::thread::sleep(d.max(self.min_backoff));
280                            }
281                        }
282                        RetryDecision::Fatal => {
283                            let body_text = resp.text().unwrap_or_default();
284                            let err = error_from_status(status, &body_text);
285                            if let Some(l) = &self.logger {
286                                let path = req.path;
287                                let corr_c = corr.clone();
288                                let kind = format!("{:?}", err.kind);
289                                l.log(
290                                    crate::logging::LogLevel::Error,
291                                    || format!("http GET {path} failed"),
292                                    || {
293                                        let mut f = std::collections::HashMap::new();
294                                        f.insert("method".to_string(), "GET".to_string());
295                                        f.insert("path".to_string(), path.to_string());
296                                        f.insert("status".to_string(), status.to_string());
297                                        f.insert("attempt".to_string(), attempt.to_string());
298                                        f.insert("correlation_id".to_string(), corr_c);
299                                        f.insert("kind".to_string(), kind);
300                                        crate::logging::redact::redact_fields(f)
301                                    },
302                                );
303                            }
304                            return Err(err);
305                        }
306                    }
307                }
308                Err(e) if e.is_timeout() => {
309                    last_err = RovenueError::Timeout();
310                    if attempt + 1 < self.max_attempts {
311                        let d = backoff(attempt, &mut rng).max(self.min_backoff);
312                        std::thread::sleep(d);
313                    }
314                }
315                Err(_) => {
316                    last_err = RovenueError::NetworkUnavailable();
317                    if attempt + 1 < self.max_attempts {
318                        let d = backoff(attempt, &mut rng).max(self.min_backoff);
319                        std::thread::sleep(d);
320                    }
321                }
322            }
323        }
324
325        // Terminal network/timeout failure after all attempts exhausted.
326        if let Some(l) = &self.logger {
327            let path = req.path;
328            let corr_c = corr.clone();
329            let kind = format!("{:?}", last_err.kind);
330            l.log(
331                crate::logging::LogLevel::Error,
332                || format!("http GET {path} failed after all attempts"),
333                || {
334                    let mut f = std::collections::HashMap::new();
335                    f.insert("method".to_string(), "GET".to_string());
336                    f.insert("path".to_string(), path.to_string());
337                    f.insert("attempt".to_string(), (self.max_attempts - 1).to_string());
338                    f.insert("correlation_id".to_string(), corr_c);
339                    f.insert("kind".to_string(), kind);
340                    crate::logging::redact::redact_fields(f)
341                },
342            );
343        }
344        Err(last_err)
345    }
346
347    /// POST a batch of session telemetry events to `/v1/sdk/sessions`.
348    /// Best-effort: returns Ok(()) for any 2xx, and discards the response body.
349    pub fn post_sessions(
350        &self,
351        subscriber_id: &str,
352        events: &[serde_json::Value],
353    ) -> RovenueResult<()> {
354        let body = serde_json::json!({
355            "subscriberId": subscriber_id,
356            "events": events,
357        });
358        let _resp = self.post_json::<serde_json::Value, serde_json::Value>(
359            // `/v1/sdk/sessions` answers 202 with an EMPTY body; parsing it
360            // as JSON failed every flush after the server had ingested it.
361            super::types::HttpPostRequest::new("/v1/sdk/sessions")
362                .user_scope(subscriber_id)
363                .expect_empty_body(),
364            &body,
365        )?;
366        Ok(())
367    }
368
369    /// POST a batch of attribute mutations to /v1/me/attributes. The
370    /// subscriber is resolved server-side from the user-scope header.
371    /// `attributes` maps key -> Some(value) to set, or None to delete.
372    pub fn post_attributes(
373        &self,
374        subscriber_id: &str,
375        attributes: &serde_json::Map<String, serde_json::Value>,
376    ) -> RovenueResult<()> {
377        let body = serde_json::json!({ "attributes": attributes });
378        let _resp = self.post_json::<serde_json::Value, serde_json::Value>(
379            super::types::HttpPostRequest::new("/v1/me/attributes").user_scope(subscriber_id),
380            &body,
381        )?;
382        Ok(())
383    }
384
385    pub fn post_json<B: Serialize, T: DeserializeOwned>(
386        &self,
387        req: super::types::HttpPostRequest<'_>,
388        body: &B,
389    ) -> RovenueResult<HttpResponse<T>> {
390        use super::retry::{backoff, classify, RetryDecision, RETRY_AFTER_MAX};
391
392        let url = format!("{}{}", self.base_url, req.path);
393        let mut rng = rand::thread_rng();
394        let mut last_err = RovenueError::NetworkUnavailable();
395        let corr = self.next_correlation_id();
396
397        let payload = serde_json::to_vec(body).map_err(|_| RovenueError::Internal())?;
398
399        for attempt in 0..self.max_attempts {
400            let mut builder = self
401                .inner
402                .post(&url)
403                .header("Authorization", format!("Bearer {}", self.api_key))
404                .header("Content-Type", "application/json");
405            if let Some(scope) = req.user_scope {
406                builder = builder.header("X-Rovenue-App-User-Id", scope);
407            }
408            if let Some(platform) = &self.platform {
409                builder = builder.header("X-Rovenue-Platform", platform);
410            }
411            if let Some(environment) = &self.environment {
412                builder = builder.header("X-Rovenue-Env", environment);
413            }
414            if let Some(key) = req.idempotency_key {
415                builder = builder.header("Idempotency-Key", key);
416            }
417            let req_built = builder.body(payload.clone());
418
419            match req_built.send() {
420                Ok(resp) => {
421                    let status = resp.status().as_u16();
422                    if let Some(l) = &self.logger {
423                        let path = req.path.to_string();
424                        let corr_c = corr.clone();
425                        l.log(
426                            crate::logging::LogLevel::Debug,
427                            || format!("http POST {path}"),
428                            || {
429                                let mut f = std::collections::HashMap::new();
430                                f.insert("method".to_string(), "POST".to_string());
431                                f.insert("path".to_string(), path.to_string());
432                                f.insert("status".to_string(), status.to_string());
433                                f.insert("attempt".to_string(), attempt.to_string());
434                                f.insert("correlation_id".to_string(), corr_c);
435                                crate::logging::redact::redact_fields(f)
436                            },
437                        );
438                    }
439                    let retry_after = resp
440                        .headers()
441                        .get("Retry-After")
442                        .and_then(|v| v.to_str().ok())
443                        .and_then(|s| s.parse::<u64>().ok())
444                        .map(std::time::Duration::from_secs);
445
446                    match classify(Some(status), retry_after) {
447                        RetryDecision::Success => {
448                            // 204 No Content is always bodyless. Other 2xx
449                            // responses are parsed unless the caller opted out
450                            // via `expect_empty_body` (e.g. /v1/events' empty
451                            // 202) — scoped per-request so we never silently
452                            // drop a body another caller actually needs.
453                            let body = if status == 204 || req.expect_empty_body {
454                                None
455                            } else {
456                                Some(resp.json::<T>().map_err(|_| RovenueError::Internal())?)
457                            };
458                            return Ok(HttpResponse {
459                                status,
460                                etag: None,
461                                body,
462                            });
463                        }
464                        RetryDecision::Retryable => {
465                            last_err = if (500..600).contains(&status) {
466                                retryable_server_error(resp)
467                            } else {
468                                RovenueError::NetworkUnavailable()
469                            };
470                            if attempt + 1 < self.max_attempts {
471                                let d = backoff(attempt, &mut rng).max(self.min_backoff);
472                                std::thread::sleep(d);
473                            }
474                        }
475                        RetryDecision::RetryAfter(d) => {
476                            if d > RETRY_AFTER_MAX {
477                                return Err(RovenueError::RateLimited());
478                            }
479                            last_err = RovenueError::RateLimited();
480                            if attempt + 1 < self.max_attempts {
481                                std::thread::sleep(d.max(self.min_backoff));
482                            }
483                        }
484                        RetryDecision::Fatal => {
485                            let body_text = resp.text().unwrap_or_default();
486                            let err = error_from_status(status, &body_text);
487                            if let Some(l) = &self.logger {
488                                let path = req.path.to_string();
489                                let corr_c = corr.clone();
490                                let kind = format!("{:?}", err.kind);
491                                l.log(
492                                    crate::logging::LogLevel::Error,
493                                    || format!("http POST {path} failed"),
494                                    || {
495                                        let mut f = std::collections::HashMap::new();
496                                        f.insert("method".to_string(), "POST".to_string());
497                                        f.insert("path".to_string(), path.to_string());
498                                        f.insert("status".to_string(), status.to_string());
499                                        f.insert("attempt".to_string(), attempt.to_string());
500                                        f.insert("correlation_id".to_string(), corr_c);
501                                        f.insert("kind".to_string(), kind);
502                                        crate::logging::redact::redact_fields(f)
503                                    },
504                                );
505                            }
506                            return Err(err);
507                        }
508                    }
509                }
510                Err(e) if e.is_timeout() => {
511                    last_err = RovenueError::Timeout();
512                    if attempt + 1 < self.max_attempts {
513                        let d = backoff(attempt, &mut rng).max(self.min_backoff);
514                        std::thread::sleep(d);
515                    }
516                }
517                Err(_) => {
518                    last_err = RovenueError::NetworkUnavailable();
519                    if attempt + 1 < self.max_attempts {
520                        let d = backoff(attempt, &mut rng).max(self.min_backoff);
521                        std::thread::sleep(d);
522                    }
523                }
524            }
525        }
526
527        // Terminal network/timeout failure after all attempts exhausted.
528        if let Some(l) = &self.logger {
529            let path = req.path.to_string();
530            let corr_c = corr.clone();
531            let kind = format!("{:?}", last_err.kind);
532            l.log(
533                crate::logging::LogLevel::Error,
534                || format!("http POST {path} failed after all attempts"),
535                || {
536                    let mut f = std::collections::HashMap::new();
537                    f.insert("method".to_string(), "POST".to_string());
538                    f.insert("path".to_string(), path.to_string());
539                    f.insert("attempt".to_string(), (self.max_attempts - 1).to_string());
540                    f.insert("correlation_id".to_string(), corr_c);
541                    f.insert("kind".to_string(), kind);
542                    crate::logging::redact::redact_fields(f)
543                },
544            );
545        }
546        Err(last_err)
547    }
548
549    /// POST that surfaces the raw HTTP status instead of collapsing 4xx into an
550    /// error. Returns `Ok((status, body))` for any 2xx or 4xx response (body is
551    /// `None` when empty or non-JSON); retries 5xx/network/timeout and returns
552    /// `Err` only when those are exhausted. Used by callers that map specific
553    /// 4xx codes themselves (e.g. funnel claim 404/410/409).
554    pub fn post_json_status<B: Serialize>(
555        &self,
556        req: super::types::HttpPostRequest<'_>,
557        body: &B,
558    ) -> RovenueResult<(u16, Option<serde_json::Value>)> {
559        use super::retry::{backoff, classify, RetryDecision, RETRY_AFTER_MAX};
560
561        let url = format!("{}{}", self.base_url, req.path);
562        let mut rng = rand::thread_rng();
563        let mut last_err = RovenueError::NetworkUnavailable();
564        let corr = self.next_correlation_id();
565        let payload = serde_json::to_vec(body).map_err(|_| RovenueError::Internal())?;
566
567        for attempt in 0..self.max_attempts {
568            let mut builder = self
569                .inner
570                .post(&url)
571                .header("Authorization", format!("Bearer {}", self.api_key))
572                .header("Content-Type", "application/json");
573            if let Some(scope) = req.user_scope {
574                builder = builder.header("X-Rovenue-App-User-Id", scope);
575            }
576            if let Some(platform) = &self.platform {
577                builder = builder.header("X-Rovenue-Platform", platform);
578            }
579            if let Some(environment) = &self.environment {
580                builder = builder.header("X-Rovenue-Env", environment);
581            }
582            if let Some(key) = req.idempotency_key {
583                builder = builder.header("Idempotency-Key", key);
584            }
585
586            match builder.body(payload.clone()).send() {
587                Ok(resp) => {
588                    let status = resp.status().as_u16();
589                    if let Some(l) = &self.logger {
590                        let path = req.path.to_string();
591                        let corr_c = corr.clone();
592                        l.log(
593                            crate::logging::LogLevel::Debug,
594                            || format!("http POST {path}"),
595                            || {
596                                let mut f = std::collections::HashMap::new();
597                                f.insert("method".to_string(), "POST".to_string());
598                                f.insert("path".to_string(), path.to_string());
599                                f.insert("status".to_string(), status.to_string());
600                                f.insert("attempt".to_string(), attempt.to_string());
601                                f.insert("correlation_id".to_string(), corr_c);
602                                crate::logging::redact::redact_fields(f)
603                            },
604                        );
605                    }
606                    // 4xx is a returnable outcome (caller maps it); 2xx too.
607                    if (200..500).contains(&status) {
608                        let parsed = resp.json::<serde_json::Value>().ok();
609                        return Ok((status, parsed));
610                    }
611                    // 5xx (and anything else) → retry per policy.
612                    let retry_after = resp
613                        .headers()
614                        .get("Retry-After")
615                        .and_then(|v| v.to_str().ok())
616                        .and_then(|s| s.parse::<u64>().ok())
617                        .map(std::time::Duration::from_secs);
618                    match classify(Some(status), retry_after) {
619                        RetryDecision::RetryAfter(d) => {
620                            if d > RETRY_AFTER_MAX {
621                                return Err(RovenueError::RateLimited());
622                            }
623                            last_err = RovenueError::RateLimited();
624                            if attempt + 1 < self.max_attempts {
625                                std::thread::sleep(d.max(self.min_backoff));
626                            }
627                        }
628                        _ => {
629                            last_err = retryable_server_error(resp);
630                            if attempt + 1 < self.max_attempts {
631                                let d = backoff(attempt, &mut rng).max(self.min_backoff);
632                                std::thread::sleep(d);
633                            }
634                        }
635                    }
636                }
637                Err(e) if e.is_timeout() => {
638                    last_err = RovenueError::Timeout();
639                    if attempt + 1 < self.max_attempts {
640                        let d = backoff(attempt, &mut rng).max(self.min_backoff);
641                        std::thread::sleep(d);
642                    }
643                }
644                Err(_) => {
645                    last_err = RovenueError::NetworkUnavailable();
646                    if attempt + 1 < self.max_attempts {
647                        let d = backoff(attempt, &mut rng).max(self.min_backoff);
648                        std::thread::sleep(d);
649                    }
650                }
651            }
652        }
653
654        // Terminal network/timeout failure after all attempts exhausted.
655        if let Some(l) = &self.logger {
656            let path = req.path.to_string();
657            let corr_c = corr.clone();
658            let kind = format!("{:?}", last_err.kind);
659            l.log(
660                crate::logging::LogLevel::Error,
661                || format!("http POST {path} failed after all attempts"),
662                || {
663                    let mut f = std::collections::HashMap::new();
664                    f.insert("method".to_string(), "POST".to_string());
665                    f.insert("path".to_string(), path.to_string());
666                    f.insert("attempt".to_string(), (self.max_attempts - 1).to_string());
667                    f.insert("correlation_id".to_string(), corr_c);
668                    f.insert("kind".to_string(), kind);
669                    crate::logging::redact::redact_fields(f)
670                },
671            );
672        }
673        Err(last_err)
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    #[test]
682    fn debug_level_logs_request_metadata_without_authorization() {
683        use crate::logging::{LogLevel, LogRecord, LogSink, Logger};
684        use std::sync::Mutex as StdMutex;
685        struct Collector(std::sync::Arc<StdMutex<Vec<LogRecord>>>);
686        impl LogSink for Collector {
687            fn on_log(&self, r: LogRecord) {
688                self.0.lock().unwrap().push(r);
689            }
690        }
691        let recs = std::sync::Arc::new(StdMutex::new(Vec::new()));
692        let logger = std::sync::Arc::new(Logger::new(LogLevel::Debug));
693        logger.set_sink(std::sync::Arc::new(Collector(recs.clone())));
694
695        // Point at an unroutable local port so the request fails fast; we only
696        // assert on the emitted trace metadata, not on a live response.
697        let client = HttpClient::new("http://127.0.0.1:1".to_string(), "pk_secret".to_string())
698            .with_max_attempts(1)
699            .with_logger(logger);
700        let _ = client.get_json::<serde_json::Value>(HttpRequest::new("/v1/entitlements"));
701
702        let got = recs.lock().unwrap();
703        assert!(
704            got.iter().any(|r| r
705                .fields
706                .get("path")
707                .map(|p| p == "/v1/entitlements")
708                .unwrap_or(false)),
709            "expected a record carrying the request path"
710        );
711        for r in got.iter() {
712            for v in r.fields.values() {
713                assert!(!v.contains("pk_secret"), "api key leaked into trace: {v}");
714            }
715            assert!(
716                !r.message.contains("pk_secret"),
717                "api key leaked into message: {}",
718                r.message
719            );
720            assert!(
721                !r.fields.contains_key("Authorization") && !r.fields.contains_key("authorization"),
722                "Authorization must never be a logged field"
723            );
724        }
725    }
726}
727
728#[cfg(test)]
729mod post_json_tests {
730    use super::super::types::HttpPostRequest;
731    use super::*;
732
733    #[test]
734    fn post_json_skips_body_when_expect_empty_body() {
735        let mut server = mockito::Server::new();
736        let m = server.mock("POST", "/v1/events").with_status(202).create();
737
738        let client = HttpClient::new(server.url(), "pk_test".into()).with_max_attempts(1);
739        let body = serde_json::json!({ "eventType": "x", "occurredAt": "2026-06-20T00:00:00Z" });
740        let resp = client
741            .post_json::<serde_json::Value, serde_json::Value>(
742                HttpPostRequest::new("/v1/events").expect_empty_body(),
743                &body,
744            )
745            .expect("202 must be Ok");
746
747        assert_eq!(resp.status, 202);
748        assert!(resp.body.is_none());
749        m.assert();
750    }
751
752    #[test]
753    fn post_json_parses_202_body_when_not_expecting_empty() {
754        // Regression guard: the 202-bodyless carve-out must be scoped to
755        // `expect_empty_body`, not global — other callers' 2xx bodies are kept.
756        let mut server = mockito::Server::new();
757        let m = server
758            .mock("POST", "/v1/some/endpoint")
759            .with_status(202)
760            .with_header("content-type", "application/json")
761            .with_body(r#"{"ok":true}"#)
762            .create();
763
764        let client = HttpClient::new(server.url(), "pk_test".into()).with_max_attempts(1);
765        let body = serde_json::json!({});
766        let resp = client
767            .post_json::<serde_json::Value, serde_json::Value>(
768                HttpPostRequest::new("/v1/some/endpoint"),
769                &body,
770            )
771            .expect("202 with body must be Ok");
772
773        assert_eq!(resp.status, 202);
774        assert_eq!(resp.body.unwrap()["ok"], serde_json::json!(true));
775        m.assert();
776    }
777}
778
779#[cfg(test)]
780mod post_json_status_tests {
781    use super::super::types::HttpPostRequest;
782    use super::*;
783
784    #[test]
785    fn returns_status_and_body_for_2xx_and_4xx() {
786        let mut server = mockito::Server::new();
787        let m200 = server
788            .mock("POST", "/ok")
789            .with_status(200)
790            .with_header("content-type", "application/json")
791            .with_body(r#"{"data":{"x":1}}"#)
792            .create();
793        let m404 = server
794            .mock("POST", "/missing")
795            .with_status(404)
796            .with_header("content-type", "application/json")
797            .with_body(r#"{"error":{"code":"x","message":"y"}}"#)
798            .create();
799
800        let client = HttpClient::new(server.url(), "pk_test".into()).with_max_attempts(1);
801        let body = serde_json::json!({"a":1});
802
803        let (s1, b1) = client
804            .post_json_status(HttpPostRequest::new("/ok"), &body)
805            .expect("2xx ok");
806        assert_eq!(s1, 200);
807        assert_eq!(b1.unwrap()["data"]["x"], 1);
808
809        let (s2, b2) = client
810            .post_json_status(HttpPostRequest::new("/missing"), &body)
811            .expect("4xx returns Ok, not Err");
812        assert_eq!(s2, 404);
813        assert_eq!(b2.unwrap()["error"]["code"], "x");
814
815        m200.assert();
816        m404.assert();
817    }
818}