Skip to main content

ma_core/ipfs/
gateway.rs

1//! HTTP gateway pool for read-only IPFS/IPNS access.
2//!
3//! [`GatewayPool`] owns the gateway list, hedged failover, per-gateway
4//! cooldowns and request timeouts. It knows nothing about DID documents,
5//! caching, or publishing — higher layers compose it.
6
7use 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
19/// Base cooldown after a gateway failure. Escalates Fibonacci-style
20/// (base × 1, 1, 2, 3, 5, …) per consecutive failure; resets on success.
21const DEFAULT_BASE_COOLDOWN: Duration = Duration::from_secs(5);
22/// Upper bound for the escalated per-gateway cooldown.
23const MAX_COOLDOWN: Duration = Duration::from_mins(5);
24/// Fibonacci escalation stops growing after this many failures (fib(12) = 144).
25const MAX_FIBONACCI_STEPS: u32 = 12;
26/// How long a pending attempt may stay unanswered before the next gateway
27/// is raced against it.
28const HEDGE_DELAY: Duration = Duration::from_secs(2);
29/// Hard deadline for one whole fetch/resolve across all gateways.
30const TOTAL_DEADLINE: Duration = Duration::from_secs(12);
31/// Default per-request timeout, covering connection and body transfer.
32const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(6);
33
34/// Per-gateway failure tracking for cooldown escalation.
35#[derive(Default)]
36struct GatewayHealth {
37    consecutive_failures: u32,
38    blocked_until: Option<Instant>,
39}
40
41/// Failed attempt outcome. `penalise` is false for content-level rejections
42/// (parse failures, deadline skips) that say nothing about gateway health.
43struct 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
69/// Ordered pool of IPFS HTTP gateways with hedged failover and per-gateway
70/// Fibonacci cooldowns.
71///
72/// Read-only: fetches content and resolves IPNS paths. Publishing, pinning
73/// and key management belong to the Kubo client (`crate::kubo`), never here.
74pub struct GatewayPool {
75    gateways: Vec<String>,
76    client: reqwest::Client,
77    base_cooldown: Duration,
78    /// Per-request timeout. `None` → the built-in default.
79    request_timeout: Mutex<Option<Duration>>,
80    /// Parallel to `gateways`.
81    health: Mutex<Vec<GatewayHealth>>,
82}
83
84impl Default for GatewayPool {
85    /// Build a local-first pool for development and native runtimes.
86    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    /// Build a public-gateway pool with no localhost probing.
96    #[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    /// Build a pool using the caller-provided primary gateway followed by
104    /// the standard public fallbacks. Localhost is used only if `gateway_url`
105    /// itself points at localhost.
106    #[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    /// Build a local-first pool: localhost, then the caller-provided primary
115    /// gateway, then the standard public fallbacks.
116    #[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    /// The ordered gateway URLs this pool will try.
148    #[must_use]
149    pub fn gateways(&self) -> &[String] {
150        &self.gateways
151    }
152
153    /// Override the base cooldown applied after a gateway failure.
154    /// The cooldown escalates Fibonacci-style per consecutive failure and
155    /// resets on success. `Duration::ZERO` disables cooldowns entirely.
156    #[must_use]
157    pub fn with_base_cooldown(mut self, cooldown: Duration) -> Self {
158        self.base_cooldown = cooldown;
159        self
160    }
161
162    /// Override the per-request timeout (default 6 seconds). The timeout
163    /// covers the whole request, connection through body transfer, and is
164    /// clamped to the time remaining before the operation deadline.
165    #[must_use]
166    pub fn with_request_timeout(self, timeout: Duration) -> Self {
167        self.set_request_timeout(Some(timeout));
168        self
169    }
170
171    /// Update the per-request timeout at runtime.
172    /// Pass `None` to revert to the 6-second built-in default.
173    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    /// GET `path` from the gateway pool until `parse` accepts a body.
180    ///
181    /// Gateways are tried in cooldown-filtered order, hedged: a pending
182    /// attempt that has not answered within [`HEDGE_DELAY`] gets the next
183    /// gateway raced against it and the first parsed success wins. Failing
184    /// gateways earn an escalating Fibonacci cooldown; the whole operation
185    /// is bounded by [`TOTAL_DEADLINE`]. On total failure the per-gateway
186    /// errors are joined into one string for the caller to wrap.
187    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    /// Fetch raw bytes for `path` (e.g. `/ipfs/<cid>`) with gateway failover.
201    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    /// Resolve an `/ipns/<name>` reference to its current `/ipfs/<cid>` path.
210    ///
211    /// Reads only gateway response metadata, never the referenced content
212    /// body. Gateways commonly expose the resolved content path in
213    /// `X-Ipfs-Path`; redirects to `/ipfs/...` are accepted as a fallback.
214    /// Uses the same hedged failover and cooldowns as [`GatewayPool::fetch`].
215    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    /// Run `attempt` against the gateways with staggered hedging: the first
233    /// candidate starts immediately, and while any attempt is pending the
234    /// next candidate joins the race after [`HEDGE_DELAY`] or as soon as an
235    /// attempt fails. First success wins; losers are dropped (cancelled).
236    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    /// Per-request timeout clamped to the time left before `deadline`.
416    /// `None` when the deadline has already passed.
417    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    /// Gateway indices to try, skipping those in cooldown. If every gateway
432    /// is in cooldown, all are returned so a fetch never dead-ends.
433    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
474/// `base × fib(n)` capped at [`MAX_COOLDOWN`]: base × 1, 1, 2, 3, 5, 8, …
475fn 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
486/// Wasm-safe sleep used only to stagger hedged gateway attempts.
487async 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/"); // exact duplicate
570        push_gateway(&mut gateways, "https://dweb.link"); // no trailing slash
571        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}