• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

0xmichalis / nftbk / 19701776817

26 Nov 2025 11:13AM UTC coverage: 54.375% (-0.1%) from 54.519%
19701776817

push

github

0xmichalis
chore: reduce default content retries

3250 of 5977 relevant lines covered (54.38%)

10.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

57.52
/src/httpclient/fetch.rs
1
use reqwest::{header::CONTENT_LENGTH, Method};
2
use tracing::{info, warn};
3

4
use crate::ipfs::config::IpfsGatewayConfig;
5
use crate::ipfs::url::{generate_url_for_gateways, GatewayUrl};
6

7
async fn send_request(
42✔
8
    method: Method,
9
    url: &str,
10
    bearer_token: Option<String>,
11
) -> anyhow::Result<reqwest::Response> {
12
    let client = reqwest::Client::builder()
84✔
13
        .user_agent(crate::USER_AGENT)
42✔
14
        .build()?;
15

16
    let mut req = client.request(method, url);
210✔
17
    if let Some(token) = bearer_token {
46✔
18
        req = req.bearer_auth(token);
6✔
19
    }
20

21
    Ok(req.send().await?)
88✔
22
}
23

24
pub(crate) async fn fetch_url(
30✔
25
    url: &str,
26
    bearer_token: Option<String>,
27
) -> anyhow::Result<reqwest::Response> {
28
    send_request(Method::GET, url, bearer_token).await
120✔
29
}
30

31
pub(crate) async fn head_url(
2✔
32
    url: &str,
33
    bearer_token: Option<String>,
34
) -> anyhow::Result<reqwest::Response> {
35
    send_request(Method::HEAD, url, bearer_token).await
8✔
36
}
37

38
pub(crate) fn create_http_error(status: reqwest::StatusCode, url: &str) -> anyhow::Error {
14✔
39
    anyhow::anyhow!("HTTP error: status {status} from {url}")
28✔
40
}
41

42
pub(crate) async fn try_fetch_response(
23✔
43
    url: &str,
44
    gateways: &[IpfsGatewayConfig],
45
) -> (
46
    anyhow::Result<reqwest::Response>,
47
    Option<reqwest::StatusCode>,
48
) {
49
    match fetch_url(url, None).await {
92✔
50
        Ok(response) => {
21✔
51
            let status = response.status();
63✔
52
            if status.is_success() {
42✔
53
                (Ok(response), Some(status))
8✔
54
            } else {
55
                let error = create_http_error(status, url);
52✔
56
                match retry_with_gateways(url, error, gateways).await {
65✔
57
                    Ok(alt_response) => {
1✔
58
                        let alt_status = alt_response.status();
3✔
59
                        if alt_status.is_success() {
2✔
60
                            (Ok(alt_response), Some(alt_status))
1✔
61
                        } else {
62
                            (
63
                                Err(create_http_error(alt_status, alt_response.url().as_str())),
×
64
                                Some(alt_status),
×
65
                            )
66
                        }
67
                    }
68
                    Err(gateway_err) => (Err(gateway_err), Some(status)),
24✔
69
                }
70
            }
71
        }
72
        Err(e) => match retry_with_gateways(url, e, gateways).await {
12✔
73
            Ok(response) => {
1✔
74
                let status = response.status();
3✔
75
                if status.is_success() {
2✔
76
                    (Ok(response), Some(status))
1✔
77
                } else {
78
                    (Err(create_http_error(status, url)), Some(status))
×
79
                }
80
            }
81
            Err(gateway_err) => (Err(gateway_err), None),
2✔
82
        },
83
    }
84
}
85

