1use rand::random;
35use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderValue, RETRY_AFTER};
36use reqwest::{Method, Response, Url};
37use std::time::Duration;
38
39use crate::constants::user_agent;
40use crate::error::{BoxError, Error, Result};
41use crate::i18n::Language;
42#[cfg(feature = "qr")]
43use crate::qr::{Options as QrOptions, QrGenerator};
44
45pub const DEFAULT_BASE_URL: &str = "https://app.pakasir.com";
47pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
49pub const DEFAULT_RETRIES: usize = 3;
51pub const DEFAULT_RETRY_WAIT_MIN: Duration = Duration::from_secs(1);
53pub const DEFAULT_RETRY_WAIT_MAX: Duration = Duration::from_secs(30);
56pub const DEFAULT_MAX_RESPONSE_SIZE: usize = 1 << 20;
61
62#[derive(Debug, Clone)]
68pub struct Client {
69 project: String,
70 api_key: String,
71 base_url: String,
72 http_client: reqwest::Client,
73 language: Language,
74 retries: usize,
75 retry_wait_min: Duration,
76 retry_wait_max: Duration,
77 max_response_size: usize,
78 #[cfg(feature = "qr")]
79 qr: QrGenerator,
80}
81
82#[derive(Debug, Clone)]
87pub struct ClientBuilder {
88 project: String,
89 api_key: String,
90 base_url: String,
91 http_client: Option<reqwest::Client>,
92 timeout: Duration,
93 language: Language,
94 retries: usize,
95 retry_wait_min: Duration,
96 retry_wait_max: Duration,
97 max_response_size: usize,
98 #[cfg(feature = "qr")]
99 qr_options: QrOptions,
100}
101
102enum AttemptError {
108 Stop(Error),
109 Retry {
110 source: BoxError,
111 retry_after_hint: Option<Duration>,
112 },
113}
114
115impl Client {
116 pub fn new(project: impl Into<String>, api_key: impl Into<String>) -> Self {
122 Self::builder(project, api_key).build()
123 }
124
125 pub fn builder(project: impl Into<String>, api_key: impl Into<String>) -> ClientBuilder {
127 ClientBuilder::new(project, api_key)
128 }
129
130 pub fn project(&self) -> &str {
132 &self.project
133 }
134
135 pub fn api_key(&self) -> &str {
137 &self.api_key
138 }
139
140 pub fn language(&self) -> Language {
142 self.language
143 }
144
145 #[cfg(feature = "qr")]
149 pub fn qr(&self) -> &QrGenerator {
150 &self.qr
151 }
152
153 pub async fn do_request(
163 &self,
164 method: Method,
165 path: &str,
166 body: Option<Vec<u8>>,
167 ) -> Result<Vec<u8>> {
168 self.validate_credentials()?;
169
170 let mut last_error: Option<BoxError> = None;
171 let mut retry_after_hint = None;
172
173 for attempt in 0..=self.retries {
174 self.wait_for_retry(attempt, retry_after_hint).await;
175
176 match self
177 .execute_attempt(method.clone(), path, body.as_deref())
178 .await
179 {
180 Ok(bytes) => return Ok(bytes),
181 Err(AttemptError::Stop(error)) => return Err(error),
182 Err(AttemptError::Retry {
183 source,
184 retry_after_hint: hint,
185 }) => {
186 last_error = Some(source);
187 retry_after_hint = hint;
188 }
189 }
190 }
191
192 let source: BoxError = last_error
193 .unwrap_or_else(|| Box::new(std::io::Error::other("request failed")) as BoxError);
194 Err(Error::request_failed_after_retries(
195 self.language,
196 self.retries,
197 source,
198 ))
199 }
200
201 fn validate_credentials(&self) -> Result<()> {
203 if self.project.is_empty() {
204 return Err(Error::invalid_project(self.language));
205 }
206 if self.api_key.is_empty() {
207 return Err(Error::invalid_api_key(self.language));
208 }
209 Ok(())
210 }
211
212 async fn execute_attempt(
218 &self,
219 method: Method,
220 path: &str,
221 body: Option<&[u8]>,
222 ) -> std::result::Result<Vec<u8>, AttemptError> {
223 let url = self.build_url(path).map_err(AttemptError::Stop)?;
224
225 let mut request = self
226 .http_client
227 .request(method, url)
228 .header(ACCEPT, HeaderValue::from_static("application/json"));
229
230 if let Some(body) = body {
231 request = request
232 .header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
233 .body(body.to_vec());
234 }
235
236 let response = request.send().await.map_err(|err| {
237 if is_retryable_transport(&err) {
238 AttemptError::Retry {
239 source: Box::new(err),
240 retry_after_hint: None,
241 }
242 } else {
243 AttemptError::Stop(Error::request_failed(self.language, Box::new(err)))
244 }
245 })?;
246
247 self.handle_response(response).await
248 }
249
250 async fn handle_response(
253 &self,
254 response: Response,
255 ) -> std::result::Result<Vec<u8>, AttemptError> {
256 let status = response.status();
257 let retry_after_hint = parse_retry_after(response.headers().get(RETRY_AFTER));
258
259 let body = self
260 .read_response_body(response)
261 .await
262 .map_err(|err| match err {
263 Error::ResponseTooLarge { .. } => {
264 AttemptError::Stop(Error::request_failed(self.language, Box::new(err)))
265 }
266 other => AttemptError::Retry {
267 source: Box::new(other),
268 retry_after_hint: None,
269 },
270 })?;
271
272 if status.is_success() {
273 return Ok(body);
274 }
275
276 let api_error = Error::Api {
277 status,
278 body: String::from_utf8_lossy(&body).into_owned(),
279 };
280
281 if is_retryable_status(status) {
282 return Err(AttemptError::Retry {
283 source: Box::new(api_error),
284 retry_after_hint,
285 });
286 }
287
288 Err(AttemptError::Stop(api_error))
289 }
290
291 async fn read_response_body(&self, mut response: Response) -> Result<Vec<u8>> {
296 let mut body = Vec::new();
297
298 while let Some(chunk) = response
299 .chunk()
300 .await
301 .map_err(|err| Error::request_failed(self.language, Box::new(err)))?
302 {
303 body.extend_from_slice(&chunk);
304 if body.len() > self.max_response_size {
305 return Err(Error::ResponseTooLarge {
306 limit: self.max_response_size,
307 });
308 }
309 }
310
311 Ok(body)
312 }
313
314 async fn wait_for_retry(&self, attempt: usize, retry_after_hint: Option<Duration>) {
320 if attempt == 0 {
321 return;
322 }
323
324 let wait = retry_after_hint
325 .map(|hint| hint.min(self.retry_wait_max))
326 .unwrap_or_else(|| self.calculate_backoff(attempt));
327
328 tokio::time::sleep(wait).await;
329 }
330
331 fn calculate_backoff(&self, attempt: usize) -> Duration {
337 let multiplier = 1u32
338 .checked_shl((attempt.saturating_sub(1)) as u32)
339 .unwrap_or(u32::MAX);
340 let max_wait = self
341 .retry_wait_min
342 .saturating_mul(multiplier)
343 .min(self.retry_wait_max);
344
345 if max_wait <= self.retry_wait_min {
346 return self.retry_wait_min;
347 }
348
349 let span_nanos = max_wait
350 .saturating_sub(self.retry_wait_min)
351 .as_nanos()
352 .min(u64::MAX as u128) as u64;
353 let jitter = random::<u64>() % (span_nanos + 1);
354 self.retry_wait_min + Duration::from_nanos(jitter)
355 }
356
357 fn build_url(&self, path: &str) -> Result<Url> {
361 Url::parse(&format!("{}{}", self.base_url, path))
362 .map_err(|source| Error::BuildRequest { source })
363 }
364}
365
366impl ClientBuilder {
367 pub fn new(project: impl Into<String>, api_key: impl Into<String>) -> Self {
370 Self {
371 project: project.into(),
372 api_key: api_key.into(),
373 base_url: DEFAULT_BASE_URL.to_owned(),
374 http_client: None,
375 timeout: DEFAULT_TIMEOUT,
376 language: Language::English,
377 retries: DEFAULT_RETRIES,
378 retry_wait_min: DEFAULT_RETRY_WAIT_MIN,
379 retry_wait_max: DEFAULT_RETRY_WAIT_MAX,
380 max_response_size: DEFAULT_MAX_RESPONSE_SIZE,
381 #[cfg(feature = "qr")]
382 qr_options: QrOptions::default(),
383 }
384 }
385
386 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
389 self.base_url = base_url.into().trim_end_matches('/').to_owned();
390 self
391 }
392
393 pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
397 self.http_client = Some(http_client);
398 self
399 }
400
401 pub fn timeout(mut self, timeout: Duration) -> Self {
404 if !timeout.is_zero() {
405 self.timeout = timeout;
406 }
407 self
408 }
409
410 pub fn language(mut self, language: Language) -> Self {
412 self.language = language;
413 self
414 }
415
416 pub fn retries(mut self, retries: usize) -> Self {
418 self.retries = retries;
419 self
420 }
421
422 pub fn retry_wait(mut self, min: Duration, max: Duration) -> Self {
427 let floor = Duration::from_millis(1);
428 let mut resolved_min = if min.is_zero() { floor } else { min };
429 let mut resolved_max = if max.is_zero() { floor } else { max };
430
431 if resolved_min > resolved_max {
432 std::mem::swap(&mut resolved_min, &mut resolved_max);
433 }
434
435 self.retry_wait_min = resolved_min;
436 self.retry_wait_max = resolved_max;
437 self
438 }
439
440 pub fn max_response_size(mut self, max_response_size: usize) -> Self {
442 if max_response_size > 0 {
443 self.max_response_size = max_response_size;
444 }
445 self
446 }
447
448 #[cfg(feature = "qr")]
452 pub fn qr_options(mut self, qr_options: QrOptions) -> Self {
453 self.qr_options = qr_options;
454 self
455 }
456
457 pub fn build(self) -> Client {
463 let http_client = self.http_client.unwrap_or_else(|| {
464 reqwest::Client::builder()
465 .timeout(self.timeout)
466 .user_agent(user_agent())
467 .build()
468 .expect("default reqwest client configuration must be valid")
469 });
470
471 Client {
472 project: self.project,
473 api_key: self.api_key,
474 base_url: self.base_url,
475 http_client,
476 language: self.language,
477 retries: self.retries,
478 retry_wait_min: self.retry_wait_min,
479 retry_wait_max: self.retry_wait_max,
480 max_response_size: self.max_response_size,
481 #[cfg(feature = "qr")]
482 qr: QrGenerator::new(self.qr_options),
483 }
484 }
485}
486
487fn is_retryable_status(status: reqwest::StatusCode) -> bool {
492 matches!(
493 status,
494 reqwest::StatusCode::TOO_MANY_REQUESTS
495 | reqwest::StatusCode::BAD_GATEWAY
496 | reqwest::StatusCode::SERVICE_UNAVAILABLE
497 | reqwest::StatusCode::GATEWAY_TIMEOUT
498 )
499}
500
501fn is_retryable_transport(error: &reqwest::Error) -> bool {
507 !error.is_builder()
508}
509
510fn parse_retry_after(value: Option<&HeaderValue>) -> Option<Duration> {
521 let raw = value?.to_str().ok()?.trim();
522 if raw.is_empty() {
523 return None;
524 }
525
526 if let Ok(seconds) = raw.parse::<u64>() {
527 return Some(Duration::from_secs(seconds.min(86_400)));
528 }
529
530 let parsed = httpdate::parse_http_date(raw).ok()?;
531 parsed.duration_since(std::time::SystemTime::now()).ok()
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use reqwest::StatusCode;
538
539 #[test]
540 fn client_new_yields_same_defaults_as_builder() {
541 let a = Client::new("p", "k");
542 let b = Client::builder("p", "k").build();
543 assert_eq!(a.project(), b.project());
544 assert_eq!(a.api_key(), b.api_key());
545 assert_eq!(a.language(), b.language());
546 assert_eq!(a.retries, b.retries);
547 assert_eq!(a.retry_wait_min, b.retry_wait_min);
548 assert_eq!(a.retry_wait_max, b.retry_wait_max);
549 assert_eq!(a.max_response_size, b.max_response_size);
550 assert_eq!(a.base_url, b.base_url);
551 }
552
553 #[test]
554 fn client_getters_return_configured_values() {
555 let client = Client::builder("proj", "key").build();
556 assert_eq!(client.project(), "proj");
557 assert_eq!(client.api_key(), "key");
558 assert_eq!(client.language(), Language::English);
559 }
560
561 #[test]
562 fn builder_base_url_strips_trailing_slashes() {
563 let client = Client::builder("p", "k").base_url("https://x/").build();
564 assert_eq!(client.base_url, "https://x");
565
566 let client = Client::builder("p", "k").base_url("https://x///").build();
567 assert_eq!(client.base_url, "https://x");
568 }
569
570 #[test]
571 fn builder_http_client_swaps_underlying_reqwest_client() {
572 let custom = reqwest::Client::builder().build().unwrap();
573 let _ = Client::builder("p", "k").http_client(custom).build();
576 }
577
578 #[test]
579 fn builder_timeout_zero_is_a_no_op() {
580 let client = Client::builder("p", "k").timeout(Duration::ZERO).build();
581 let _ = client;
584 }
585
586 #[test]
587 fn builder_timeout_applies_positive_durations() {
588 let _client = Client::builder("p", "k")
589 .timeout(Duration::from_secs(7))
590 .build();
591 }
592
593 #[test]
594 fn builder_language_overrides_default() {
595 let client = Client::builder("p", "k")
596 .language(Language::Indonesian)
597 .build();
598 assert_eq!(client.language(), Language::Indonesian);
599 }
600
601 #[test]
602 fn builder_retries_overrides_default() {
603 let client = Client::builder("p", "k").retries(7).build();
604 assert_eq!(client.retries, 7);
605
606 let client = Client::builder("p", "k").retries(0).build();
607 assert_eq!(client.retries, 0);
608 }
609
610 #[test]
611 fn builder_retry_wait_zero_durations_clamp_to_one_millisecond() {
612 let client = Client::builder("p", "k")
613 .retry_wait(Duration::ZERO, Duration::ZERO)
614 .build();
615 assert_eq!(client.retry_wait_min, Duration::from_millis(1));
616 assert_eq!(client.retry_wait_max, Duration::from_millis(1));
617 }
618
619 #[test]
620 fn builder_retry_wait_swaps_min_and_max_when_inverted() {
621 let client = Client::builder("p", "k")
622 .retry_wait(Duration::from_secs(10), Duration::from_secs(1))
623 .build();
624 assert_eq!(client.retry_wait_min, Duration::from_secs(1));
626 assert_eq!(client.retry_wait_max, Duration::from_secs(10));
627 }
628
629 #[test]
630 fn builder_retry_wait_keeps_already_ordered_pair() {
631 let client = Client::builder("p", "k")
632 .retry_wait(Duration::from_millis(100), Duration::from_millis(500))
633 .build();
634 assert_eq!(client.retry_wait_min, Duration::from_millis(100));
635 assert_eq!(client.retry_wait_max, Duration::from_millis(500));
636 }
637
638 #[test]
639 fn builder_max_response_size_zero_is_a_no_op() {
640 let client = Client::builder("p", "k").max_response_size(0).build();
641 assert_eq!(client.max_response_size, DEFAULT_MAX_RESPONSE_SIZE);
642 }
643
644 #[test]
645 fn builder_max_response_size_overrides_default() {
646 let client = Client::builder("p", "k").max_response_size(512).build();
647 assert_eq!(client.max_response_size, 512);
648 }
649
650 #[cfg(feature = "qr")]
651 #[test]
652 fn builder_qr_options_propagate_to_client() {
653 use crate::qr::{Options as QrOpts, RecoveryLevel};
654 let opts = QrOpts::default()
655 .with_size(384)
656 .with_recovery_level(RecoveryLevel::High);
657 let client = Client::builder("p", "k").qr_options(opts.clone()).build();
658 assert_eq!(client.qr().options(), &opts);
659 }
660
661 #[test]
662 fn is_retryable_status_matches_documented_set() {
663 assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS));
664 assert!(is_retryable_status(StatusCode::BAD_GATEWAY));
665 assert!(is_retryable_status(StatusCode::SERVICE_UNAVAILABLE));
666 assert!(is_retryable_status(StatusCode::GATEWAY_TIMEOUT));
667 }
668
669 #[test]
670 fn is_retryable_status_excludes_other_statuses() {
671 for code in [
672 StatusCode::OK,
673 StatusCode::BAD_REQUEST,
674 StatusCode::NOT_FOUND,
675 StatusCode::UNAUTHORIZED,
676 StatusCode::INTERNAL_SERVER_ERROR,
677 StatusCode::NOT_IMPLEMENTED,
678 ] {
679 assert!(!is_retryable_status(code), "must NOT retry on {code}");
680 }
681 }
682
683 #[test]
684 fn parse_retry_after_returns_none_for_missing_header() {
685 assert!(parse_retry_after(None).is_none());
686 }
687
688 #[test]
689 fn parse_retry_after_returns_none_for_empty_value() {
690 let header = HeaderValue::from_static("");
691 assert!(parse_retry_after(Some(&header)).is_none());
692
693 let header = HeaderValue::from_static(" ");
694 assert!(parse_retry_after(Some(&header)).is_none());
695 }
696
697 #[test]
698 fn parse_retry_after_returns_none_for_unparseable_value() {
699 let header = HeaderValue::from_static("not a real value");
700 assert!(parse_retry_after(Some(&header)).is_none());
701 }
702
703 #[test]
704 fn parse_retry_after_parses_delta_seconds() {
705 let header = HeaderValue::from_static("12");
706 assert_eq!(
707 parse_retry_after(Some(&header)),
708 Some(Duration::from_secs(12))
709 );
710 }
711
712 #[test]
713 fn parse_retry_after_caps_delta_seconds_at_24h() {
714 let header = HeaderValue::from_static("999999"); assert_eq!(
716 parse_retry_after(Some(&header)),
717 Some(Duration::from_secs(86_400))
718 );
719 }
720
721 #[test]
722 fn parse_retry_after_parses_http_date_in_the_future() {
723 let target = std::time::SystemTime::now() + Duration::from_secs(60);
726 let formatted = httpdate::fmt_http_date(target);
727 let header = HeaderValue::from_str(&formatted).unwrap();
728 let parsed = parse_retry_after(Some(&header)).expect("future HTTP-date should parse");
729 assert!(parsed <= Duration::from_secs(61));
731 }
732
733 #[test]
734 fn parse_retry_after_returns_none_for_http_date_in_the_past() {
735 let header = HeaderValue::from_static("Wed, 21 Oct 1970 07:28:00 GMT");
736 assert!(parse_retry_after(Some(&header)).is_none());
737 }
738
739 #[test]
740 fn calculate_backoff_floors_at_retry_wait_min() {
741 let client = Client::builder("p", "k")
742 .retry_wait(Duration::from_millis(10), Duration::from_millis(20))
743 .build();
744 assert_eq!(client.calculate_backoff(0), Duration::from_millis(10));
746 }
747
748 #[test]
749 fn calculate_backoff_stays_within_configured_bounds() {
750 let client = Client::builder("p", "k")
751 .retry_wait(Duration::from_millis(10), Duration::from_millis(20))
752 .build();
753 for attempt in 1..=5_usize {
754 let wait = client.calculate_backoff(attempt);
755 assert!(
756 wait >= Duration::from_millis(10),
757 "attempt={attempt} wait={wait:?}"
758 );
759 assert!(
760 wait <= Duration::from_millis(20),
761 "attempt={attempt} wait={wait:?}"
762 );
763 }
764 }
765
766 #[test]
767 fn calculate_backoff_handles_extreme_attempt_count() {
768 let client = Client::builder("p", "k")
769 .retry_wait(Duration::from_millis(10), Duration::from_secs(30))
770 .build();
771 let wait = client.calculate_backoff(64);
773 assert!(wait <= Duration::from_secs(30));
774 }
775
776 #[tokio::test]
777 async fn do_request_rejects_empty_project() {
778 let client = Client::builder("", "k").retries(0).build();
779 let err = client
780 .do_request(Method::GET, "/x", None)
781 .await
782 .unwrap_err();
783 assert!(matches!(err, Error::InvalidProject { .. }));
784 }
785
786 #[tokio::test]
787 async fn do_request_rejects_empty_api_key() {
788 let client = Client::builder("p", "").retries(0).build();
789 let err = client
790 .do_request(Method::GET, "/x", None)
791 .await
792 .unwrap_err();
793 assert!(matches!(err, Error::InvalidApiKey { .. }));
794 }
795
796 #[tokio::test]
797 async fn do_request_surfaces_build_url_failures() {
798 let client = Client::builder("p", "k")
801 .base_url("not a url")
802 .retries(0)
803 .build();
804 let err = client
805 .do_request(Method::GET, "/path", None)
806 .await
807 .unwrap_err();
808 assert!(matches!(err, Error::BuildRequest { .. }));
809 }
810
811 #[tokio::test]
812 async fn wait_for_retry_returns_immediately_on_first_attempt() {
813 let client = Client::builder("p", "k")
814 .retry_wait(Duration::from_secs(60), Duration::from_secs(60))
815 .build();
816 let start = std::time::Instant::now();
818 client.wait_for_retry(0, None).await;
819 assert!(start.elapsed() < Duration::from_millis(500));
820 }
821
822 #[tokio::test]
823 async fn wait_for_retry_honors_retry_after_hint_clamped_to_max() {
824 let client = Client::builder("p", "k")
825 .retry_wait(Duration::from_millis(1), Duration::from_millis(5))
826 .build();
827 let start = std::time::Instant::now();
829 client
830 .wait_for_retry(1, Some(Duration::from_secs(60)))
831 .await;
832 assert!(start.elapsed() < Duration::from_millis(500));
833 }
834}