ytsaurus_client/error.rs
1//! Errors the client can fail with.
2
3use thiserror::Error;
4
5use crate::jobs::JobFailure;
6
7/// Shorthand for a client result.
8pub type Result<T, E = ClientError> = std::result::Result<T, E>;
9
10/// Something went wrong talking to the cluster.
11///
12/// **Non-exhaustive.** A `match` over this must carry a `_` arm: the ways a
13/// cluster can refuse are the cluster's to add, not this crate's to freeze, and
14/// every release so far has added one. Naming a variant, constructing one and
15/// destructuring one all work as before.
16#[derive(Debug, Error)]
17#[non_exhaustive]
18pub enum ClientError {
19 /// The request could not be made, or the connection failed.
20 #[error("{command}: transport error: {source}{}", certificate_advice(.source))]
21 Transport {
22 /// The API command being attempted.
23 command: String,
24 /// The underlying HTTP error.
25 #[source]
26 source: Box<ureq::Error>,
27 },
28
29 /// The cluster reported an error.
30 ///
31 /// YTsaurus returns a structured error in the `X-YT-Error` header; the
32 /// message and code are lifted out of it so the common case reads well,
33 /// and the whole thing is kept in `raw` because the nested `inner_errors`
34 /// are often where the real cause is.
35 #[error("{command}: cluster error {code}: {message}")]
36 Cluster {
37 /// The API command that failed.
38 command: String,
39 /// YTsaurus error code.
40 code: i64,
41 /// Top-level error message.
42 message: String,
43 /// The full error document, as returned.
44 raw: String,
45 },
46
47 /// The cluster answered with an unexpected HTTP status and no usable error.
48 #[error("{command}: unexpected HTTP {status}{}", body_hint(.body))]
49 Http {
50 /// The API command that failed.
51 command: String,
52 /// The HTTP status returned.
53 status: u16,
54 /// Whatever body came back, truncated.
55 body: String,
56 },
57
58 /// A redirect was refused rather than followed.
59 ///
60 /// A control proxy does not refuse a heavy *read*: it answers `307
61 /// Temporary Redirect` naming a data proxy on another host — the
62 /// [HTTP proxy reference](https://ytsaurus.tech/docs/en/user-guide/proxy/http-reference#return_codes)
63 /// gives that row as *"307 | Redirecting heavy queries from light to heavy
64 /// proxies"*. Following it *without* the `Authorization` header — which is
65 /// what `ureq` does by default — makes the request arrive unauthenticated,
66 /// and the cluster then reports `Client is missing credentials` about a
67 /// token that may be perfectly valid. Re-attaching it and going would
68 /// follow an instruction the client never asked for, on a request already
69 /// addressed elsewhere. This error is the third answer: go nowhere, and say
70 /// where the proxy pointed.
71 ///
72 /// The message stops short of declaring the token good. It cannot know
73 /// that — a gateway in front of the cluster may answer an expired token
74 /// with a redirect of its own — so it reports the one thing this client is
75 /// certain of: the credentials never reached the host that answered.
76 ///
77 /// Not every redirect ends here. One that stays on the origin the request
78 /// was addressed to is followed, credentials and all, because nothing new
79 /// learns the token by it; `refusal` says which rule this redirect met.
80 #[error(
81 "{command}: the proxy answered HTTP {status} and redirected to {location}, \
82 which this client did not follow: {refusal}{}",
83 redirect_advice(.heavy)
84 )]
85 Redirected {
86 /// The API command that was redirected.
87 command: String,
88 /// The redirect status the proxy answered with — `307` in practice.
89 status: u16,
90 /// Where it pointed, resolved against the address the request went to,
91 /// so a relative `Location` still names a host. Usually a data proxy on
92 /// a different one.
93 location: String,
94 /// Which rule the redirect met.
95 refusal: RedirectRefusal,
96 /// Whether the redirected command reads or writes a data stream.
97 ///
98 /// Only those belong on a heavy proxy, so only those are told to go to
99 /// one: a `create` that met a balancer's `301` cannot use that advice
100 /// and is not given it.
101 heavy: bool,
102 },
103
104 /// A response could not be decoded.
105 #[error("{command}: could not decode the response: {reason}")]
106 Decode {
107 /// The API command whose response was unreadable.
108 command: String,
109 /// What went wrong.
110 reason: String,
111 },
112
113 /// A buffered response ran past what this client will hold in memory.
114 ///
115 /// Its own variant rather than a [`ClientError::Decode`], which is what it
116 /// was first written as. Every other `Decode` in this crate means *the
117 /// bytes were read and were not the shape expected* — a YSON document that
118 /// does not parse, a Skiff frame that ends early, an envelope missing the
119 /// key the command answers under. This body was never read at all, and the
120 /// difference is the whole of what the caller can do next: a `Decode`
121 /// invites a look at the data, and this invites the streaming half of the
122 /// same command, which the message names.
123 ///
124 /// Refused rather than truncated, and never retried — no amount of waiting
125 /// shrinks a response, and the host that served it did nothing wrong. That
126 /// second half is not this caller's concern alone: a heavy read blamed on
127 /// its host takes a healthy data proxy out of the pool, and enough of them
128 /// empty it. See `http::body_failure`.
129 ///
130 /// `limit` counts bytes **after** decompression, which is where they are
131 /// actually held — and it is what this client *holds*, not what the
132 /// process needs: the buffer grows by doubling and copies, so peak
133 /// residency runs above the number. See `http::RESPONSE_LIMIT`.
134 #[error(
135 "{command}: the response ran past the {} this client will hold in \
136 memory{}",
137 cap_size(.limit),
138 streaming_advice(.command)
139 )]
140 ResponseTooLarge {
141 /// The API command whose response was too large.
142 command: String,
143 /// The ceiling it ran past, in decoded bytes.
144 limit: u64,
145 },
146
147 /// A split batch stopped part of the way through, and the requests before
148 /// the failure have **already run on the cluster**.
149 ///
150 /// [`Client::execute_batch`](crate::Client::execute_batch) sends a batch
151 /// larger than [`BatchRequest::with_max_part_size`](crate::BatchRequest::with_max_part_size)
152 /// as several `execute_batch` requests. There is no rollback: when a later
153 /// request fails wholesale, the earlier ones have run and whichever of
154 /// their parts succeeded have taken effect. Reporting only the failure
155 /// would hide that, and re-running the same
156 /// [`BatchRequest`](crate::BatchRequest) is not a recovery either — a
157 /// second execution mints fresh mutation ids, so the parts that already
158 /// landed are applied a second time rather than deduplicated.
159 ///
160 /// So the prefix comes back with the failure: `answered` holds one entry
161 /// per part of every request that completed, in part order, with exactly
162 /// the per-part `Ok`/`Err` split [`Client::execute_batch`](crate::Client::execute_batch)
163 /// would have handed back. `answered.len()` is where the batch stopped, and
164 /// `parts` is how many there were, so the parts never attempted are
165 /// `batch[answered.len()..]`.
166 ///
167 /// Only for a batch that **was** split: a batch that fits in one request
168 /// fails with the underlying error itself, since there is no prefix to
169 /// report. Put the sequence in a transaction, or keep it inside one
170 /// request, if a partial application is not something the caller can act
171 /// on.
172 ///
173 /// The rendered message says the same thing, deliberately. It is the
174 /// sentence that reaches a log line and an `unwrap()` panic, so it must not
175 /// draw a line the cluster does not honour: `answered.len()` is where the
176 /// *answers* stop, not where the effects stop. The request that failed runs
177 /// its parts whatever it answers — measured — and `answered` itself holds
178 /// `Err` entries, which applied nothing at all.
179 #[error(
180 "execute_batch: {} of {parts} parts were answered for before the batch stopped — \
181 that is where the answers stop, not where the effects do: the request that failed \
182 still ran its parts, and an Err among the answers applied nothing: {cause}",
183 .answered.len()
184 )]
185 BatchInterrupted {
186 /// The parts already answered, in part order — every part of every
187 /// request that completed, `Ok` and `Err` alike.
188 answered: Vec<Result<ytsaurus_yson::YsonValue>>,
189 /// How many parts the batch held in all.
190 parts: usize,
191 /// Why the rest never went.
192 #[source]
193 cause: Box<ClientError>,
194 },
195
196 /// Reading a local file failed.
197 #[error("reading {path}: {source}")]
198 Io {
199 /// The path that could not be read.
200 path: String,
201 /// The underlying I/O error.
202 #[source]
203 source: std::io::Error,
204 },
205
206 /// An operation finished in a state other than `completed`.
207 #[error("operation {id} finished as {state}{}{}", failure_hint(.error), jobs_hint(.jobs))]
208 OperationFailed {
209 /// The operation's ID.
210 id: String,
211 /// Its terminal state — `failed`, `aborted`, …
212 state: String,
213 /// The operation's error document, when it has one.
214 error: Option<String>,
215 /// The jobs that failed, with what they printed.
216 ///
217 /// Empty if the cluster reported none, if job diagnostics are turned
218 /// off (see
219 /// [`Client::with_job_diagnostics`](crate::Client::with_job_diagnostics)),
220 /// or if asking for them failed — collecting them must never replace
221 /// the failure being reported.
222 jobs: Vec<JobFailure>,
223 },
224
225 /// A binary that a cluster node could not run was about to be uploaded.
226 #[error("{path} cannot run on a cluster node: {reason}")]
227 NotAWorker {
228 /// The binary that was refused.
229 path: String,
230 /// What is wrong with it, and what to do instead.
231 reason: String,
232 },
233
234 /// The environment did not describe a cluster to talk to.
235 #[error("{0}")]
236 Config(String),
237}
238
239/// Why a redirect was refused rather than followed.
240///
241/// The rules live with the transport that applies them; this is the half of
242/// them a caller can branch on. Each variant renders the clause the error
243/// message carries, so `refusal.to_string()` is what the user is told.
244#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
245#[non_exhaustive]
246pub enum RedirectRefusal {
247 /// The request carries credentials, and the redirect leaves the origin
248 /// they were addressed to.
249 ///
250 /// The one this crate exists to report. A same-origin redirect is followed
251 /// instead: the token reaches no host it was not already going to.
252 #[error(
253 "the request carries credentials and the redirect leaves the host they \
254 were addressed to. Following it drops the `Authorization` header — \
255 `ureq` does that by default — and the cluster then answers with a \
256 credentials failure about a token that may be perfectly good. The \
257 token was not sent to the host that answered, so start with the \
258 redirect rather than with the token."
259 )]
260 Credentials,
261
262 /// The request body cannot be sent a second time.
263 ///
264 /// A redirect is followed by sending the same request to the address it
265 /// named: same method, same payload. So a body is no reason to refuse one
266 /// — a bodiless `POST`, which is most of API v4, goes wherever it is
267 /// pointed, and a body held in memory goes with it.
268 ///
269 /// A body that is a **stream** cannot. `write_table` from an iterator and
270 /// every `raw_command_upload` read their body as it is sent, so by the
271 /// time the `3xx` arrives some of it has already gone and there is nothing
272 /// to rewind. Sending what is left would be a different request; sending
273 /// nothing is the expensive failure this refuses — a `write_table` that
274 /// arrived carrying no rows is answered much like one that succeeded.
275 #[error(
276 "the request body is read as it is sent, so this client cannot send it \
277 to the address the redirect named — a reader that has already begun \
278 to drain cannot be rewound. A write that arrived carrying no rows is \
279 answered much like one that succeeded, which is worse than failing. \
280 Send the body from memory, or address the host you meant to reach."
281 )]
282 Body,
283
284 /// The request carries data, and the redirect leaves the origin it was
285 /// addressed to.
286 ///
287 /// [`RedirectRefusal::Credentials`] asked again about the other thing a
288 /// caller chooses a host for. A token is not the only thing worth
289 /// withholding from a host nobody named: a table's rows are the caller's
290 /// own data, and a `Location` header is the far end of the connection
291 /// asking for them to be sent somewhere else. So this one does not wait
292 /// for a token to be present.
293 ///
294 /// A redirect that stays on the origin is followed, body and all — the
295 /// bytes were already going there. And a body of length zero is not data:
296 /// `Content-Length: 0` gives nothing away, so most of API v4 is unaffected.
297 #[error(
298 "the request carries data and the redirect leaves the host it was \
299 addressed to. Sending it on would hand the body to a host the caller \
300 never named, on the say-so of a header that arrived mid-flight. A \
301 redirect that stays on the same host is followed, body and all; to \
302 reach another one on purpose, ask the cluster for it and address it \
303 yourself."
304 )]
305 Payload,
306
307 /// The redirects did not end.
308 ///
309 /// This client follows a bounded number of same-origin hops; a balancer
310 /// pointing at itself is a loop, not a route.
311 #[error(
312 "the redirects did not end. This client follows a bounded number of \
313 them and that bound was reached, which is a loop rather than a route."
314 )]
315 TooMany,
316}
317
318/// The sentence a rejected root store needs, and nothing else does.
319///
320/// `invalid peer certificate: UnknownIssuer` is the whole of what a cluster
321/// behind a private CA says on its first request, and it names neither the two
322/// things that fix it nor the fact that this client's roots are not the
323/// machine's. Every internal installation begins there — the `yt` CLI and the Go
324/// SDK read the system store, so the machine where `curl` works is exactly the
325/// machine where this fails — and the message that arrives is one word about a
326/// certificate.
327///
328/// Classified by [`crate::retry::settled_certificate_verdict`] rather than by
329/// looking for the word here. That function narrows three times — an
330/// `ureq::Error::Io` of kind `InvalidData`, carrying `rustls`'s `invalid peer
331/// certificate: ` prefix, whose reason **starts with** a settled verdict — and
332/// every one of them matters to this message. A plain `contains("UnknownIssuer")`
333/// would fire on `Other(OtherError("UnknownIssuer lookup failed"))`, which
334/// `retry` deliberately treats as retriable: it is `rustls-platform-verifier`
335/// reporting a passing condition of *this machine*, so the advice would tell a
336/// build that already has the platform verifier to go and enable it.
337///
338/// Only `UnknownIssuer` gets this. `NotValidForName` is a certificate that does
339/// not cover the host asked for, which no root store mends, and pointing its
340/// reader at a CA bundle would send them to rewrite the one thing that is
341/// working.
342fn certificate_advice(source: &ureq::Error) -> &'static str {
343 if crate::retry::settled_certificate_verdict(source) == Some("UnknownIssuer") {
344 " The chain does not end in a root this client trusts, which is the \
345 Mozilla bundle compiled in and not what the machine trusts: point \
346 YT_CA_BUNDLE at a PEM file of roots (the `yt` CLI reads the same \
347 variable; on Linux the system bundle is usually \
348 /etc/ssl/certs/ca-certificates.crt), or build with the \
349 `platform-verifier` feature to trust whatever the operating system \
350 does."
351 } else {
352 ""
353 }
354}
355
356/// The sentence only a heavy command can act on. See [`ClientError::Redirected`].
357fn redirect_advice(heavy: &bool) -> &'static str {
358 if *heavy {
359 " Heavy commands belong on a heavy proxy: ask the cluster for one \
360 (`Client::heavy_proxy`) and address it directly."
361 } else {
362 ""
363 }
364}
365
366/// The cap, written the way the caller thinks about it.
367///
368/// `536870912` is the number a matcher wants and not the one a reader wants;
369/// `512 MiB` is the reverse. Both, then — the round one first, because the
370/// question the message answers is *how big is too big*, and nobody sizes a
371/// machine in bytes. Only a whole number of mebibytes gets the treatment: a
372/// test's cap of 4 096 reads better as itself than as `0.00390625 MiB`.
373fn cap_size(limit: &u64) -> String {
374 const MIB: u64 = 1024 * 1024;
375
376 if *limit >= MIB && limit.is_multiple_of(MIB) {
377 format!("{} MiB ({limit} bytes)", limit / MIB)
378 } else {
379 format!("{limit} bytes")
380 }
381}
382
383/// The way past the cap, for a command that has one. See
384/// [`ClientError::ResponseTooLarge`].
385///
386/// `the response body is larger than request limit: 536870912` — what `ureq`
387/// says — names neither the number a caller can plan around nor the method
388/// that makes the number irrelevant, and the streaming half of a read is a
389/// method a caller may not know exists. A command with no streaming half
390/// promises nothing.
391fn streaming_advice(command: &str) -> &'static str {
392 match command {
393 // Every `read_table` shape — `_with_format`, `_skiff_table`, `_rows` —
394 // sends this one command name.
395 "read_table" => " — Client::read_table_streaming moves the same bytes without holding them",
396 "read_file" => " — Client::read_file_streaming moves the same bytes without holding them",
397 _ => "",
398 }
399}
400
401fn body_hint(body: &str) -> String {
402 if body.trim().is_empty() {
403 String::new()
404 } else {
405 format!(": {}", body.trim())
406 }
407}
408
409fn failure_hint(error: &Option<String>) -> String {
410 match error {
411 Some(e) if !e.trim().is_empty() => format!(": {}", e.trim()),
412 _ => String::new(),
413 }
414}
415
416/// Renders the failed jobs under the operation's own line.
417///
418/// Deliberately multi-line: a job's stderr is what the user came for, and
419/// squeezing a panic message onto one line is how it becomes unreadable.
420fn jobs_hint(jobs: &[JobFailure]) -> String {
421 let mut out = String::new();
422
423 for job in jobs {
424 out.push_str("\n job ");
425 out.push_str(&job.id);
426 if let Some(address) = &job.address {
427 out.push_str(&format!(" on {address}"));
428 }
429 if let Some(error) = &job.error {
430 out.push_str(&format!(": {}", error.trim()));
431 }
432
433 if let Some(stderr) = &job.stderr
434 && !stderr.trim().is_empty()
435 {
436 out.push_str("\n stderr:");
437 for line in stderr.lines() {
438 out.push_str("\n ");
439 out.push_str(line);
440 }
441 }
442 }
443
444 out
445}
446
447impl ClientError {
448 /// Builds a [`ClientError::Cluster`] from an `X-YT-Error` document.
449 ///
450 /// Falls back to [`ClientError::Http`] if the document is not the shape
451 /// YTsaurus documents — better a slightly clumsy error than a panic while
452 /// reporting one.
453 pub(crate) fn from_yt_error(command: &str, status: u16, raw: &str) -> Self {
454 let parsed: Option<serde_json::Value> = serde_json::from_str(raw).ok();
455
456 match parsed {
457 Some(value) => {
458 let code = value
459 .get("code")
460 .and_then(serde_json::Value::as_i64)
461 .unwrap_or(-1);
462 let message = value
463 .get("message")
464 .and_then(serde_json::Value::as_str)
465 .unwrap_or("(no message)")
466 .to_owned();
467
468 // The useful detail is usually one level down.
469 let message = match innermost_message(&value) {
470 Some(inner) if inner != message => format!("{message}: {inner}"),
471 _ => message,
472 };
473
474 ClientError::Cluster {
475 command: command.to_owned(),
476 code,
477 message,
478 raw: raw.to_owned(),
479 }
480 }
481 None => ClientError::Http {
482 command: command.to_owned(),
483 status,
484 body: truncate(raw, 400),
485 },
486 }
487 }
488}
489
490/// Walks `inner_errors` to the deepest message, which is where YTsaurus tends
491/// to put the actual cause.
492fn innermost_message(value: &serde_json::Value) -> Option<String> {
493 let inner = value.get("inner_errors")?.as_array()?;
494 let first = inner.first()?;
495 innermost_message(first).or_else(|| {
496 first
497 .get("message")
498 .and_then(serde_json::Value::as_str)
499 .map(str::to_owned)
500 })
501}
502
503pub(crate) fn truncate(s: &str, limit: usize) -> String {
504 if s.len() <= limit {
505 return s.to_owned();
506 }
507 let mut end = limit;
508 while end > 0 && !s.is_char_boundary(end) {
509 end -= 1;
510 }
511 format!("{}… ({} bytes total)", &s[..end], s.len())
512}
513
514/// Keeps the **last** `limit` bytes of `s`, saying what was dropped.
515///
516/// The tail rather than the head, because this is used on a job's stderr: a job
517/// that logs as it works and then dies puts the reason last, and cutting from
518/// the front would keep the startup chatter and throw away the panic.
519pub(crate) fn tail(s: &str, limit: usize) -> String {
520 if s.len() <= limit {
521 return s.to_owned();
522 }
523 let mut start = s.len() - limit;
524 while start < s.len() && !s.is_char_boundary(start) {
525 start += 1;
526 }
527 format!(
528 "… ({} bytes total, last {} shown)\n{}",
529 s.len(),
530 s.len() - start,
531 &s[start..]
532 )
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 fn failure(jobs: Vec<JobFailure>) -> ClientError {
540 ClientError::OperationFailed {
541 id: "1-2-3-4".to_owned(),
542 state: "failed".to_owned(),
543 error: Some("Operation failed: User job failed".to_owned()),
544 jobs,
545 }
546 }
547
548 #[test]
549 fn a_failed_operation_reports_what_the_job_printed() {
550 let message = failure(vec![JobFailure {
551 id: "a-b-c-d".to_owned(),
552 address: Some("node.local:9012".to_owned()),
553 error: Some("User job failed: Process exited with code 101".to_owned()),
554 stderr: Some("boom: refusing row 7\nthread 'main' panicked".to_owned()),
555 }])
556 .to_string();
557
558 assert!(
559 message.contains("operation 1-2-3-4 finished as failed"),
560 "{message}"
561 );
562 assert!(
563 message.contains("job a-b-c-d on node.local:9012"),
564 "{message}"
565 );
566 assert!(
567 message.contains("Process exited with code 101"),
568 "{message}"
569 );
570 // The point of the whole feature: the job's own words, indented under it.
571 assert!(
572 message.contains("\n thread 'main' panicked"),
573 "{message}"
574 );
575 }
576
577 #[test]
578 fn a_failure_with_no_job_information_stays_one_line() {
579 let message = failure(Vec::new()).to_string();
580 assert_eq!(
581 message,
582 "operation 1-2-3-4 finished as failed: Operation failed: User job failed"
583 );
584 }
585
586 #[test]
587 fn a_job_with_empty_stderr_gets_no_stderr_block() {
588 let message = failure(vec![JobFailure {
589 id: "a-b-c-d".to_owned(),
590 address: None,
591 error: None,
592 stderr: Some(" \n".to_owned()),
593 }])
594 .to_string();
595
596 assert!(message.ends_with("job a-b-c-d"), "{message}");
597 }
598
599 #[test]
600 fn tail_keeps_the_end_and_says_how_much_it_dropped() {
601 let long = format!("{}the panic", "chatter\n".repeat(100));
602 let kept = tail(&long, 20);
603
604 assert!(kept.ends_with("the panic"), "{kept}");
605 assert!(
606 kept.contains(&format!("{} bytes total", long.len())),
607 "{kept}"
608 );
609 assert_eq!(tail("short", 20), "short");
610 }
611
612 #[test]
613 fn tail_does_not_cut_a_character_in_half() {
614 // Cut at every byte offset, so multi-byte characters are crossed
615 // mid-character. A job's stderr is arbitrary bytes; this must not
616 // panic, and what it keeps must still be the end of the text.
617 let text = "ошибка в джобе";
618 for limit in 0..=text.len() {
619 let kept = tail(text, limit);
620 // Everything after the "…" header, or the whole thing if it fits.
621 let suffix = kept.rsplit_once('\n').map_or(kept.as_str(), |(_, s)| s);
622
623 assert!(text.ends_with(suffix), "limit {limit}: {kept:?}");
624 assert!(suffix.len() <= limit.max(text.len()), "limit {limit}");
625 }
626 }
627
628 /// A transport failure carrying the `io::Error` `ureq` would have carried.
629 fn transport(message: &str) -> ClientError {
630 ClientError::Transport {
631 command: "get".to_owned(),
632 source: Box::new(ureq::Error::Io(std::io::Error::new(
633 std::io::ErrorKind::InvalidData,
634 message.to_owned(),
635 ))),
636 }
637 }
638
639 #[test]
640 fn an_untrusted_root_names_the_two_things_that_change_it() {
641 // Verbatim what a cluster behind a private CA answers on the very first
642 // request, before any YTsaurus logic runs. On its own it says nothing
643 // about whose roots were consulted or how to change them, and the
644 // machine it fails on is usually one where `curl` works.
645 let message = transport("invalid peer certificate: UnknownIssuer").to_string();
646
647 assert!(message.contains("UnknownIssuer"), "{message}");
648 assert!(message.contains("YT_CA_BUNDLE"), "{message}");
649 assert!(message.contains("platform-verifier"), "{message}");
650 }
651
652 #[test]
653 fn other_transport_failures_are_left_alone() {
654 // A certificate that does not cover the host asked for is not a root
655 // store problem, and a connection refused is not a TLS problem at all.
656 // Advising a CA bundle for either sends the reader to rewrite the one
657 // part of the configuration that is working.
658 for message in [
659 "invalid peer certificate: certificate not valid for name \
660 \"cluster.example.net\"",
661 "connection refused",
662 // The one that a `contains` would get wrong, and the reason this
663 // goes through `retry`'s classifier rather than looking for the
664 // word: `Other(..)` is `rustls-platform-verifier` reporting a
665 // passing condition of this machine — a revocation lookup that
666 // timed out, a trust store briefly unreadable — which `retry`
667 // treats as worth another attempt. Only that verifier produces it,
668 // so advising `platform-verifier` here would be advice to enable
669 // what is already on.
670 "invalid peer certificate: Other(OtherError(\"UnknownIssuer lookup failed\"))",
671 ] {
672 let rendered = transport(message).to_string();
673 // `ureq` renders an `Error::Io` with an `io: ` of its own, so this
674 // is the whole message and nothing has been appended to it.
675 assert_eq!(rendered, format!("get: transport error: io: {message}"));
676 }
677 }
678}