1use std::{collections::HashMap, time::Duration};
2
3use http::{HeaderMap, header::HeaderValue};
4use serde::{Serialize, de::DeserializeOwned};
5
6use crate::{config::ClientOptions, error::OpencodeError, resources::app::AppResource};
7
8const VERSION: &str = env!("CARGO_PKG_VERSION");
10
11#[derive(Debug, Default, Clone)]
16pub struct RequestOptions {
17 pub extra_headers: Option<HeaderMap>,
19 pub timeout: Option<Duration>,
21 pub max_retries: Option<u32>,
23}
24
25#[derive(Clone)]
30pub struct Opencode {
31 base_url: String,
32 timeout: Duration,
33 max_retries: u32,
34 default_headers: HeaderMap,
35 default_query: HashMap<String, String>,
36 pub(crate) http_client: hpx::Client,
37}
38
39impl std::fmt::Debug for Opencode {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct("Opencode")
42 .field("base_url", &self.base_url)
43 .field("timeout", &self.timeout)
44 .field("max_retries", &self.max_retries)
45 .field("default_headers", &self.default_headers)
46 .field("default_query", &self.default_query)
47 .field("http_client", &"hpx::Client { .. }")
48 .finish()
49 }
50}
51
52impl Opencode {
53 pub fn new() -> Result<Self, OpencodeError> {
63 Self::with_options(&ClientOptions::default())
64 }
65
66 pub fn with_options(opts: &ClientOptions) -> Result<Self, OpencodeError> {
73 let timeout = opts.resolve_timeout();
74 let default_headers = opts.resolve_default_headers();
75
76 let http_client = hpx::Client::builder()
77 .timeout(timeout)
78 .default_headers(default_headers.clone())
79 .build()
80 .map_err(|e| OpencodeError::Http(Box::new(e)))?;
81
82 Ok(Self {
83 base_url: opts.resolve_base_url().to_owned(),
84 timeout,
85 max_retries: opts.resolve_max_retries(),
86 default_headers,
87 default_query: opts.resolve_default_query(),
88 http_client,
89 })
90 }
91
92 #[must_use]
94 pub fn builder() -> OpencodeBuilder {
95 OpencodeBuilder { options: ClientOptions::default() }
96 }
97
98 #[must_use]
102 pub fn base_url(&self) -> &str {
103 &self.base_url
104 }
105
106 #[must_use]
108 pub const fn timeout(&self) -> Duration {
109 self.timeout
110 }
111
112 #[must_use]
114 pub const fn max_retries(&self) -> u32 {
115 self.max_retries
116 }
117
118 #[must_use]
120 pub const fn default_headers(&self) -> &HeaderMap {
121 &self.default_headers
122 }
123
124 #[must_use]
126 pub const fn default_query(&self) -> &HashMap<String, String> {
127 &self.default_query
128 }
129
130 pub const fn app(&self) -> AppResource<'_> {
134 AppResource::new(self)
135 }
136
137 pub const fn config(&self) -> crate::resources::config::ConfigResource<'_> {
139 crate::resources::config::ConfigResource::new(self)
140 }
141
142 pub const fn event(&self) -> crate::resources::event::EventResource<'_> {
144 crate::resources::event::EventResource::new(self)
145 }
146
147 pub const fn file(&self) -> crate::resources::file::FileResource<'_> {
149 crate::resources::file::FileResource::new(self)
150 }
151
152 pub const fn find(&self) -> crate::resources::find::FindResource<'_> {
154 crate::resources::find::FindResource::new(self)
155 }
156
157 pub const fn session(&self) -> crate::resources::session::SessionResource<'_> {
159 crate::resources::session::SessionResource::new(self)
160 }
161
162 pub const fn tui(&self) -> crate::resources::tui::TuiResource<'_> {
164 crate::resources::tui::TuiResource::new(self)
165 }
166
167 pub(crate) fn build_url(&self, path: &str, query: Option<&HashMap<String, String>>) -> String {
174 let base = self.base_url.trim_end_matches('/');
175 let path_part = if path.starts_with('/') { path.to_owned() } else { format!("/{path}") };
176
177 let mut params: Vec<(&str, &str)> =
178 self.default_query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
179
180 if let Some(q) = query {
181 params.extend(q.iter().map(|(k, v)| (k.as_str(), v.as_str())));
182 }
183
184 if params.is_empty() {
185 format!("{base}{path_part}")
186 } else {
187 params.sort_by_key(|(k, _)| *k);
188 let qs = params.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&");
189 format!("{base}{path_part}?{qs}")
190 }
191 }
192
193 pub(crate) fn build_headers(
198 &self,
199 extra_headers: Option<&HeaderMap>,
200 retry_count: u32,
201 ) -> HeaderMap {
202 let mut headers = self.default_headers.clone();
203
204 headers.insert(http::header::ACCEPT, HeaderValue::from_static("application/json"));
205
206 if let Ok(ua) = HeaderValue::from_str(&format!("opencode-sdk-rs/{VERSION}")) {
207 headers.insert(http::header::USER_AGENT, ua);
208 }
209
210 if retry_count > 0 &&
211 let Ok(val) = HeaderValue::from_str(&retry_count.to_string())
212 {
213 headers.insert("x-retry-count", val);
214 }
215
216 if let Some(extra) = extra_headers {
217 for (key, value) in extra {
218 headers.insert(key, value.clone());
219 }
220 }
221
222 headers
223 }
224
225 async fn make_request<T, Q>(
233 &self,
234 method: http::Method,
235 path: &str,
236 body: Option<serde_json::Value>,
237 query: Option<&Q>,
238 options: Option<&RequestOptions>,
239 ) -> Result<T, OpencodeError>
240 where
241 T: DeserializeOwned,
242 Q: Serialize + Sync + ?Sized,
243 {
244 let url = self.build_url(path, None);
245 let max_retries = options.and_then(|o| o.max_retries).unwrap_or(self.max_retries);
246 let timeout = options.and_then(|o| o.timeout).unwrap_or(self.timeout);
247 let extra_headers = options.and_then(|o| o.extra_headers.as_ref());
248
249 let mut last_error: Option<OpencodeError> = None;
250
251 for attempt in 0..=max_retries {
252 let headers = self.build_headers(extra_headers, attempt);
253
254 tracing::debug!(
255 method = %method,
256 url = %url,
257 attempt,
258 "sending request"
259 );
260
261 let mut req =
262 self.http_client.request(method.clone(), &url).headers(headers).timeout(timeout);
263
264 if let Some(q) = query {
265 req = req.query(q);
266 }
267
268 if let Some(ref b) = body {
269 req = req.json(b);
270 }
271
272 let result = req.send().await;
273
274 match result {
275 Ok(resp) => {
276 let status = resp.status();
277 let resp_headers = resp.headers().clone();
278
279 if status.is_success() {
280 let bytes =
281 resp.bytes().await.map_err(|e| OpencodeError::Http(Box::new(e)))?;
282 let parsed: T = serde_json::from_slice(&bytes)?;
283 return Ok(parsed);
284 }
285
286 let body_bytes = resp.bytes().await.ok();
288 let body_value: Option<serde_json::Value> =
289 body_bytes.as_ref().and_then(|b| serde_json::from_slice(b).ok());
290
291 let err = OpencodeError::from_response(
292 status.as_u16(),
293 Some(resp_headers.clone()),
294 body_value,
295 );
296
297 if attempt < max_retries && should_retry(&err, &resp_headers) {
298 let delay = retry_delay(attempt, &resp_headers);
299 tracing::debug!(
300 attempt,
301 delay_ms = delay.as_millis() as u64,
302 "retrying after error"
303 );
304 tokio::time::sleep(delay).await;
305 last_error = Some(err);
306 continue;
307 }
308
309 return Err(err);
310 }
311 Err(send_err) => {
312 let err = classify_transport_error(send_err);
313
314 if attempt < max_retries && err.is_retryable() {
315 let delay = retry_delay(attempt, &HeaderMap::new());
316 tracing::debug!(
317 attempt,
318 delay_ms = delay.as_millis() as u64,
319 "retrying after transport error"
320 );
321 tokio::time::sleep(delay).await;
322 last_error = Some(err);
323 continue;
324 }
325
326 return Err(err);
327 }
328 }
329 }
330
331 Err(last_error
334 .unwrap_or_else(|| OpencodeError::Http("max retries exhausted".to_owned().into())))
335 }
336
337 pub async fn get<T: DeserializeOwned>(
341 &self,
342 path: &str,
343 options: Option<&RequestOptions>,
344 ) -> Result<T, OpencodeError> {
345 self.make_request::<T, ()>(http::Method::GET, path, None, None, options).await
346 }
347
348 pub async fn get_with_query<T, Q>(
350 &self,
351 path: &str,
352 query: Option<&Q>,
353 options: Option<&RequestOptions>,
354 ) -> Result<T, OpencodeError>
355 where
356 T: DeserializeOwned,
357 Q: Serialize + Sync + ?Sized,
358 {
359 self.make_request(http::Method::GET, path, None, query, options).await
360 }
361
362 pub async fn post<T, B>(
364 &self,
365 path: &str,
366 body: Option<&B>,
367 options: Option<&RequestOptions>,
368 ) -> Result<T, OpencodeError>
369 where
370 T: DeserializeOwned,
371 B: Serialize + Sync,
372 {
373 let body_value = body.map(serde_json::to_value).transpose()?;
374 self.make_request::<T, ()>(http::Method::POST, path, body_value, None, options).await
375 }
376
377 pub async fn put<T, B>(
379 &self,
380 path: &str,
381 body: Option<&B>,
382 options: Option<&RequestOptions>,
383 ) -> Result<T, OpencodeError>
384 where
385 T: DeserializeOwned,
386 B: Serialize + Sync,
387 {
388 let body_value = body.map(serde_json::to_value).transpose()?;
389 self.make_request::<T, ()>(http::Method::PUT, path, body_value, None, options).await
390 }
391
392 pub async fn patch<T, B>(
394 &self,
395 path: &str,
396 body: Option<&B>,
397 options: Option<&RequestOptions>,
398 ) -> Result<T, OpencodeError>
399 where
400 T: DeserializeOwned,
401 B: Serialize + Sync,
402 {
403 let body_value = body.map(serde_json::to_value).transpose()?;
404 self.make_request::<T, ()>(http::Method::PATCH, path, body_value, None, options).await
405 }
406
407 pub async fn get_stream<T: DeserializeOwned + 'static>(
413 &self,
414 path: &str,
415 ) -> Result<crate::streaming::SseStream<T>, OpencodeError> {
416 let url = self.build_url(path, None);
417 let headers = self.build_headers(None, 0);
418
419 let response = self
420 .http_client
421 .get(&url)
422 .headers(headers)
423 .send()
424 .await
425 .map_err(classify_transport_error)?;
426
427 let status = response.status();
428 if !status.is_success() {
429 let resp_headers = response.headers().clone();
430 let body_bytes = response.bytes().await.ok();
431 let body_value: Option<serde_json::Value> =
432 body_bytes.as_ref().and_then(|b| serde_json::from_slice(b).ok());
433 return Err(OpencodeError::from_response(
434 status.as_u16(),
435 Some(resp_headers),
436 body_value,
437 ));
438 }
439
440 Ok(crate::streaming::SseStream::new(response.bytes_stream()))
441 }
442
443 pub async fn delete<T, B>(
445 &self,
446 path: &str,
447 body: Option<&B>,
448 options: Option<&RequestOptions>,
449 ) -> Result<T, OpencodeError>
450 where
451 T: DeserializeOwned,
452 B: Serialize + Sync,
453 {
454 let body_value = body.map(serde_json::to_value).transpose()?;
455 self.make_request::<T, ()>(http::Method::DELETE, path, body_value, None, options).await
456 }
457}
458
459fn should_retry(err: &OpencodeError, headers: &HeaderMap) -> bool {
463 if let Some(val) = headers.get("x-should-retry") &&
464 let Ok(s) = val.to_str()
465 {
466 match s {
467 "true" => return true,
468 "false" => return false,
469 _ => {}
470 }
471 }
472 err.is_retryable()
473}
474
475fn retry_delay(attempt: u32, headers: &HeaderMap) -> Duration {
480 if let Some(ms) = header_u64(headers, "retry-after-ms") {
482 return Duration::from_millis(ms);
483 }
484
485 if let Some(val) = headers.get("retry-after") &&
486 let Ok(s) = val.to_str() &&
487 let Ok(secs) = s.parse::<f64>()
488 {
489 return Duration::from_secs_f64(secs);
490 }
491
492 let base = (0.5 * 2.0_f64.powi(attempt.cast_signed())).min(8.0);
494 Duration::from_secs_f64(base * jitter_factor())
495}
496
497fn header_u64(headers: &HeaderMap, name: &str) -> Option<u64> {
499 headers.get(name)?.to_str().ok()?.parse().ok()
500}
501
502fn jitter_factor() -> f64 {
504 let nanos = std::time::SystemTime::now()
505 .duration_since(std::time::UNIX_EPOCH)
506 .unwrap_or_default()
507 .subsec_nanos();
508 (f64::from(nanos % 1000) / 1000.0).mul_add(-0.25, 1.0)
509}
510
511fn classify_transport_error(err: hpx::Error) -> OpencodeError {
513 if err.is_timeout() {
514 OpencodeError::Timeout
515 } else if err.is_connect() {
516 OpencodeError::Connection { message: err.to_string(), source: Some(Box::new(err)) }
517 } else {
518 OpencodeError::Http(Box::new(err))
519 }
520}
521
522#[derive(Debug)]
524pub struct OpencodeBuilder {
525 options: ClientOptions,
526}
527
528impl OpencodeBuilder {
529 #[must_use]
531 pub fn base_url(mut self, url: impl Into<String>) -> Self {
532 self.options.base_url = Some(url.into());
533 self
534 }
535
536 #[must_use]
538 pub const fn timeout(mut self, timeout: Duration) -> Self {
539 self.options.timeout = Some(timeout);
540 self
541 }
542
543 #[must_use]
545 pub const fn max_retries(mut self, retries: u32) -> Self {
546 self.options.max_retries = Some(retries);
547 self
548 }
549
550 #[must_use]
552 pub fn default_headers(mut self, headers: HeaderMap) -> Self {
553 self.options.default_headers = Some(headers);
554 self
555 }
556
557 #[must_use]
559 pub fn default_query(mut self, query: HashMap<String, String>) -> Self {
560 self.options.default_query = Some(query);
561 self
562 }
563
564 pub fn build(self) -> Result<Opencode, OpencodeError> {
571 Opencode::with_options(&self.options)
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use crate::config::{DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT};
579
580 fn test_client() -> Opencode {
584 Opencode::with_options(&ClientOptions::empty()).expect("test client")
585 }
586
587 fn test_client_with_defaults(
588 base: &str,
589 dq: HashMap<String, String>,
590 dh: HeaderMap,
591 ) -> Opencode {
592 Opencode::with_options(&ClientOptions {
593 base_url: Some(base.to_owned()),
594 timeout: None,
595 max_retries: None,
596 default_headers: Some(dh),
597 default_query: Some(dq),
598 })
599 .expect("test client")
600 }
601
602 #[test]
605 fn with_empty_options_uses_defaults() {
606 let client = Opencode::with_options(&ClientOptions::empty()).expect("client");
607 assert_eq!(client.base_url(), DEFAULT_BASE_URL);
608 assert_eq!(client.timeout(), DEFAULT_TIMEOUT);
609 assert_eq!(client.max_retries(), DEFAULT_MAX_RETRIES);
610 assert!(client.default_headers().is_empty());
611 assert!(client.default_query().is_empty());
612 }
613
614 #[test]
615 fn with_options_custom() {
616 let opts = ClientOptions {
617 base_url: Some("http://myhost:8080".to_owned()),
618 timeout: Some(Duration::from_secs(10)),
619 max_retries: Some(5),
620 default_headers: None,
621 default_query: None,
622 };
623 let client = Opencode::with_options(&opts).expect("client");
624 assert_eq!(client.base_url(), "http://myhost:8080");
625 assert_eq!(client.timeout(), Duration::from_secs(10));
626 assert_eq!(client.max_retries(), 5);
627 }
628
629 #[test]
630 fn builder_overrides() {
631 let client = Opencode::builder()
632 .base_url("http://builder:1234")
633 .timeout(Duration::from_secs(15))
634 .max_retries(0)
635 .build()
636 .expect("client");
637
638 assert_eq!(client.base_url(), "http://builder:1234");
639 assert_eq!(client.timeout(), Duration::from_secs(15));
640 assert_eq!(client.max_retries(), 0);
641 }
642
643 #[test]
644 fn builder_with_explicit_empty_falls_back() {
645 let client = Opencode::with_options(&ClientOptions::empty()).expect("client");
646 assert_eq!(client.base_url(), DEFAULT_BASE_URL);
647 assert_eq!(client.timeout(), DEFAULT_TIMEOUT);
648 assert_eq!(client.max_retries(), DEFAULT_MAX_RETRIES);
649 }
650
651 #[test]
652 fn builder_base_url_overrides_option() {
653 let client = Opencode::builder().base_url("http://explicit:2222").build().expect("client");
654 assert_eq!(client.base_url(), "http://explicit:2222");
655 }
656
657 #[test]
660 fn build_url_simple_path() {
661 let client = test_client();
662 let url = client.build_url("/app", None);
663 assert_eq!(url, format!("{DEFAULT_BASE_URL}/app"));
664 }
665
666 #[test]
667 fn build_url_strips_trailing_slash_from_base() {
668 let client =
669 test_client_with_defaults("http://example.com/", HashMap::new(), HeaderMap::new());
670 assert_eq!(client.build_url("/path", None), "http://example.com/path");
671 }
672
673 #[test]
674 fn build_url_adds_leading_slash() {
675 let client = test_client();
676 let url = client.build_url("session", None);
677 assert_eq!(url, format!("{DEFAULT_BASE_URL}/session"));
678 }
679
680 #[test]
681 fn build_url_with_default_query() {
682 let mut dq = HashMap::new();
683 dq.insert("version".to_owned(), "2".to_owned());
684 let client = test_client_with_defaults("http://host", dq, HeaderMap::new());
685
686 let url = client.build_url("/api", None);
687 assert_eq!(url, "http://host/api?version=2");
688 }
689
690 #[test]
691 fn build_url_with_extra_query() {
692 let client = test_client_with_defaults("http://host", HashMap::new(), HeaderMap::new());
693
694 let mut extra = HashMap::new();
695 extra.insert("foo".to_owned(), "bar".to_owned());
696
697 let url = client.build_url("/api", Some(&extra));
698 assert_eq!(url, "http://host/api?foo=bar");
699 }
700
701 #[test]
702 fn build_url_merges_default_and_extra_query() {
703 let mut dq = HashMap::new();
704 dq.insert("a".to_owned(), "1".to_owned());
705
706 let client = test_client_with_defaults("http://host", dq, HeaderMap::new());
707
708 let mut extra = HashMap::new();
709 extra.insert("b".to_owned(), "2".to_owned());
710
711 let url = client.build_url("/x", Some(&extra));
712 assert_eq!(url, "http://host/x?a=1&b=2");
714 }
715
716 #[test]
717 fn build_url_no_query_no_question_mark() {
718 let client = test_client();
719 let url = client.build_url("/clean", None);
720 assert!(!url.contains('?'));
721 }
722
723 #[test]
726 fn build_headers_sets_accept_json() {
727 let client = test_client();
728 let headers = client.build_headers(None, 0);
729 assert_eq!(
730 headers.get(http::header::ACCEPT).map(|v| v.to_str().ok()),
731 Some(Some("application/json"))
732 );
733 }
734
735 #[test]
736 fn build_headers_sets_user_agent() {
737 let client = test_client();
738 let headers = client.build_headers(None, 0);
739 let ua =
740 headers.get(http::header::USER_AGENT).expect("user-agent").to_str().expect("ascii");
741 assert!(ua.starts_with("opencode-sdk-rs/"), "unexpected user-agent: {ua}");
742 }
743
744 #[test]
745 fn build_headers_no_retry_count_on_first_attempt() {
746 let client = test_client();
747 let headers = client.build_headers(None, 0);
748 assert!(headers.get("x-retry-count").is_none());
749 }
750
751 #[test]
752 fn build_headers_includes_retry_count() {
753 let client = test_client();
754 let headers = client.build_headers(None, 3);
755 assert_eq!(headers.get("x-retry-count").map(|v| v.to_str().ok()), Some(Some("3")));
756 }
757
758 #[test]
759 fn build_headers_merges_extra() {
760 let client = test_client();
761 let mut extra = HeaderMap::new();
762 extra.insert("x-custom", HeaderValue::from_static("yes"));
763
764 let headers = client.build_headers(Some(&extra), 0);
765 assert_eq!(headers.get("x-custom").map(|v| v.to_str().ok()), Some(Some("yes")));
766 assert!(headers.get(http::header::ACCEPT).is_some());
768 }
769
770 #[test]
771 fn build_headers_includes_default_headers() {
772 let mut dh = HeaderMap::new();
773 dh.insert("x-default", HeaderValue::from_static("value"));
774
775 let client = test_client_with_defaults(DEFAULT_BASE_URL, HashMap::new(), dh);
776 let headers = client.build_headers(None, 0);
777 assert_eq!(headers.get("x-default").map(|v| v.to_str().ok()), Some(Some("value")));
778 }
779
780 #[test]
781 fn build_headers_extra_overrides_default() {
782 let mut dh = HeaderMap::new();
783 dh.insert("x-key", HeaderValue::from_static("default"));
784
785 let client = test_client_with_defaults(DEFAULT_BASE_URL, HashMap::new(), dh);
786
787 let mut extra = HeaderMap::new();
788 extra.insert("x-key", HeaderValue::from_static("override"));
789
790 let headers = client.build_headers(Some(&extra), 0);
791 assert_eq!(headers.get("x-key").map(|v| v.to_str().ok()), Some(Some("override")));
792 }
793
794 #[test]
797 fn should_retry_honours_x_should_retry_true() {
798 let err = OpencodeError::bad_request(None, None, "nope");
799 let mut headers = HeaderMap::new();
800 headers.insert("x-should-retry", HeaderValue::from_static("true"));
801 assert!(should_retry(&err, &headers));
802 }
803
804 #[test]
805 fn should_retry_honours_x_should_retry_false() {
806 let err = OpencodeError::internal_server(500, None, None, "fail");
807 let mut headers = HeaderMap::new();
808 headers.insert("x-should-retry", HeaderValue::from_static("false"));
809 assert!(!should_retry(&err, &headers));
810 }
811
812 #[test]
813 fn should_retry_falls_back_to_is_retryable() {
814 let retryable = OpencodeError::rate_limit(None, None, "slow down");
815 assert!(should_retry(&retryable, &HeaderMap::new()));
816
817 let not_retryable = OpencodeError::not_found(None, None, "gone");
818 assert!(!should_retry(¬_retryable, &HeaderMap::new()));
819 }
820
821 #[test]
824 fn retry_delay_uses_retry_after_ms() {
825 let mut headers = HeaderMap::new();
826 headers.insert("retry-after-ms", HeaderValue::from_static("1500"));
827 let delay = retry_delay(0, &headers);
828 assert_eq!(delay, Duration::from_millis(1500));
829 }
830
831 #[test]
832 fn retry_delay_uses_retry_after_seconds() {
833 let mut headers = HeaderMap::new();
834 headers.insert("retry-after", HeaderValue::from_static("2"));
835 let delay = retry_delay(0, &headers);
836 assert_eq!(delay, Duration::from_secs(2));
837 }
838
839 #[test]
840 fn retry_delay_exponential_backoff_attempt_0() {
841 let delay = retry_delay(0, &HeaderMap::new());
843 let secs = delay.as_secs_f64();
844 assert!((0.375..=0.5).contains(&secs), "attempt 0 delay {secs}s out of range");
845 }
846
847 #[test]
848 fn retry_delay_exponential_backoff_attempt_4() {
849 let delay = retry_delay(4, &HeaderMap::new());
851 let secs = delay.as_secs_f64();
852 assert!((6.0..=8.0).contains(&secs), "attempt 4 delay {secs}s out of range");
853 }
854
855 #[test]
856 fn retry_delay_caps_at_8_seconds() {
857 let delay = retry_delay(10, &HeaderMap::new());
859 let secs = delay.as_secs_f64();
860 assert!(secs <= 8.0, "delay {secs}s should be capped at 8");
861 }
862
863 #[test]
866 fn jitter_factor_in_range() {
867 for _ in 0..100 {
868 let j = jitter_factor();
869 assert!((0.75..=1.0).contains(&j), "jitter {j} out of [0.75, 1.0]");
870 }
871 }
872
873 #[test]
876 fn request_options_default_is_all_none() {
877 let opts = RequestOptions::default();
878 assert!(opts.extra_headers.is_none());
879 assert!(opts.timeout.is_none());
880 assert!(opts.max_retries.is_none());
881 }
882}