86
/// Retry a failed HTTP request against configured IPFS gateways using the
87
/// provided HTTP method so both GET and HEAD flows can share the same logic.
88
/// The original error is logged for context and returned if no gateway
89
/// succeeds.
90
async fn retry_with_gateways_with_method(
21✔
91
    url: &str,
92
    original_error: anyhow::Error,
93
    gateways: &[IpfsGatewayConfig],
94
    method: Method,
95
) -> anyhow::Result<reqwest::Response> {
96
    let gateway_urls = match generate_url_for_gateways(url, gateways) {
51✔
97
        Some(urls) => {
9✔
98
            warn!(
9✔
99
                "IPFS gateway error for {}, retrying with other gateways: {}",
×
100
                url, original_error
101
            );
102
            urls
9✔
103
        }
104
        None => return Err(original_error),
12✔
105
    };
106

107
    let mut last_err = original_error;
18✔
108
    let alternative_gateways: Vec<_> = gateway_urls
27✔
109
        .into_iter()
110
        .filter(|gateway_url| gateway_url.url != url)
45✔
111
        .collect();
112

113
    for GatewayUrl {
114
        url: new_url,
10✔
115
        bearer_token,
10✔
116
    } in alternative_gateways
13✔
117
    {
118
        match send_request(method.clone(), &new_url, bearer_token).await {
60✔
119
            Ok(response) => {
9✔
120
                let status = response.status();
27✔
121
                if status.is_success() {
18✔
122
                    info!(
6✔
123
                        "Received successful response from alternative IPFS gateway: {} (status: {})",
×
124
                        new_url, status
125
                    );
126
                    return Ok(response);
6✔
127
                } else {
128
                    warn!("Alternative IPFS gateway {new_url} failed: {status}");
3✔
129
                    last_err = anyhow::anyhow!("{status}");
9✔
130
                }
131
            }
132
            Err(err) => {
1✔
133
                warn!("Failed to fetch from IPFS gateway {}: {}", new_url, err);
1✔
134
                last_err = err;
2✔
135
            }
136
        }
137
    }
138

139
    Err(last_err)
3✔
140
}
141

142
pub(crate) async fn retry_with_gateways(
20✔
143
    url: &str,
144
    original_error: anyhow::Error,
145
    gateways: &[IpfsGatewayConfig],
146
) -> anyhow::Result<reqwest::Response> {
147
    retry_with_gateways_with_method(url, original_error, gateways, Method::GET).await
100✔
148
}
149

150
async fn try_get_content_length(
3✔
151
    url: &str,
152
    gateways: &[IpfsGatewayConfig],
153
) -> (anyhow::Result<u64>, Option<reqwest::StatusCode>) {
154
    info!(
3✔
155
        "HEAD denied for {}; falling back to GET for size estimation",
×
156
        url
157
    );
158
    match fetch_url(url, None).await {
12✔
159
        Ok(response) => {
3✔
160
            let status = response.status();
9✔
161
            if status.is_success() {
6✔
162
                if let Some(len) = extract_content_length(&response) {
4✔
163
                    (Ok(len), Some(status))
2✔
164
                } else {
165
                    (
166
                        Err(anyhow::anyhow!(
×
167
                            "GET response missing Content-Length for {}",
×
168
                            url
×
169
                        )),
170
                        Some(status),
×
171
                    )
172
                }
173
            } else {
174
                let error = create_http_error(status, url);
4✔
175
                match retry_with_gateways_with_method(url, error, gateways, Method::GET).await {
6✔
176
                    Ok(alt_response) => {
1✔
177
                        let alt_status = alt_response.status();
3✔
178
                        if alt_status.is_success() {
2✔
179
                            if let Some(len) = extract_content_length(&alt_response) {
2✔
180
                                (Ok(len), Some(alt_status))
1✔
181
                            } else {
182
                                (
183
                                    Err(anyhow::anyhow!(
×
184
                                        "GET response missing Content-Length for {}",
×
185
                                        alt_response.url()
×
186
                                    )),
187
                                    Some(alt_status),
×
188
                                )
189
                            }
190
                        } else {
191
                            (
192
                                Err(create_http_error(alt_status, alt_response.url().as_str())),
×
193
                                Some(alt_status),
×
194
                            )
195
                        }
196
                    }
197
                    Err(gateway_err) => (Err(gateway_err), Some(status)),
×
198
                }
199
            }
200
        }
201
        Err(e) => match retry_with_gateways_with_method(url, e, gateways, Method::GET).await {
×
202
            Ok(response) => {
×
203
                let status = response.status();
×
204
                if status.is_success() {
×
205
                    if let Some(len) = extract_content_length(&response) {
×
206
                        (Ok(len), Some(status))
×
207
                    } else {
208
                        (
209
                            Err(anyhow::anyhow!(
×
210
                                "GET response missing Content-Length for {}",
×
211
                                response.url()
×
212
                            )),
213
                            Some(status),
×
214
                        )
215
                    }
216
                } else {
217
                    (Err(create_http_error(status, url)), Some(status))
×
218
                }
219
            }
220
            Err(gateway_err) => (Err(gateway_err), None),
×
221
        },
222
    }
223
}
224

