1use std::future::Future;
8use std::pin::pin;
9use std::sync::Mutex;
10
11use futures_util::future::{select, Either};
12use futures_util::stream::{FuturesUnordered, StreamExt};
13use web_time::{Duration, Instant};
14
15pub(crate) const LOCALHOST_GATEWAY: &str = "http://127.0.0.1:8080/";
16pub(crate) const DEFAULT_PUBLIC_GATEWAYS: [&str; 2] =
17 ["https://dweb.link/", "https://4everland.io/"];
18
19const DEFAULT_BASE_COOLDOWN: Duration = Duration::from_secs(5);
22const MAX_COOLDOWN: Duration = Duration::from_mins(5);
24const MAX_FIBONACCI_STEPS: u32 = 12;
26const HEDGE_DELAY: Duration = Duration::from_secs(2);
29const TOTAL_DEADLINE: Duration = Duration::from_secs(12);
31const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(6);
33
34#[derive(Default)]
36struct GatewayHealth {
37 consecutive_failures: u32,
38 blocked_until: Option<Instant>,
39}
40
41struct AttemptError {
44 detail: String,
45 penalise: bool,
46}
47
48impl AttemptError {
49 fn gateway(detail: String) -> Self {
50 Self {
51 detail,
52 penalise: true,
53 }
54 }
55
56 fn content(detail: String) -> Self {
57 Self {
58 detail,
59 penalise: false,
60 }
61 }
62}
63
64enum RaceStep<T> {
65 Completed(usize, Result<T, AttemptError>),
66 StartNext,
67}
68
69pub struct GatewayPool {
75 gateways: Vec<String>,
76 client: reqwest::Client,
77 base_cooldown: Duration,
78 request_timeout: Mutex<Option<Duration>>,
80 health: Mutex<Vec<GatewayHealth>>,
82}
83
84impl Default for GatewayPool {
85 fn default() -> Self {
87 let mut gateways = Vec::new();
88 push_gateway(&mut gateways, LOCALHOST_GATEWAY);
89 push_default_public_gateways(&mut gateways);
90 Self::from_gateway_list(gateways)
91 }
92}
93
94impl GatewayPool {
95 #[must_use]
97 pub fn public_default() -> Self {
98 let mut gateways = Vec::new();
99 push_default_public_gateways(&mut gateways);
100 Self::from_gateway_list(gateways)
101 }
102
103 #[must_use]
107 pub fn new(gateway_url: impl Into<String>) -> Self {
108 let mut gateways = Vec::new();
109 push_gateway(&mut gateways, &gateway_url.into());
110 push_default_public_gateways(&mut gateways);
111 Self::from_gateway_list(gateways)
112 }
113
114 #[must_use]
117 pub fn local_first(gateway_url: impl Into<String>) -> Self {
118 let mut gateways = Vec::new();
119 push_gateway(&mut gateways, LOCALHOST_GATEWAY);
120 push_gateway(&mut gateways, &gateway_url.into());
121 push_default_public_gateways(&mut gateways);
122 Self::from_gateway_list(gateways)
123 }
124
125 fn from_gateway_list(gateways: Vec<String>) -> Self {
126 #[cfg(not(target_arch = "wasm32"))]
127 let client = reqwest::Client::builder()
128 .connect_timeout(Duration::from_secs(2))
129 .build()
130 .unwrap_or_else(|_| reqwest::Client::new());
131
132 #[cfg(target_arch = "wasm32")]
133 let client = reqwest::Client::builder()
134 .build()
135 .unwrap_or_else(|_| reqwest::Client::new());
136
137 let health = gateways.iter().map(|_| GatewayHealth::default()).collect();
138 Self {
139 gateways,
140 client,
141 base_cooldown: DEFAULT_BASE_COOLDOWN,
142 request_timeout: Mutex::new(None),
143 health: Mutex::new(health),
144 }
145 }
146
147 #[must_use]
149 pub fn gateways(&self) -> &[String] {
150 &self.gateways
151 }
152
153 #[must_use]
157 pub fn with_base_cooldown(mut self, cooldown: Duration) -> Self {
158 self.base_cooldown = cooldown;
159 self
160 }
161
162 #[must_use]
166 pub fn with_request_timeout(self, timeout: Duration) -> Self {
167 self.set_request_timeout(Some(timeout));
168 self
169 }
170
171 pub fn set_request_timeout(&self, timeout: Option<Duration>) {
174 if let Ok(mut t) = self.request_timeout.lock() {
175 *t = timeout;
176 }
177 }
178
179 pub async fn fetch<T>(
188 &self,
189 path: &str,
190 accept: Option<&str>,
191 parse: impl Fn(&[u8]) -> std::result::Result<T, String>,
192 ) -> std::result::Result<T, String> {
193 let deadline = Instant::now() + TOTAL_DEADLINE;
194 let parse = &parse;
195 self.race_gateways(move |index| self.attempt_fetch(index, path, accept, parse, deadline))
196 .await
197 .map_err(|detail| format!("all gateways failed: {detail}"))
198 }
199
200 pub async fn fetch_bytes(
202 &self,
203 path: &str,
204 accept: Option<&str>,
205 ) -> std::result::Result<Vec<u8>, String> {
206 self.fetch(path, accept, |body| Ok(body.to_vec())).await
207 }
208
209 pub async fn resolve_ipns_path(&self, path: &str) -> crate::error::Result<String> {
216 if !path.starts_with("/ipns/") || path.len() <= "/ipns/".len() {
217 return Err(crate::error::Error::IpnsResolution {
218 path: path.to_string(),
219 detail: "expected a non-empty /ipns/<name> path".to_string(),
220 });
221 }
222
223 let deadline = Instant::now() + TOTAL_DEADLINE;
224 self.race_gateways(move |index| self.attempt_resolve(index, path, deadline))
225 .await
226 .map_err(|detail| crate::error::Error::IpnsResolution {
227 path: path.to_string(),
228 detail,
229 })
230 }
231
232 async fn race_gateways<T, F, Fut>(&self, attempt: F) -> std::result::Result<T, String>
237 where
238 F: Fn(usize) -> Fut,
239 Fut: Future<Output = (usize, std::result::Result<T, AttemptError>)>,
240 {
241 let mut errors = Vec::new();
242 let order = self.gateway_order(Instant::now(), &mut errors);
243
244 let mut in_flight = FuturesUnordered::new();
245 let mut candidates = order.into_iter();
246 let mut next_candidate = candidates.next();
247
248 loop {
249 if in_flight.is_empty() {
250 match next_candidate.take() {
251 Some(index) => {
252 in_flight.push(attempt(index));
253 next_candidate = candidates.next();
254 continue;
255 }
256 None => break,
257 }
258 }
259
260 let step = if next_candidate.is_some() {
261 let completion = in_flight.next();
262 match select(pin!(completion), pin!(hedge_sleep(HEDGE_DELAY))).await {
263 Either::Left((Some((index, result)), _)) => RaceStep::Completed(index, result),
264 Either::Left((None, _)) | Either::Right(_) => RaceStep::StartNext,
265 }
266 } else {
267 match in_flight.next().await {
268 Some((index, result)) => RaceStep::Completed(index, result),
269 None => break,
270 }
271 };
272
273 match step {
274 RaceStep::Completed(index, Ok(value)) => {
275 self.record_success(index);
276 return Ok(value);
277 }
278 RaceStep::Completed(index, Err(error)) => {
279 if error.penalise {
280 self.record_failure(index);
281 }
282 errors.push(error.detail);
283 if let Some(index) = next_candidate.take() {
284 in_flight.push(attempt(index));
285 next_candidate = candidates.next();
286 }
287 }
288 RaceStep::StartNext => {
289 if let Some(index) = next_candidate.take() {
290 in_flight.push(attempt(index));
291 next_candidate = candidates.next();
292 }
293 }
294 }
295 }
296
297 Err(errors.join(" | "))
298 }
299
300 async fn attempt_fetch<T, P>(
301 &self,
302 index: usize,
303 path: &str,
304 accept: Option<&str>,
305 parse: &P,
306 deadline: Instant,
307 ) -> (usize, std::result::Result<T, AttemptError>)
308 where
309 P: Fn(&[u8]) -> std::result::Result<T, String>,
310 {
311 let url = format!("{}{}", self.gateways[index], path.trim_start_matches('/'));
312 let Some(timeout) = self.remaining_timeout(deadline) else {
313 return (
314 index,
315 Err(AttemptError::content(format!(
316 "{url} -> skipped (deadline exceeded)"
317 ))),
318 );
319 };
320
321 let mut request = self.client.get(&url).timeout(timeout);
322 if let Some(accept) = accept {
323 request = request.header(reqwest::header::ACCEPT, accept);
324 }
325
326 let response = match request.send().await {
327 Ok(response) if response.status().is_success() => response,
328 Ok(response) => {
329 return (
330 index,
331 Err(AttemptError::gateway(format!(
332 "{url} -> HTTP {}",
333 response.status()
334 ))),
335 );
336 }
337 Err(error) => {
338 return (
339 index,
340 Err(AttemptError::gateway(format!("{url} -> {error}"))),
341 );
342 }
343 };
344
345 let body = match response.bytes().await {
346 Ok(body) => body,
347 Err(error) => {
348 return (
349 index,
350 Err(AttemptError::gateway(format!("{url} -> {error}"))),
351 );
352 }
353 };
354
355 match parse(&body) {
356 Ok(value) => (index, Ok(value)),
357 Err(detail) => (
358 index,
359 Err(AttemptError::content(format!("{url} -> {detail}"))),
360 ),
361 }
362 }
363
364 async fn attempt_resolve(
365 &self,
366 index: usize,
367 path: &str,
368 deadline: Instant,
369 ) -> (usize, std::result::Result<String, AttemptError>) {
370 let url = format!("{}{}", self.gateways[index], path.trim_start_matches('/'));
371 let Some(timeout) = self.remaining_timeout(deadline) else {
372 return (
373 index,
374 Err(AttemptError::content(format!(
375 "{url} -> skipped (deadline exceeded)"
376 ))),
377 );
378 };
379
380 let response = match self.client.head(&url).timeout(timeout).send().await {
381 Ok(response) if response.status().is_success() => response,
382 Ok(response) => {
383 return (
384 index,
385 Err(AttemptError::gateway(format!(
386 "{url} -> HTTP {}",
387 response.status()
388 ))),
389 );
390 }
391 Err(error) => {
392 return (
393 index,
394 Err(AttemptError::gateway(format!("{url} -> {error}"))),
395 );
396 }
397 };
398
399 let header_path = response
400 .headers()
401 .get("x-ipfs-path")
402 .and_then(|value| value.to_str().ok());
403 if let Some(resolved) = resolved_ipfs_path(header_path, response.url().path()) {
404 return (index, Ok(resolved));
405 }
406
407 (
408 index,
409 Err(AttemptError::content(format!(
410 "{url} -> gateway did not expose a resolved /ipfs path"
411 ))),
412 )
413 }
414
415 fn remaining_timeout(&self, deadline: Instant) -> Option<Duration> {
418 let remaining = deadline.saturating_duration_since(Instant::now());
419 if remaining.is_zero() {
420 return None;
421 }
422 let configured = self
423 .request_timeout
424 .lock()
425 .ok()
426 .and_then(|guard| *guard)
427 .unwrap_or(DEFAULT_REQUEST_TIMEOUT);
428 Some(configured.min(remaining))
429 }
430
431 fn gateway_order(&self, now: Instant, errors: &mut Vec<String>) -> Vec<usize> {
434 let Ok(health) = self.health.lock() else {
435 return (0..self.gateways.len()).collect();
436 };
437 let mut available = Vec::new();
438 let mut skipped = Vec::new();
439 for (index, entry) in health.iter().enumerate() {
440 if entry.blocked_until.is_none_or(|until| until <= now) {
441 available.push(index);
442 } else {
443 skipped.push(index);
444 }
445 }
446 if available.is_empty() {
447 return (0..self.gateways.len()).collect();
448 }
449 for index in skipped {
450 errors.push(format!("{} -> skipped (cooldown)", self.gateways[index]));
451 }
452 available
453 }
454
455 fn record_success(&self, index: usize) {
456 if let Ok(mut health) = self.health.lock() {
457 if let Some(entry) = health.get_mut(index) {
458 *entry = GatewayHealth::default();
459 }
460 }
461 }
462
463 fn record_failure(&self, index: usize) {
464 if let Ok(mut health) = self.health.lock() {
465 if let Some(entry) = health.get_mut(index) {
466 entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
467 let cooldown = fibonacci_cooldown(self.base_cooldown, entry.consecutive_failures);
468 entry.blocked_until = Some(Instant::now() + cooldown);
469 }
470 }
471 }
472}
473
474fn fibonacci_cooldown(base: Duration, consecutive_failures: u32) -> Duration {
476 let steps = consecutive_failures.clamp(1, MAX_FIBONACCI_STEPS);
477 let (mut previous, mut current) = (0u32, 1u32);
478 for _ in 1..steps {
479 let next = previous + current;
480 previous = current;
481 current = next;
482 }
483 base.saturating_mul(current).min(MAX_COOLDOWN)
484}
485
486async fn hedge_sleep(duration: Duration) {
488 #[cfg(not(target_arch = "wasm32"))]
489 tokio::time::sleep(duration).await;
490 #[cfg(target_arch = "wasm32")]
491 gloo_timers::future::TimeoutFuture::new(
492 u32::try_from(duration.as_millis()).unwrap_or(u32::MAX),
493 )
494 .await;
495}
496
497fn normalize_gateway_url(input: &str) -> String {
498 let mut url = input.trim().to_string();
499 if !url.ends_with('/') {
500 url.push('/');
501 }
502 url
503}
504
505fn push_gateway(gateways: &mut Vec<String>, candidate: &str) {
506 let normalized = normalize_gateway_url(candidate);
507 if !gateways.iter().any(|g| g.eq_ignore_ascii_case(&normalized)) {
508 gateways.push(normalized);
509 }
510}
511
512fn push_default_public_gateways(gateways: &mut Vec<String>) {
513 for fallback in DEFAULT_PUBLIC_GATEWAYS {
514 push_gateway(gateways, fallback);
515 }
516}
517
518fn resolved_ipfs_path(header_path: Option<&str>, final_path: &str) -> Option<String> {
519 header_path
520 .into_iter()
521 .chain(std::iter::once(final_path))
522 .find_map(|path| {
523 path.strip_prefix("/ipfs/")
524 .map(|cid| format!("/ipfs/{cid}"))
525 })
526}
527
528#[cfg(test)]
529mod tests {
530 use super::{
531 fibonacci_cooldown, normalize_gateway_url, push_gateway, resolved_ipfs_path, GatewayPool,
532 MAX_COOLDOWN,
533 };
534 use web_time::{Duration, Instant};
535
536 #[test]
537 fn resolved_ipfs_path_prefers_gateway_header() {
538 assert_eq!(
539 resolved_ipfs_path(Some("/ipfs/bafyheader"), "/ipfs/bafyredirect"),
540 Some("/ipfs/bafyheader".to_string())
541 );
542 assert_eq!(
543 resolved_ipfs_path(None, "/ipfs/bafyredirect"),
544 Some("/ipfs/bafyredirect".to_string())
545 );
546 assert_eq!(resolved_ipfs_path(None, "/ipns/k51name"), None);
547 }
548
549 #[test]
550 fn normalize_gateway_url_adds_missing_trailing_slash() {
551 assert_eq!(
552 normalize_gateway_url("https://dweb.link"),
553 "https://dweb.link/"
554 );
555 assert_eq!(
556 normalize_gateway_url("https://dweb.link/"),
557 "https://dweb.link/"
558 );
559 assert_eq!(
560 normalize_gateway_url(" https://dweb.link "),
561 "https://dweb.link/"
562 );
563 }
564
565 #[test]
566 fn push_gateway_deduplicates_case_insensitively() {
567 let mut gateways = Vec::new();
568 push_gateway(&mut gateways, "https://dweb.link/");
569 push_gateway(&mut gateways, "https://dweb.link/"); push_gateway(&mut gateways, "https://dweb.link"); assert_eq!(gateways.len(), 1, "duplicates must not be added");
572 }
573
574 #[test]
575 fn default_is_local_first() {
576 let pool = GatewayPool::default();
577 assert_eq!(
578 pool.gateways(),
579 [
580 "http://127.0.0.1:8080/".to_string(),
581 "https://dweb.link/".to_string(),
582 "https://4everland.io/".to_string(),
583 ]
584 );
585 }
586
587 #[test]
588 fn public_default_never_includes_localhost() {
589 let pool = GatewayPool::public_default();
590 assert_eq!(
591 pool.gateways(),
592 [
593 "https://dweb.link/".to_string(),
594 "https://4everland.io/".to_string(),
595 ]
596 );
597 }
598
599 #[test]
600 fn new_uses_primary_then_public_fallbacks_without_hidden_localhost() {
601 let pool = GatewayPool::new("https://example.test/ipfs");
602 assert_eq!(
603 pool.gateways(),
604 [
605 "https://example.test/ipfs/".to_string(),
606 "https://dweb.link/".to_string(),
607 "https://4everland.io/".to_string(),
608 ]
609 );
610 }
611
612 #[test]
613 fn local_first_puts_localhost_before_primary() {
614 let pool = GatewayPool::local_first("https://example.test/");
615 assert_eq!(
616 pool.gateways(),
617 [
618 "http://127.0.0.1:8080/".to_string(),
619 "https://example.test/".to_string(),
620 "https://dweb.link/".to_string(),
621 "https://4everland.io/".to_string(),
622 ]
623 );
624 }
625
626 #[test]
627 fn fibonacci_cooldown_escalates_and_caps() {
628 let base = Duration::from_secs(5);
629 assert_eq!(fibonacci_cooldown(base, 1), Duration::from_secs(5));
630 assert_eq!(fibonacci_cooldown(base, 2), Duration::from_secs(5));
631 assert_eq!(fibonacci_cooldown(base, 3), Duration::from_secs(10));
632 assert_eq!(fibonacci_cooldown(base, 4), Duration::from_secs(15));
633 assert_eq!(fibonacci_cooldown(base, 5), Duration::from_secs(25));
634 assert_eq!(fibonacci_cooldown(base, 6), Duration::from_secs(40));
635 assert_eq!(fibonacci_cooldown(base, 100), MAX_COOLDOWN);
636 assert_eq!(fibonacci_cooldown(Duration::ZERO, 7), Duration::ZERO);
637 }
638
639 #[test]
640 fn record_failure_blocks_gateway_and_success_clears_it() {
641 let pool = GatewayPool::default();
642 let mut errors = Vec::new();
643
644 assert_eq!(
645 pool.gateway_order(Instant::now(), &mut errors),
646 vec![0, 1, 2],
647 "all gateways available initially"
648 );
649
650 pool.record_failure(0);
651 errors.clear();
652 assert_eq!(
653 pool.gateway_order(Instant::now(), &mut errors),
654 vec![1, 2],
655 "failed gateway must be skipped during cooldown"
656 );
657 assert_eq!(errors.len(), 1);
658 assert!(errors[0].contains("skipped (cooldown)"));
659
660 pool.record_success(0);
661 errors.clear();
662 assert_eq!(
663 pool.gateway_order(Instant::now(), &mut errors),
664 vec![0, 1, 2],
665 "success must clear the cooldown"
666 );
667 assert!(errors.is_empty());
668 }
669
670 #[test]
671 fn gateway_order_falls_back_to_all_when_everything_is_blocked() {
672 let pool = GatewayPool::default();
673 for index in 0..pool.gateways().len() {
674 pool.record_failure(index);
675 }
676 let mut errors = Vec::new();
677 assert_eq!(
678 pool.gateway_order(Instant::now(), &mut errors),
679 vec![0, 1, 2],
680 "a fetch must never dead-end on cooldowns alone"
681 );
682 assert!(errors.is_empty());
683 }
684
685 #[test]
686 fn zero_base_cooldown_disables_blocking() {
687 let pool = GatewayPool::default().with_base_cooldown(Duration::ZERO);
688 pool.record_failure(0);
689 let mut errors = Vec::new();
690 assert_eq!(
691 pool.gateway_order(Instant::now(), &mut errors),
692 vec![0, 1, 2],
693 "zero base cooldown must never block a gateway"
694 );
695 }
696}