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(format!(
298 "all {} gateways failed: {}",
299 errors.len(),
300 errors.join("; ")
301 ))
302 }
303
304 async fn attempt_fetch<T, P>(
305 &self,
306 index: usize,
307 path: &str,
308 accept: Option<&str>,
309 parse: &P,
310 deadline: Instant,
311 ) -> (usize, std::result::Result<T, AttemptError>)
312 where
313 P: Fn(&[u8]) -> std::result::Result<T, String>,
314 {
315 let url = format!("{}{}", self.gateways[index], path.trim_start_matches('/'));
316 let Some(timeout) = self.remaining_timeout(deadline) else {
317 return (
318 index,
319 Err(AttemptError::content(format!(
320 "{url} -> skipped (deadline exceeded)"
321 ))),
322 );
323 };
324
325 let mut request = self.client.get(&url).timeout(timeout);
326 if let Some(accept) = accept {
327 request = request.header(reqwest::header::ACCEPT, accept);
328 }
329
330 let response = match request.send().await {
331 Ok(response) if response.status().is_success() => response,
332 Ok(response) => {
333 return (
334 index,
335 Err(AttemptError::gateway(format!(
336 "{url} -> HTTP {}",
337 response.status()
338 ))),
339 );
340 }
341 Err(error) => {
342 return (
343 index,
344 Err(AttemptError::gateway(format!("{url} -> {error}"))),
345 );
346 }
347 };
348
349 let body = match response.bytes().await {
350 Ok(body) => body,
351 Err(error) => {
352 return (
353 index,
354 Err(AttemptError::gateway(format!("{url} -> {error}"))),
355 );
356 }
357 };
358
359 match parse(&body) {
360 Ok(value) => (index, Ok(value)),
361 Err(detail) => (
362 index,
363 Err(AttemptError::content(format!("{url} -> {detail}"))),
364 ),
365 }
366 }
367
368 async fn attempt_resolve(
369 &self,
370 index: usize,
371 path: &str,
372 deadline: Instant,
373 ) -> (usize, std::result::Result<String, AttemptError>) {
374 let url = format!("{}{}", self.gateways[index], path.trim_start_matches('/'));
375 let Some(timeout) = self.remaining_timeout(deadline) else {
376 return (
377 index,
378 Err(AttemptError::content(format!(
379 "{url} -> skipped (deadline exceeded)"
380 ))),
381 );
382 };
383
384 let response = match self.client.head(&url).timeout(timeout).send().await {
385 Ok(response) if response.status().is_success() => response,
386 Ok(response) => {
387 return (
388 index,
389 Err(AttemptError::gateway(format!(
390 "{url} -> HTTP {}",
391 response.status()
392 ))),
393 );
394 }
395 Err(error) => {
396 return (
397 index,
398 Err(AttemptError::gateway(format!("{url} -> {error}"))),
399 );
400 }
401 };
402
403 let header_path = response
404 .headers()
405 .get("x-ipfs-path")
406 .and_then(|value| value.to_str().ok());
407 if let Some(resolved) = resolved_ipfs_path(header_path, response.url().path()) {
408 return (index, Ok(resolved));
409 }
410
411 (
412 index,
413 Err(AttemptError::content(format!(
414 "{url} -> gateway did not expose a resolved /ipfs path"
415 ))),
416 )
417 }
418
419 fn remaining_timeout(&self, deadline: Instant) -> Option<Duration> {
422 let remaining = deadline.saturating_duration_since(Instant::now());
423 if remaining.is_zero() {
424 return None;
425 }
426 let configured = self
427 .request_timeout
428 .lock()
429 .ok()
430 .and_then(|guard| *guard)
431 .unwrap_or(DEFAULT_REQUEST_TIMEOUT);
432 Some(configured.min(remaining))
433 }
434
435 fn gateway_order(&self, now: Instant, errors: &mut Vec<String>) -> Vec<usize> {
438 let Ok(health) = self.health.lock() else {
439 return (0..self.gateways.len()).collect();
440 };
441 let mut available = Vec::new();
442 let mut skipped = Vec::new();
443 for (index, entry) in health.iter().enumerate() {
444 if entry.blocked_until.is_none_or(|until| until <= now) {
445 available.push(index);
446 } else {
447 skipped.push(index);
448 }
449 }
450 if available.is_empty() {
451 return (0..self.gateways.len()).collect();
452 }
453 for index in skipped {
454 errors.push(format!("{} -> skipped (cooldown)", self.gateways[index]));
455 }
456 available
457 }
458
459 fn record_success(&self, index: usize) {
460 if let Ok(mut health) = self.health.lock() {
461 if let Some(entry) = health.get_mut(index) {
462 *entry = GatewayHealth::default();
463 }
464 }
465 }
466
467 fn record_failure(&self, index: usize) {
468 if let Ok(mut health) = self.health.lock() {
469 if let Some(entry) = health.get_mut(index) {
470 entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
471 let cooldown = fibonacci_cooldown(self.base_cooldown, entry.consecutive_failures);
472 entry.blocked_until = Some(Instant::now() + cooldown);
473 }
474 }
475 }
476}
477
478fn fibonacci_cooldown(base: Duration, consecutive_failures: u32) -> Duration {
480 let steps = consecutive_failures.clamp(1, MAX_FIBONACCI_STEPS);
481 let (mut previous, mut current) = (0u32, 1u32);
482 for _ in 1..steps {
483 let next = previous + current;
484 previous = current;
485 current = next;
486 }
487 base.saturating_mul(current).min(MAX_COOLDOWN)
488}
489
490async fn hedge_sleep(duration: Duration) {
492 #[cfg(not(target_arch = "wasm32"))]
493 tokio::time::sleep(duration).await;
494 #[cfg(target_arch = "wasm32")]
495 gloo_timers::future::TimeoutFuture::new(
496 u32::try_from(duration.as_millis()).unwrap_or(u32::MAX),
497 )
498 .await;
499}
500
501fn normalize_gateway_url(input: &str) -> String {
502 let mut url = input.trim().to_string();
503 if !url.ends_with('/') {
504 url.push('/');
505 }
506 url
507}
508
509fn push_gateway(gateways: &mut Vec<String>, candidate: &str) {
510 let normalized = normalize_gateway_url(candidate);
511 if !gateways.iter().any(|g| g.eq_ignore_ascii_case(&normalized)) {
512 gateways.push(normalized);
513 }
514}
515
516fn push_default_public_gateways(gateways: &mut Vec<String>) {
517 for fallback in DEFAULT_PUBLIC_GATEWAYS {
518 push_gateway(gateways, fallback);
519 }
520}
521
522fn resolved_ipfs_path(header_path: Option<&str>, final_path: &str) -> Option<String> {
523 header_path
524 .into_iter()
525 .chain(std::iter::once(final_path))
526 .find_map(|path| {
527 path.strip_prefix("/ipfs/")
528 .map(|cid| format!("/ipfs/{cid}"))
529 })
530}
531
532#[cfg(test)]
533mod tests {
534 use super::{
535 fibonacci_cooldown, normalize_gateway_url, push_gateway, resolved_ipfs_path, GatewayPool,
536 MAX_COOLDOWN,
537 };
538 use web_time::{Duration, Instant};
539
540 #[test]
541 fn resolved_ipfs_path_prefers_gateway_header() {
542 assert_eq!(
543 resolved_ipfs_path(Some("/ipfs/bafyheader"), "/ipfs/bafyredirect"),
544 Some("/ipfs/bafyheader".to_string())
545 );
546 assert_eq!(
547 resolved_ipfs_path(None, "/ipfs/bafyredirect"),
548 Some("/ipfs/bafyredirect".to_string())
549 );
550 assert_eq!(resolved_ipfs_path(None, "/ipns/k51name"), None);
551 }
552
553 #[test]
554 fn normalize_gateway_url_adds_missing_trailing_slash() {
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 assert_eq!(
564 normalize_gateway_url(" https://dweb.link "),
565 "https://dweb.link/"
566 );
567 }
568
569 #[test]
570 fn push_gateway_deduplicates_case_insensitively() {
571 let mut gateways = Vec::new();
572 push_gateway(&mut gateways, "https://dweb.link/");
573 push_gateway(&mut gateways, "https://dweb.link/"); push_gateway(&mut gateways, "https://dweb.link"); assert_eq!(gateways.len(), 1, "duplicates must not be added");
576 }
577
578 #[test]
579 fn default_is_local_first() {
580 let pool = GatewayPool::default();
581 assert_eq!(
582 pool.gateways(),
583 [
584 "http://127.0.0.1:8080/".to_string(),
585 "https://dweb.link/".to_string(),
586 "https://4everland.io/".to_string(),
587 ]
588 );
589 }
590
591 #[test]
592 fn public_default_never_includes_localhost() {
593 let pool = GatewayPool::public_default();
594 assert_eq!(
595 pool.gateways(),
596 [
597 "https://dweb.link/".to_string(),
598 "https://4everland.io/".to_string(),
599 ]
600 );
601 }
602
603 #[test]
604 fn new_uses_primary_then_public_fallbacks_without_hidden_localhost() {
605 let pool = GatewayPool::new("https://example.test/ipfs");
606 assert_eq!(
607 pool.gateways(),
608 [
609 "https://example.test/ipfs/".to_string(),
610 "https://dweb.link/".to_string(),
611 "https://4everland.io/".to_string(),
612 ]
613 );
614 }
615
616 #[test]
617 fn local_first_puts_localhost_before_primary() {
618 let pool = GatewayPool::local_first("https://example.test/");
619 assert_eq!(
620 pool.gateways(),
621 [
622 "http://127.0.0.1:8080/".to_string(),
623 "https://example.test/".to_string(),
624 "https://dweb.link/".to_string(),
625 "https://4everland.io/".to_string(),
626 ]
627 );
628 }
629
630 #[test]
631 fn fibonacci_cooldown_escalates_and_caps() {
632 let base = Duration::from_secs(5);
633 assert_eq!(fibonacci_cooldown(base, 1), Duration::from_secs(5));
634 assert_eq!(fibonacci_cooldown(base, 2), Duration::from_secs(5));
635 assert_eq!(fibonacci_cooldown(base, 3), Duration::from_secs(10));
636 assert_eq!(fibonacci_cooldown(base, 4), Duration::from_secs(15));
637 assert_eq!(fibonacci_cooldown(base, 5), Duration::from_secs(25));
638 assert_eq!(fibonacci_cooldown(base, 6), Duration::from_secs(40));
639 assert_eq!(fibonacci_cooldown(base, 100), MAX_COOLDOWN);
640 assert_eq!(fibonacci_cooldown(Duration::ZERO, 7), Duration::ZERO);
641 }
642
643 #[test]
644 fn record_failure_blocks_gateway_and_success_clears_it() {
645 let pool = GatewayPool::default();
646 let mut errors = Vec::new();
647
648 assert_eq!(
649 pool.gateway_order(Instant::now(), &mut errors),
650 vec![0, 1, 2],
651 "all gateways available initially"
652 );
653
654 pool.record_failure(0);
655 errors.clear();
656 assert_eq!(
657 pool.gateway_order(Instant::now(), &mut errors),
658 vec![1, 2],
659 "failed gateway must be skipped during cooldown"
660 );
661 assert_eq!(errors.len(), 1);
662 assert!(errors[0].contains("skipped (cooldown)"));
663
664 pool.record_success(0);
665 errors.clear();
666 assert_eq!(
667 pool.gateway_order(Instant::now(), &mut errors),
668 vec![0, 1, 2],
669 "success must clear the cooldown"
670 );
671 assert!(errors.is_empty());
672 }
673
674 #[test]
675 fn gateway_order_falls_back_to_all_when_everything_is_blocked() {
676 let pool = GatewayPool::default();
677 for index in 0..pool.gateways().len() {
678 pool.record_failure(index);
679 }
680 let mut errors = Vec::new();
681 assert_eq!(
682 pool.gateway_order(Instant::now(), &mut errors),
683 vec![0, 1, 2],
684 "a fetch must never dead-end on cooldowns alone"
685 );
686 assert!(errors.is_empty());
687 }
688
689 #[test]
690 fn zero_base_cooldown_disables_blocking() {
691 let pool = GatewayPool::default().with_base_cooldown(Duration::ZERO);
692 pool.record_failure(0);
693 let mut errors = Vec::new();
694 assert_eq!(
695 pool.gateway_order(Instant::now(), &mut errors),
696 vec![0, 1, 2],
697 "zero base cooldown must never block a gateway"
698 );
699 }
700}