225
fn extract_content_length(response: &reqwest::Response) -> Option<u64> {
4✔
226
    response
4✔
227
        .headers()
228
        .get(CONTENT_LENGTH)
8✔
229
        .and_then(|h| h.to_str().ok())
16✔
230
        .and_then(|s| s.parse::<u64>().ok())
16✔
231
        .or_else(|| response.content_length())
4✔
232
}
233

234
/// Attempt to read the `Content-Length` of `url` using HEAD requests, falling
235
/// back to alternate IPFS gateways when available. Returns the parsed byte size
236
/// and the HTTP status code (if any) encountered while probing.
237
pub(crate) async fn try_head_content_length(
2✔
238
    url: &str,
239
    gateways: &[IpfsGatewayConfig],
240
) -> (anyhow::Result<u64>, Option<reqwest::StatusCode>) {
241
    match head_url(url, None).await {
8✔
242
        Ok(response) => {
2✔
243
            let status = response.status();
6✔
244
            if status.is_success() {
4✔
245
                if let Some(len) = extract_content_length(&response) {
2✔
246
                    (Ok(len), Some(status))
1✔
247
                } else {
248
                    (
249
                        Err(anyhow::anyhow!(
×
250
                            "HEAD response missing Content-Length for {}",
×
251
                            url
×
252
                        )),
253
                        Some(status),
×
254
                    )
255
                }
256
            } else if status == reqwest::StatusCode::FORBIDDEN {
1✔
257
                return try_get_content_length(url, gateways).await;
3✔
258
            } else {
259
                let error = create_http_error(status, url);
×
260
                match retry_with_gateways_with_method(url, error, gateways, Method::HEAD).await {
×
261
                    Ok(alt_response) => {
×
262
                        let alt_status = alt_response.status();
×
263
                        if alt_status.is_success() {
×
264
                            if let Some(len) = extract_content_length(&alt_response) {
×
265
                                (Ok(len), Some(alt_status))
×
266
                            } else {
267
                                (
268
                                    Err(anyhow::anyhow!(
×
269
                                        "HEAD response missing Content-Length for {}",
×
270
                                        alt_response.url()
×
271
                                    )),
272
                                    Some(alt_status),
×
273
                                )
274
                            }
275
                        } else if alt_status == reqwest::StatusCode::FORBIDDEN {
×
276
                            let fallback_url = alt_response.url().to_string();
×
277
                            return try_get_content_length(&fallback_url, gateways).await;
×
278
                        } else {
279
                            (
280
                                Err(create_http_error(alt_status, alt_response.url().as_str())),
×
281
                                Some(alt_status),
×
282
                            )
283
                        }
284
                    }
285
                    Err(gateway_err) => (Err(gateway_err), Some(status)),
×
286
                }
287
            }
288
        }
289
        Err(e) => match retry_with_gateways_with_method(url, e, gateways, Method::HEAD).await {
×
290
            Ok(response) => {
×
291
                let status = response.status();
×
292
                if status.is_success() {
×
293
                    if let Some(len) = extract_content_length(&response) {
×
294
                        (Ok(len), Some(status))
×
295
                    } else {
296
                        (
297
                            Err(anyhow::anyhow!(
×
298
                                "HEAD response missing Content-Length for {}",
×
299
                                response.url()
×
300
                            )),
301
                            Some(status),
×
302
                        )
303
                    }
304
                } else if status == reqwest::StatusCode::FORBIDDEN {
×
305
                    let fallback_url = response.url().to_string();
×
306
                    return try_get_content_length(&fallback_url, gateways).await;
×
307
                } else {
308
                    (Err(create_http_error(status, url)), Some(status))
×
309
                }
310
            }
311
            Err(gateway_err) => (Err(gateway_err), None),
×
312
        },
313
    }
314
}
315

