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(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    /// Per-request timeout clamped to the time left before `deadline`.
420    /// `None` when the deadline has already passed.
421    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    /// Gateway indices to try, skipping those in cooldown. If every gateway
436    /// is in cooldown, all are returned so a fetch never dead-ends.
437    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
478/// `base × fib(n)` capped at [`MAX_COOLDOWN`]: base × 1, 1, 2, 3, 5, 8, …
479fn 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
490/// Wasm-safe sleep used only to stagger hedged gateway attempts.
491async 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/"); // exact duplicate
574        push_gateway(&mut gateways, "https://dweb.link"); // no trailing slash
575        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}