1use crate::{Did, Document};
7use anyhow::{anyhow, Result};
8use reqwest::multipart;
9use serde::de::DeserializeOwned;
10use serde::{Deserialize, Serialize};
11use tokio::time::sleep;
12use tracing::warn;
13use web_time::Duration;
14
15#[derive(Debug, Deserialize)]
18struct AddResponse {
19 #[serde(rename = "Hash")]
20 hash: String,
21}
22
23#[derive(Debug, Deserialize)]
24struct DagPutCid {
25 #[serde(rename = "/")]
26 slash: String,
27}
28
29#[derive(Debug, Deserialize)]
30struct DagPutResponse {
31 #[serde(default, rename = "Cid")]
32 cid_upper: Option<DagPutCid>,
33 #[serde(default)]
34 cid: Option<DagPutCid>,
35}
36
37#[derive(Debug, Deserialize)]
38struct NamePublishResponse {
39 #[serde(default, rename = "Value")]
40 value_upper: String,
41 #[serde(default, rename = "value")]
42 value_lower: String,
43}
44
45#[derive(Debug, Deserialize)]
46struct NameResolveResponse {
47 #[serde(default, rename = "Path")]
48 path_upper: String,
49 #[serde(default, rename = "path")]
50 path_lower: String,
51}
52
53#[derive(Debug, Deserialize)]
54struct VersionResponse {
55 #[serde(default, rename = "Version")]
56 version_upper: String,
57 #[serde(default, rename = "version")]
58 version_lower: String,
59}
60
61#[derive(Debug, Deserialize)]
62struct KeyListEntry {
63 #[serde(default, rename = "Name")]
64 name: String,
65 #[serde(default, rename = "name")]
66 name_lower: String,
67 #[serde(default, rename = "Id")]
68 id: String,
69 #[serde(default, rename = "id")]
70 id_lower: String,
71}
72
73#[derive(Debug, Deserialize)]
74struct KeyListResponse {
75 #[serde(default, rename = "Keys")]
76 keys: Vec<KeyListEntry>,
77}
78
79#[derive(Debug, Deserialize)]
80struct KeyImportResponse {
81 #[serde(default, rename = "Name")]
82 name_upper: String,
83 #[serde(default, rename = "name")]
84 name_lower: String,
85 #[serde(default, rename = "Id")]
86 id_upper: String,
87 #[serde(default, rename = "id")]
88 id_lower: String,
89}
90
91#[derive(Clone, Debug)]
92pub struct KuboKey {
93 pub name: String,
94 pub id: String,
95}
96
97#[derive(Clone, Debug)]
100pub struct IpnsPublishOptions {
101 pub timeout: Duration,
102 pub allow_offline: bool,
103 pub lifetime: String,
104 pub ttl: Option<String>,
105 pub resolve: bool,
106 pub quieter: bool,
107}
108
109impl Default for IpnsPublishOptions {
110 fn default() -> Self {
111 Self {
112 timeout: Duration::from_mins(2),
113 allow_offline: true,
114 lifetime: "8760h".to_string(),
115 ttl: None,
116 resolve: false,
117 quieter: true,
118 }
119 }
120}
121
122pub async fn wait_for_api(kubo_url: &str, attempts: u32) -> Result<()> {
125 if attempts == 0 {
126 return Err(anyhow!("kubo readiness attempts must be >= 1"));
127 }
128
129 let base = kubo_url.trim_end_matches('/');
130 let url = format!("{base}/api/v0/version");
131 let client = reqwest::Client::builder()
132 .timeout(Duration::from_secs(6))
133 .build()?;
134
135 let mut fib_prev = Duration::from_millis(0);
136 let mut fib_curr = Duration::from_millis(200);
137 let mut last_err: Option<anyhow::Error> = None;
138
139 for attempt in 1..=attempts {
140 let result = async {
141 let response = client.post(&url).send().await?.error_for_status()?;
142 let body = response.text().await?;
143 let parsed: VersionResponse = serde_json::from_str(&body)
144 .map_err(|e| anyhow!("failed parsing version response: {} body={}", e, body))?;
145 let version = if !parsed.version_upper.is_empty() {
146 parsed.version_upper
147 } else {
148 parsed.version_lower
149 };
150 if version.trim().is_empty() {
151 return Err(anyhow!("missing version field in response: {}", body));
152 }
153 Ok::<(), anyhow::Error>(())
154 }
155 .await;
156
157 match result {
158 Ok(()) => return Ok(()),
159 Err(err) => {
160 warn!("kubo readiness {}/{}: {}", attempt, attempts, err);
161 last_err = Some(err);
162 if attempt < attempts {
163 sleep(fib_curr).await;
164 let next_ms = fib_prev.as_millis().saturating_add(fib_curr.as_millis());
165 fib_prev = fib_curr;
166 fib_curr = Duration::from_millis(std::cmp::min(next_ms, 5_000) as u64);
167 }
168 }
169 }
170 }
171 Err(anyhow!(
172 "kubo API not ready after {} attempts: {}",
173 attempts,
174 last_err
175 .map(|e| e.to_string())
176 .unwrap_or_else(|| "unknown error".to_string())
177 ))
178}
179
180pub async fn ipfs_add(kubo_url: &str, data: Vec<u8>) -> Result<String> {
183 let base = kubo_url.trim_end_matches('/');
184 let url = format!("{base}/api/v0/add");
185
186 let part = multipart::Part::bytes(data).file_name("data");
187 let form = multipart::Form::new().part("file", part);
188
189 let client = reqwest::Client::builder()
190 .timeout(Duration::from_secs(10))
191 .build()?;
192
193 let body = client
194 .post(url)
195 .query(&[("pin", "true")])
196 .multipart(form)
197 .send()
198 .await?
199 .error_for_status()?
200 .text()
201 .await?;
202
203 let parsed: AddResponse = serde_json::from_str(&body)
204 .map_err(|e| anyhow!("failed parsing add response: {} body={}", e, body))?;
205 Ok(parsed.hash)
206}
207
208pub async fn cat_bytes(kubo_url: &str, cid: &str) -> Result<Vec<u8>> {
209 let base = kubo_url.trim_end_matches('/');
210 let url = format!("{base}/api/v0/cat");
211
212 let client = reqwest::Client::builder()
213 .timeout(Duration::from_secs(30))
214 .build()?;
215
216 let bytes = client
217 .post(url)
218 .query(&[("arg", cid)])
219 .send()
220 .await?
221 .error_for_status()?
222 .bytes()
223 .await?;
224
225 Ok(bytes.to_vec())
226}
227
228pub async fn cat_text(kubo_url: &str, cid: &str) -> Result<String> {
229 let bytes = cat_bytes(kubo_url, cid).await?;
230 String::from_utf8(bytes).map_err(|e| anyhow!("non-utf8 content from {}: {}", cid, e))
231}
232
233pub async fn dag_put<T: Serialize>(kubo_url: &str, value: &T) -> Result<String> {
236 let base = kubo_url.trim_end_matches('/');
237 let url = format!("{base}/api/v0/dag/put");
238 let payload = serde_json::to_vec(value)?;
239
240 let part = multipart::Part::bytes(payload)
241 .file_name("node.json")
242 .mime_str("application/json")?;
243 let form = multipart::Form::new().part("file", part);
244
245 let client = reqwest::Client::builder()
246 .timeout(Duration::from_secs(10))
247 .build()?;
248
249 let body = client
250 .post(url)
251 .query(&[
252 ("store-codec", "dag-cbor"),
253 ("input-codec", "dag-json"),
254 ("pin", "true"),
255 ])
256 .multipart(form)
257 .send()
258 .await?
259 .error_for_status()?
260 .text()
261 .await?;
262
263 let parsed: DagPutResponse = serde_json::from_str(&body)
264 .map_err(|e| anyhow!("failed parsing dag/put response: {} body={}", e, body))?;
265 parsed
266 .cid_upper
267 .or(parsed.cid)
268 .map(|c| c.slash)
269 .ok_or_else(|| anyhow!("missing CID in dag/put response: {}", body))
270}
271
272pub async fn dag_get<T: DeserializeOwned>(kubo_url: &str, cid: &str) -> Result<T> {
273 let base = kubo_url.trim_end_matches('/');
274 let url = format!("{base}/api/v0/dag/get");
275
276 let client = reqwest::Client::builder()
277 .timeout(Duration::from_secs(10))
278 .build()?;
279
280 let body = client
281 .post(url)
282 .query(&[("arg", cid)])
283 .send()
284 .await?
285 .error_for_status()?
286 .text()
287 .await?;
288
289 serde_json::from_str::<T>(&body).map_err(|e| {
290 anyhow!(
291 "failed parsing dag/get response for {}: {} body={}",
292 cid,
293 e,
294 body
295 )
296 })
297}
298
299fn normalize_ipfs_arg(cid_or_path: &str) -> String {
302 let mut value = cid_or_path.trim().to_string();
303 while let Some(rest) = value.strip_prefix("/ipfs/") {
304 value = rest.to_string();
305 }
306 while let Some(rest) = value.strip_prefix('/') {
307 value = rest.to_string();
308 }
309 format!("/ipfs/{value}")
310}
311
312pub async fn name_publish(kubo_url: &str, key_name: &str, cid: &str) -> Result<String> {
313 let options = IpnsPublishOptions::default();
314 name_publish_with_options(kubo_url, key_name, cid, &options).await
315}
316
317pub async fn name_publish_with_options(
318 kubo_url: &str,
319 key_name: &str,
320 cid: &str,
321 options: &IpnsPublishOptions,
322) -> Result<String> {
323 let base = kubo_url.trim_end_matches('/');
324 let url = format!("{base}/api/v0/name/publish");
325 let arg = normalize_ipfs_arg(cid);
326
327 let client = reqwest::Client::builder()
328 .timeout(options.timeout)
329 .build()?;
330
331 let allow_offline = if options.allow_offline {
332 "true"
333 } else {
334 "false"
335 };
336 let resolve = if options.resolve { "true" } else { "false" };
337 let quieter = if options.quieter { "true" } else { "false" };
338
339 let mut params: Vec<(&str, &str)> = vec![
340 ("arg", arg.as_str()),
341 ("key", key_name),
342 ("allow-offline", allow_offline),
343 ("lifetime", options.lifetime.as_str()),
344 ("resolve", resolve),
345 ("quieter", quieter),
346 ];
347 if let Some(ref ttl) = options.ttl {
348 params.push(("ttl", ttl.as_str()));
349 }
350
351 let body = client
352 .post(url)
353 .query(¶ms)
354 .send()
355 .await?
356 .error_for_status()?
357 .text()
358 .await?;
359
360 let parsed: NamePublishResponse = serde_json::from_str(&body)
361 .map_err(|e| anyhow!("failed parsing name/publish response: {} body={}", e, body))?;
362 let value = if !parsed.value_upper.is_empty() {
363 parsed.value_upper
364 } else {
365 parsed.value_lower
366 };
367 if value.is_empty() {
368 return Err(anyhow!("missing value in name/publish response: {}", body));
369 }
370 Ok(value)
371}
372
373pub async fn name_publish_with_retry(
374 kubo_url: &str,
375 key_name: &str,
376 cid: &str,
377 options: &IpnsPublishOptions,
378 attempts: u32,
379 initial_backoff: Duration,
380) -> Result<String> {
381 if attempts == 0 {
382 return Err(anyhow!("name publish attempts must be >= 1"));
383 }
384
385 let mut fib_prev = Duration::from_millis(0);
386 let mut fib_curr = initial_backoff;
387 let mut last_err: Option<anyhow::Error> = None;
388
389 for attempt in 1..=attempts {
390 match name_publish_with_options(kubo_url, key_name, cid, options).await {
391 Ok(value) => return Ok(value),
392 Err(err) => {
393 if let Ok(value) = verify_name_target_after_error(kubo_url, key_name, cid).await {
394 warn!(
395 "name publish attempt {}/{} reported error for key '{}' but resolve confirms target; accepting: {}",
396 attempt, attempts, key_name, value
397 );
398 return Ok(value);
399 }
400 warn!(
401 "name publish attempt {}/{} failed for key '{}' cid '{}': {}",
402 attempt, attempts, key_name, cid, err
403 );
404 last_err = Some(err);
405 if attempt < attempts {
406 sleep(fib_curr).await;
407 let next_ms = fib_prev.as_millis().saturating_add(fib_curr.as_millis());
408 fib_prev = fib_curr;
409 fib_curr = Duration::from_millis(std::cmp::min(next_ms, 30_000) as u64);
410 }
411 }
412 }
413 }
414
415 Err(anyhow!(
416 "name publish failed after {} attempt(s): {}",
417 attempts,
418 last_err
419 .map(|e| e.to_string())
420 .unwrap_or_else(|| "unknown error".to_string())
421 ))
422}
423
424async fn verify_name_target_after_error(
425 kubo_url: &str,
426 key_name: &str,
427 cid: &str,
428) -> Result<String> {
429 let expected = normalize_ipfs_arg(cid);
430 let resolved = name_resolve(kubo_url, &format!("/ipns/{key_name}"), true).await?;
431 if resolved.trim() == expected {
432 return Ok(resolved);
433 }
434 Err(anyhow!(
435 "post-error resolve mismatch for key '{}': expected '{}' got '{}'",
436 key_name,
437 expected,
438 resolved
439 ))
440}
441
442pub async fn name_resolve(kubo_url: &str, path: &str, recursive: bool) -> Result<String> {
443 let base = kubo_url.trim_end_matches('/');
444 let url = format!("{base}/api/v0/name/resolve");
445
446 let client = reqwest::Client::builder()
447 .timeout(Duration::from_secs(15))
448 .build()?;
449
450 let recursive_flag = if recursive { "true" } else { "false" };
451 let body = client
452 .post(url)
453 .query(&[("arg", path), ("recursive", recursive_flag)])
454 .send()
455 .await?
456 .error_for_status()?
457 .text()
458 .await?;
459
460 let parsed: NameResolveResponse = serde_json::from_str(&body)
461 .map_err(|e| anyhow!("failed parsing name/resolve response: {} body={}", e, body))?;
462 let resolved = if !parsed.path_upper.is_empty() {
463 parsed.path_upper
464 } else {
465 parsed.path_lower
466 };
467 if resolved.is_empty() {
468 return Err(anyhow!("missing path in name/resolve response: {}", body));
469 }
470 Ok(resolved)
471}
472
473pub async fn fetch_did_document(kubo_url: &str, did: &Did) -> Result<Document> {
476 let ipns_path = format!("/ipns/{}", did.ipns);
477 let mut fib_prev = Duration::from_millis(0);
478 let mut fib_curr = Duration::from_millis(150);
479 let mut last_err: Option<anyhow::Error> = None;
480 let mut document: Option<Document> = None;
481
482 for attempt in 1..=4 {
483 let dag_err = match dag_get::<Document>(kubo_url, &ipns_path).await {
485 Ok(doc) => {
486 document = Some(doc);
487 break;
488 }
489 Err(e) => e,
490 };
491
492 match name_resolve(kubo_url, &ipns_path, true).await {
494 Err(resolve_err) => {
495 last_err = Some(anyhow!(
496 "dag_get and name/resolve both failed for {}: dag={} resolve={}",
497 ipns_path,
498 dag_err,
499 resolve_err
500 ));
501 if !should_retry_name_resolve_error(&resolve_err) {
502 break;
503 }
504 }
505 Ok(resolved_path) => match dag_get::<Document>(kubo_url, &resolved_path).await {
506 Ok(doc) => {
507 document = Some(doc);
508 break;
509 }
510 Err(err) => {
511 last_err = Some(anyhow!(
512 "dag_get failed for {}: direct={} resolved={}",
513 ipns_path,
514 dag_err,
515 err
516 ));
517 }
518 },
519 }
520
521 if attempt < 4 {
522 sleep(fib_curr).await;
523 let next_ms = fib_prev.as_millis().saturating_add(fib_curr.as_millis());
524 fib_prev = fib_curr;
525 fib_curr = Duration::from_millis(std::cmp::min(next_ms, 2_000) as u64);
526 }
527 }
528
529 let document = document.ok_or_else(|| {
530 anyhow!(
531 "failed to fetch DID document for {} via {} after retries: {}",
532 did.id(),
533 ipns_path,
534 last_err
535 .map(|e| e.to_string())
536 .unwrap_or_else(|| "unknown error".to_string())
537 )
538 })?;
539
540 document.validate()?;
541 document.verify()?;
542
543 let doc_did = Did::try_from(document.id.as_str())
544 .map_err(|e| anyhow!("DID document has invalid id '{}': {}", document.id, e))?;
545 if doc_did.ipns != did.ipns {
546 return Err(anyhow!(
547 "DID document IPNS mismatch: expected {} but document id is {}",
548 did.base_id(),
549 document.id
550 ));
551 }
552
553 Ok(document)
554}
555
556fn should_retry_name_resolve_error(err: &anyhow::Error) -> bool {
557 let text = err.to_string().to_ascii_lowercase();
558 if text.contains("http status client error") {
559 return false;
560 }
561 if text.contains("missing path in name/resolve response") {
562 return false;
563 }
564 true
565}
566
567pub async fn pin_add_named(kubo_url: &str, cid: &str, name: &str) -> Result<()> {
570 let base = kubo_url.trim_end_matches('/');
571 let url = format!("{base}/api/v0/pin/add");
572 let arg = normalize_ipfs_arg(cid);
573
574 let client = reqwest::Client::builder()
575 .timeout(Duration::from_secs(10))
576 .build()?;
577
578 client
579 .post(url)
580 .query(&[("arg", arg.as_str()), ("recursive", "true"), ("name", name)])
581 .send()
582 .await?
583 .error_for_status()?;
584
585 Ok(())
586}
587
588pub async fn pin_rm(kubo_url: &str, cid: &str) -> Result<()> {
589 let base = kubo_url.trim_end_matches('/');
590 let url = format!("{base}/api/v0/pin/rm");
591 let arg = normalize_ipfs_arg(cid);
592
593 let client = reqwest::Client::builder()
594 .timeout(Duration::from_secs(10))
595 .build()?;
596
597 client
598 .post(url)
599 .query(&[("arg", arg.as_str()), ("recursive", "true")])
600 .send()
601 .await?
602 .error_for_status()?;
603
604 Ok(())
605}
606
607pub async fn remote_pin_add_named(
608 kubo_url: &str,
609 service: &str,
610 cid: &str,
611 name: &str,
612) -> Result<()> {
613 let base = kubo_url.trim_end_matches('/');
614 let url = format!("{base}/api/v0/pin/remote/add");
615 let arg = normalize_ipfs_arg(cid);
616
617 let client = reqwest::Client::builder()
618 .timeout(Duration::from_secs(30))
619 .build()?;
620
621 let resp = client
622 .post(url)
623 .query(&[
624 ("arg", arg.as_str()),
625 ("service", service),
626 ("name", name),
627 ("background", "false"),
628 ])
629 .send()
630 .await?;
631 if resp.status().is_success() {
632 return Ok(());
633 }
634 let status = resp.status();
635 let body = resp.text().await.unwrap_or_default();
636 if status.as_u16() == 409
637 || body.contains("DUPLICATE_OBJECT")
638 || body.contains("already pinned")
639 || body.contains("already exists")
640 {
641 return Ok(());
642 }
643 Err(anyhow!(
644 "pin/remote/add {cid} to {service} as {name} failed: {body}"
645 ))
646}
647
648pub async fn remote_pin_rm(kubo_url: &str, service: &str, cid: &str) -> Result<()> {
649 let base = kubo_url.trim_end_matches('/');
650 let url = format!("{base}/api/v0/pin/remote/rm");
651 let arg = normalize_ipfs_arg(cid);
652
653 let client = reqwest::Client::builder()
654 .timeout(Duration::from_secs(30))
655 .build()?;
656
657 let resp = client
658 .post(url)
659 .query(&[("service", service), ("cid", arg.as_str())])
660 .send()
661 .await?;
662 if resp.status().is_success() {
663 return Ok(());
664 }
665 let status = resp.status();
666 let body = resp.text().await.unwrap_or_default();
667 if status.as_u16() == 404 || body.contains("not found") || body.contains("not pinned") {
668 return Ok(());
669 }
670 Err(anyhow!("pin/remote/rm {cid} from {service} failed: {body}"))
671}
672
673pub async fn generate_key(kubo_url: &str, key_name: &str) -> Result<()> {
676 let base = kubo_url.trim_end_matches('/');
677 let url = format!("{base}/api/v0/key/gen");
678
679 reqwest::Client::builder()
680 .timeout(Duration::from_secs(10))
681 .build()?
682 .post(url)
683 .query(&[("arg", key_name), ("type", "ed25519")])
684 .send()
685 .await?
686 .error_for_status()?;
687
688 Ok(())
689}
690
691pub async fn import_key(kubo_url: &str, key_name: &str, key_bytes: Vec<u8>) -> Result<KuboKey> {
692 let base = kubo_url.trim_end_matches('/');
693 let url = format!("{base}/api/v0/key/import");
694
695 let part = multipart::Part::bytes(key_bytes)
696 .file_name("ipns.key")
697 .mime_str("application/octet-stream")?;
698 let form = multipart::Form::new().part("file", part);
699
700 let response = reqwest::Client::builder()
701 .timeout(Duration::from_secs(10))
702 .build()?
703 .post(url)
704 .query(&[
705 ("arg", key_name),
706 ("ipns-base", "base36"),
707 ("allow-any-key-type", "true"),
708 ])
709 .multipart(form)
710 .send()
711 .await?
712 .error_for_status()?;
713
714 let body = response.text().await?;
715 let parsed: KeyImportResponse = serde_json::from_str(&body)
716 .map_err(|e| anyhow!("failed parsing key/import response: {} body={}", e, body))?;
717
718 let name = if !parsed.name_upper.trim().is_empty() {
719 parsed.name_upper.trim().to_string()
720 } else {
721 parsed.name_lower.trim().to_string()
722 };
723 let id = if !parsed.id_upper.trim().is_empty() {
724 parsed.id_upper.trim().to_string()
725 } else {
726 parsed.id_lower.trim().to_string()
727 };
728
729 if name.is_empty() || id.is_empty() {
730 return Err(anyhow!("missing name/id in key/import response: {}", body));
731 }
732
733 Ok(KuboKey { name, id })
734}
735
736pub async fn list_keys(kubo_url: &str) -> Result<Vec<KuboKey>> {
737 let base = kubo_url.trim_end_matches('/');
738 let url = format!("{base}/api/v0/key/list");
739
740 let body = reqwest::Client::builder()
741 .timeout(Duration::from_secs(10))
742 .build()?
743 .post(url)
744 .send()
745 .await?
746 .error_for_status()?
747 .text()
748 .await?;
749
750 let parsed: KeyListResponse = serde_json::from_str(&body)
751 .map_err(|e| anyhow!("failed parsing key/list response: {} body={}", e, body))?;
752 Ok(parsed
753 .keys
754 .into_iter()
755 .filter_map(|k| {
756 let name = if !k.name.trim().is_empty() {
757 k.name.trim().to_string()
758 } else {
759 k.name_lower.trim().to_string()
760 };
761 let id = if !k.id.trim().is_empty() {
762 k.id.trim().to_string()
763 } else {
764 k.id_lower.trim().to_string()
765 };
766 if name.is_empty() {
767 None
768 } else {
769 Some(KuboKey { name, id })
770 }
771 })
772 .collect())
773}
774
775pub async fn list_key_names(kubo_url: &str) -> Result<Vec<String>> {
776 let keys = list_keys(kubo_url).await?;
777 Ok(keys.into_iter().map(|k| k.name).collect())
778}
779
780pub async fn remove_key(kubo_url: &str, key_name: &str) -> Result<()> {
782 let base = kubo_url.trim_end_matches('/');
783 let url = format!("{base}/api/v0/key/rm");
784
785 reqwest::Client::builder()
786 .timeout(Duration::from_secs(10))
787 .build()?
788 .post(url)
789 .query(&[("arg", key_name)])
790 .send()
791 .await?
792 .error_for_status()?;
793
794 Ok(())
795}
796
797#[cfg(test)]
800mod tests {
801 use super::*;
802
803 #[test]
804 fn normalize_ipfs_arg_from_raw_cid() {
805 assert_eq!(normalize_ipfs_arg("QmExampleCid"), "/ipfs/QmExampleCid");
806 }
807
808 #[test]
809 fn normalize_ipfs_arg_from_prefixed_path() {
810 assert_eq!(
811 normalize_ipfs_arg("/ipfs/QmExampleCid"),
812 "/ipfs/QmExampleCid"
813 );
814 }
815
816 #[test]
817 fn normalize_ipfs_arg_from_double_prefixed_path() {
818 assert_eq!(
819 normalize_ipfs_arg("/ipfs//ipfs/QmExampleCid"),
820 "/ipfs/QmExampleCid"
821 );
822 }
823
824 #[test]
825 fn does_not_retry_http_client_status_errors() {
826 let err = anyhow!(
827 "HTTP status client error (404 Not Found) for url (http://127.0.0.1:5001/api/v0/name/resolve)"
828 );
829 assert!(!should_retry_name_resolve_error(&err));
830 }
831
832 #[test]
833 fn retries_http_server_status_errors() {
834 let err = anyhow!(
835 "HTTP status server error (500 Internal Server Error) for url (http://127.0.0.1:5001/api/v0/name/resolve)"
836 );
837 assert!(should_retry_name_resolve_error(&err));
838 }
839
840 #[test]
841 fn retries_network_errors() {
842 let err =
843 anyhow!("error sending request for url (http://127.0.0.1:5001/api/v0/name/resolve)");
844 assert!(should_retry_name_resolve_error(&err));
845 }
846}