316
#[cfg(test)]
317
mod try_get_content_length_tests {
318
    use crate::ipfs::config::{IpfsGatewayConfig, IpfsGatewayType};
319
    use wiremock::{
320
        matchers::{method, path},
321
        Mock, MockServer, ResponseTemplate,
322
    };
323

324
    fn leak_str(s: String) -> &'static str {
325
        Box::leak(s.into_boxed_str())
326
    }
327

328
    fn gw(base: &str) -> IpfsGatewayConfig {
329
        IpfsGatewayConfig {
330
            url: leak_str(base.to_string()),
331
            gateway_type: IpfsGatewayType::Path,
332
            bearer_token_env: None,
333
        }
334
    }
335

336
    #[tokio::test]
337
    async fn returns_length_from_successful_get() {
338
        let mock_server = MockServer::start().await;
339
        let url = format!("{}/asset.png", mock_server.uri());
340

341
        Mock::given(method("GET"))
342
            .and(path("/asset.png"))
343
            .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0u8; 1234]))
344
            .mount(&mock_server)
345
            .await;
346

347
        let (res, status) = super::try_get_content_length(&url, &[]).await;
348
        assert_eq!(status, Some(reqwest::StatusCode::OK));
349
        assert_eq!(res.unwrap(), 1234);
350
    }
351

352
    #[tokio::test]
353
    async fn retries_with_gateways_on_error() {
354
        let server_a = MockServer::start().await;
355
        let server_b = MockServer::start().await;
356
        let content_path = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/file";
357
        let original_url = format!("{}/ipfs/{}", server_a.uri(), content_path);
358

359
        Mock::given(method("GET"))
360
            .and(path(format!("/ipfs/{}", content_path)))
361
            .respond_with(ResponseTemplate::new(500))
362
            .mount(&server_a)
363
            .await;
364

365
        Mock::given(method("GET"))
366
            .and(path(format!("/ipfs/{}", content_path)))
367
            .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0u8; 4096]))
368
            .mount(&server_b)
369
            .await;
370

371
        let gateways = vec![gw(&server_a.uri()), gw(&server_b.uri())];
372

373
        let (res, status) = super::try_get_content_length(&original_url, &gateways).await;
374
        assert_eq!(status, Some(reqwest::StatusCode::OK));
375
        assert_eq!(res.unwrap(), 4096);
376
    }
377
}
378

379
#[cfg(test)]
380
mod try_fetch_response_tests {
381
    use wiremock::{
382
        matchers::{method, path},
383
        Mock, MockServer, ResponseTemplate,
384
    };
385

386
    use crate::ipfs::config::{IpfsGatewayConfig, IpfsGatewayType};
387

388
    fn leak_str(s: String) -> &'static str {
389
        Box::leak(s.into_boxed_str())
390
    }
391

392
    fn gw(base: &str) -> IpfsGatewayConfig {
393
        IpfsGatewayConfig {
394
            url: leak_str(base.to_string()),
395
            gateway_type: IpfsGatewayType::Path,
396
            bearer_token_env: None,
397
        }
398
    }
