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
15pub const SUBSCRIBER_UNRESOLVABLE_CODE: &str = "SUBSCRIBER_UNRESOLVABLE";
19
20pub 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
35pub 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, 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
68fn 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 platform: Option<String>,
90 environment: Option<String>,
94 logger: Option<std::sync::Arc<crate::logging::Logger>>,
97 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 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 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 pub fn with_logger(mut self, logger: std::sync::Arc<crate::logging::Logger>) -> Self {
164 self.logger = Some(logger);
165 self
166 }
167
168 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 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 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 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 super::types::HttpPostRequest::new("/v1/sdk/sessions")
362 .user_scope(subscriber_id)
363 .expect_empty_body(),
364 &body,
365 )?;
366 Ok(())
367 }
368
369 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 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 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 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 if (200..500).contains(&status) {
608 let parsed = resp.json::<serde_json::Value>().ok();
609 return Ok((status, parsed));
610 }
611 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 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 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 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}