Skip to main content

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}