399

400
    #[tokio::test]
401
    async fn test_http_200_success() {
402
        let mock_server = MockServer::start().await;
403
        let cid = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco";
404
        let url = format!("{}/ipfs/{}", mock_server.uri(), cid);
405

406
        Mock::given(method("GET"))
407
            .and(path(format!("/ipfs/{}", cid)))
408
            .respond_with(ResponseTemplate::new(200).set_body_string("Hello, World!"))
409
            .mount(&mock_server)
410
            .await;
411

412
        let (res, status) = super::try_fetch_response(&url, &[]).await;
413
        assert!(status.is_some());
414
        assert_eq!(status.unwrap(), reqwest::StatusCode::OK);
415
        assert!(res.is_ok());
416
        let body = res.unwrap().bytes().await.unwrap();
417
        assert_eq!(body.as_ref(), b"Hello, World!");
418
    }
419

420
    #[tokio::test]
421
    async fn test_http_404_no_retry() {
422
        let mock_server = MockServer::start().await;
423
        let cid = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco";
424
        let url = format!("{}/ipfs/{}", mock_server.uri(), cid);
425

426
        Mock::given(method("GET"))
427
            .and(path(format!("/ipfs/{}", cid)))
428
            .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
429
            .expect(1)
430
            .mount(&mock_server)
431
            .await;
432

433
        let (res, status) = super::try_fetch_response(&url, &[]).await;
434
        assert!(status.is_some());
435
        assert_eq!(status.unwrap(), reqwest::StatusCode::NOT_FOUND);
436
        assert!(res.is_err());
437
        let err = res.err().unwrap().to_string();
438
        assert!(err.contains("HTTP error: status 404"));
439
    }
440

441
    #[tokio::test]
442
    async fn test_http_500_retry_and_fail() {
443
        let mock_server = MockServer::start().await;
444
        let cid = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco";
445
        let url = format!("{}/ipfs/{}", mock_server.uri(), cid);
446

447
        Mock::given(method("GET"))
448
            .and(path(format!("/ipfs/{}", cid)))
449
            .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
450
            .expect(1)
451
            .mount(&mock_server)
452
            .await;
453

454
        let (res, status) = super::try_fetch_response(&url, &[]).await;
455
        assert!(status.is_some());
456
        assert_eq!(status.unwrap(), reqwest::StatusCode::INTERNAL_SERVER_ERROR);
457
        assert!(res.is_err());
458
        let err = res.err().unwrap().to_string();
459
        assert!(err.contains("HTTP error: status 500"));
460
    }
461

462
    #[tokio::test]
463
    async fn test_initial_error_then_alternative_gateway_succeeds() {
464
        // Arrange: original gateway fails, alternative returns 200
465
        let server_a = MockServer::start().await;
466
        let server_b = MockServer::start().await;
467
        let cid = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco";
468
        let content_path = format!("{}/ok", cid);
469
        let original_url = format!("{}/ipfs/{}", server_a.uri(), content_path);
470

471
        // A returns 500 (server error)
472
        Mock::given(method("GET"))
473
            .and(path(format!("/ipfs/{}", content_path)))
474
            .respond_with(ResponseTemplate::new(500).set_body_string("Server Error"))
475
            .mount(&server_a)
476
            .await;
477

478
        // B returns 200 for the same IPFS path
479
        Mock::given(method("GET"))
480
            .and(path(format!("/ipfs/{}", content_path)))
481
            .respond_with(ResponseTemplate::new(200).set_body_string("ALT OK"))
482
            .mount(&server_b)
483
            .await;
484

485
        // Gateways: first the failing one (A), then the working mock (B)
486
        let gateways = vec![gw(&server_a.uri()), gw(&server_b.uri())];
487

488
        // Act
489
        let (res, status) = super::try_fetch_response(&original_url, &gateways).await;
490

491
        // Assert
492
        assert!(res.is_ok());
493
        assert_eq!(status, Some(reqwest::StatusCode::OK));
494
        let body = res.unwrap().bytes().await.unwrap();
495
        assert_eq!(body.as_ref(), b"ALT OK");
496
    }
497

498
    #[tokio::test]
499
    async fn test_initial_network_error_then_alternative_gateway_succeeds() {
500
        // Arrange: original gateway has network error, alternative returns 200
501
        let server_b = MockServer::start().await;
502
        let dead_gateway_base = "http://127.0.0.1:9"; // simulate connect error
503
        let cid = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco";
504
        let content_path = format!("{}/network_test", cid);
505
        let original_url = format!("{}/ipfs/{}", dead_gateway_base, content_path);
506

507
        // B returns 200 for the same IPFS path
508
        Mock::given(method("GET"))
509
            .and(path(format!("/ipfs/{}", content_path)))
510
            .respond_with(ResponseTemplate::new(200).set_body_string("NETWORK RETRY OK"))
511
            .mount(&server_b)
512
            .await;
513

514
        // Gateways: first the dead one (original), then the working mock
515
        let gateways = vec![gw(dead_gateway_base), gw(&server_b.uri())];
516

517
        // Act
518
        let (res, status) = super::try_fetch_response(&original_url, &gateways).await;
519

520
        // Assert
521
        assert!(res.is_ok());
522
        assert_eq!(status, Some(reqwest::StatusCode::OK));
523
        let body = res.unwrap().bytes().await.unwrap();
524
        assert_eq!(body.as_ref(), b"NETWORK RETRY OK");
525
    }
526
}
527

528
#[cfg(test)]
529
mod retry_with_gateways_tests {
530
    use wiremock::{
531
        matchers::{method, path},
532
        Mock, MockServer, ResponseTemplate,
533
    };
534

535
    use crate::ipfs::config::{IpfsGatewayConfig, IpfsGatewayType};
536

537
    // Helper to leak a String into a 'static str for IpfsGatewayConfig
538
    fn leak_str(s: String) -> &'static str {
539
        Box::leak(s.into_boxed_str())
540
    }
541

542
    // Build a path-based gateway config from a base URL
543
    fn gw(base: &str) -> IpfsGatewayConfig {
544
        IpfsGatewayConfig {
545
            url: leak_str(base.to_string()),
546
            gateway_type: IpfsGatewayType::Path,
547
            bearer_token_env: None,
548
        }
549
    }
550

551
    #[tokio::test]
552
    async fn first_alternative_succeeds() {
553
        // Arrange gateways A(original), B(success), C(not used)
554
        let server_a = MockServer::start().await;
555
        let server_b = MockServer::start().await;
556
        let server_c = MockServer::start().await;
557

558
        let content_path = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/ok";
559
        let original_url = format!("{}/ipfs/{}", server_a.uri(), content_path);
560

561
        // B returns 200
562
        Mock::given(method("GET"))
563
            .and(path(format!("/ipfs/{}", content_path)))
564
            .respond_with(ResponseTemplate::new(200).set_body_string("B OK"))
565
            .mount(&server_b)
566
            .await;
567

568
        let gateways = vec![
569
            gw(&server_a.uri()),
570
            gw(&server_b.uri()),
571
            gw(&server_c.uri()),
572
        ];
573

574
        // Act
575
        let res =
576
            super::retry_with_gateways(&original_url, anyhow::anyhow!("orig err"), &gateways).await;
577

578
        // Assert
579
        assert!(res.is_ok());
580
        let body = res.unwrap().bytes().await.unwrap();
581
        assert_eq!(body.as_ref(), b"B OK");
582
    }
583

584
    #[tokio::test]
585
    async fn first_alternative_http_error_second_succeeds() {
586
        // Arrange gateways A(original), B(500), C(200)
587
        let server_a = MockServer::start().await;
588
        let server_b = MockServer::start().await;
589
        let server_c = MockServer::start().await;
590

591
        let content_path = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/ok2";
592
        let original_url = format!("{}/ipfs/{}", server_a.uri(), content_path);
593

594
        // B returns 500
595
        Mock::given(method("GET"))
596
            .and(path(format!("/ipfs/{}", content_path)))
597
            .respond_with(ResponseTemplate::new(500))
598
            .mount(&server_b)
599
            .await;
600

601
        // C returns 200
602
        Mock::given(method("GET"))
603
            .and(path(format!("/ipfs/{}", content_path)))
604
            .respond_with(ResponseTemplate::new(200).set_body_string("C OK"))
605
            .mount(&server_c)
606
            .await;
607

608
        let gateways = vec![
609
            gw(&server_a.uri()),
610
            gw(&server_b.uri()),
611
            gw(&server_c.uri()),
612
        ];
613

614
        // Act
615
        let res =
616
            super::retry_with_gateways(&original_url, anyhow::anyhow!("orig err"), &gateways).await;
617

618
        // Assert
619
        assert!(res.is_ok());
620
        let body = res.unwrap().bytes().await.unwrap();
621
        assert_eq!(body.as_ref(), b"C OK");
622
    }
623

624
    #[tokio::test]
625
    async fn first_alternative_non_http_error_second_succeeds() {
626
        // Arrange gateways A(original), B(non-http error), C(200)
627
        let server_a = MockServer::start().await;
628
        let server_c = MockServer::start().await;
629

630
        // Use an unroutable/closed port for B to simulate network error
631
        let dead_gateway_base = "http://127.0.0.1:9"; // port 9 (discard) likely closed
632

633
        let content_path = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/ok3";
634
        let original_url = format!("{}/ipfs/{}", server_a.uri(), content_path);
635

636
        // C returns 200
637
        Mock::given(method("GET"))
638
            .and(path(format!("/ipfs/{}", content_path)))
639
            .respond_with(ResponseTemplate::new(200).set_body_string("C OK"))
640
            .mount(&server_c)
641
            .await;
642

643
        let gateways = vec![
644
            gw(&server_a.uri()),
645
            gw(dead_gateway_base),
646
            gw(&server_c.uri()),
647
        ];
648

649
        // Act
650
        let res =
651
            super::retry_with_gateways(&original_url, anyhow::anyhow!("orig err"), &gateways).await;
652

653
        // Assert
654
        assert!(res.is_ok());
655
        let body = res.unwrap().bytes().await.unwrap();
656
        assert_eq!(body.as_ref(), b"C OK");
657
    }
658

659
    #[tokio::test]
660
    async fn both_alternatives_fail() {
661
        // Arrange gateways A(original), B(500), C(502)
662
        let server_a = MockServer::start().await;
663
        let server_b = MockServer::start().await;
664
        let server_c = MockServer::start().await;
665

666
        let content_path = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/fail";
667
        let original_url = format!("{}/ipfs/{}", server_a.uri(), content_path);
668

669
        // B returns 500
670
        Mock::given(method("GET"))
671
            .and(path(format!("/ipfs/{}", content_path)))
672
            .respond_with(ResponseTemplate::new(500))
673
            .mount(&server_b)
674
            .await;
675

676
        // C returns 502
677
        Mock::given(method("GET"))
678
            .and(path(format!("/ipfs/{}", content_path)))
679
            .respond_with(ResponseTemplate::new(502))
680
            .mount(&server_c)
681
            .await;
682

683
        let gateways = vec![
684
            gw(&server_a.uri()),
685
            gw(&server_b.uri()),
686
            gw(&server_c.uri()),
687
        ];
688

689
        // Act
690
        let res =
691
            super::retry_with_gateways(&original_url, anyhow::anyhow!("orig err"), &gateways).await;
692

693
        // Assert: error should reflect the last error (502)
694
        assert!(res.is_err());
695
        let err = res.err().unwrap().to_string();
696
        assert!(err.contains("502"));
697
    }
698

699
    #[tokio::test]
700
    async fn non_ipfs_url_returns_original_error_without_warning() {
701
        // Test that non-IPFS URLs return the original error without logging IPFS gateway warnings
702
        let non_ipfs_url =
703
            "https://mirror.xyz/10/0xceda033195af537e16d203a4d7fbfe1c5f0eb843/render";
704
        let original_error_msg = "HTTP error: status 429 Too Many Requests";
705
        let original_error = anyhow::anyhow!(original_error_msg);
706
        let gateways = vec![gw("https://ipfs.io")];
707

708
        // Act
709
        let res = super::retry_with_gateways(non_ipfs_url, original_error, &gateways).await;
710

711
        // Assert: should return the original error without trying IPFS gateways
712
        assert!(res.is_err());
713
        let err = res.err().unwrap();
714
        assert_eq!(err.to_string(), original_error_msg);
715
    }
716
}
717

718
#[cfg(test)]
719
mod fetch_url_tests {
720
    use wiremock::{
721
        matchers::{header, method, path},
722
        Mock, MockServer, ResponseTemplate,
723
    };
724

725
    #[tokio::test]
726
    async fn test_fetch_url_without_bearer_token() {
727
        let mock_server = MockServer::start().await;
728
        let url = format!("{}/test", mock_server.uri());
729

730
        Mock::given(method("GET"))
731
            .and(path("/test"))
732
            .and(header("User-Agent", crate::USER_AGENT))
733
            .respond_with(ResponseTemplate::new(200).set_body_string("OK"))
734
            .mount(&mock_server)
735
            .await;
736

737
        let result = super::fetch_url(&url, None).await;
738
        assert!(result.is_ok());
739
        let response = result.unwrap();
740
        assert_eq!(response.status(), reqwest::StatusCode::OK);
741
    }
742

743
    #[tokio::test]
744
    async fn test_fetch_url_with_bearer_token() {
745
        let mock_server = MockServer::start().await;
746
        let url = format!("{}/protected", mock_server.uri());
747
        let token = "test-bearer-token-123";
748
        let auth_header = format!("Bearer {}", token);
749

750
        Mock::given(method("GET"))
751
            .and(path("/protected"))
752
            .and(header("User-Agent", crate::USER_AGENT))
753
            .and(header("Authorization", auth_header.as_str()))
754
            .respond_with(ResponseTemplate::new(200).set_body_string("Protected OK"))
755
            .mount(&mock_server)
756
            .await;
757

758
        let result = super::fetch_url(&url, Some(token.to_string())).await;
759
        assert!(result.is_ok());
760
        let response = result.unwrap();
761
        assert_eq!(response.status(), reqwest::StatusCode::OK);
762
    }
763

764
    #[tokio::test]
765
    async fn test_fetch_url_with_empty_bearer_token() {
766
        let mock_server = MockServer::start().await;
767
        let url = format!("{}/test", mock_server.uri());
768

769
        Mock::given(method("GET"))
770
            .and(path("/test"))
771
            .and(header("User-Agent", crate::USER_AGENT))
772
            .respond_with(ResponseTemplate::new(200).set_body_string("OK"))
773
            .mount(&mock_server)
774
            .await;
775

776
        // Empty string should not add Authorization header
777
        let result = super::fetch_url(&url, Some("".to_string())).await;
778
        assert!(result.is_ok());
779
        let response = result.unwrap();
780
        assert_eq!(response.status(), reqwest::StatusCode::OK);
781
    }
782

783
    #[tokio::test]
784
    async fn test_fetch_url_network_error() {
785
        // Use a non-existent URL to test network error handling
786
        let url = "http://127.0.0.1:9/nonexistent";
787

788
        let result = super::fetch_url(url, None).await;
789
        assert!(result.is_err());
790
    }
791
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc