Skip to main content

ytsaurus_client/
retry.rs

1//! Repeating a request that failed for a reason that will pass.
2//!
3//! The rules come from the
4//! [HTTP command reference](https://ytsaurus.tech/docs/en/api/commands#retry):
5//!
6//! - a **non-mutating light** command can simply be repeated;
7//! - a **mutating light** command must carry a `mutation_id` — a GUID — in both
8//!   the original request and the retries, with `retry=%false` on the first and
9//!   `retry=%true` afterwards. The cluster keeps the first response for five to
10//!   ten minutes and hands it back instead of applying the change twice;
11//! - a **heavy** command cannot be retried at all. The documented way to make
12//!   one atomic is a transaction.
13//!
14//! Which failures are worth repeating follows the Python client's HTTP retry
15//! list (`get_retriable_errors` in `yt/python/yt/wrapper/http_helpers.py`):
16//! transport failures, request timeouts, an unavailable or overloaded proxy,
17//! and a banned one.
18//!
19//! With one exception, which no cluster reports and only a client can know: a
20//! transport failure that is the TLS layer **rejecting the cluster's
21//! certificate for a reason this client's own configuration decided** is a
22//! settled verdict, not a passing condition. It is reported at the first
23//! attempt — see [`rejected_the_certificate`], and [`SETTLED_REJECTIONS`] for
24//! how few of the TLS layer's complaints that actually is.
25
26use std::time::Duration;
27
28use crate::error::{ClientError, Result};
29
30/// How a command may be repeated — and, for a heavy one, where it goes.
31///
32/// The classification is the cluster's, not this crate's: each command declares
33/// whether it mutates and whether it is heavy, and the rules at the top of this
34/// module follow from those two bits. A modelled command has its answer written
35/// into its call site; [`Client::raw_command_with`](crate::Client::raw_command_with)
36/// is where a caller supplies one for a command this crate does not model.
37///
38/// **[`Repeatable::Never`] is the safe answer and the default there.** A retry
39/// of something that turned out to be mutating applies it twice, and a
40/// `mutation_id` only prevents that where the master's mutation cache covers
41/// the command — it does not cover the scheduler, which is why
42/// [`Client::abort_operation`](crate::Client::abort_operation) is `Never`
43/// despite being both light and mutating.
44///
45/// The enum is `#[non_exhaustive]`: the cluster's registry has more shapes than
46/// this crate has needed so far, and a caller that matches on it exhaustively
47/// would break the next time one of them earns a name.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum Repeatable {
51    /// Safe to repeat unchanged, with no mutation ID to deduplicate by.
52    ///
53    /// A non-mutating light command, which is the common case — and also a
54    /// mutating one the cluster answers the same way however many times it
55    /// arrives, where the mutation cache would not have covered it anyway.
56    /// [`Client::suspend_operation`](crate::Client::suspend_operation) and
57    /// [`Client::update_operation_parameters`](crate::Client::update_operation_parameters)
58    /// are the two: suspending a suspended operation is accepted, and setting
59    /// a pool assigns rather than increments.
60    ///
61    /// **Idempotent is not the same as consequence-free.** A retry sent after
62    /// the scheduler has let the operation go is answered `No such operation`,
63    /// so a change that was applied can still be reported as an error. Each of
64    /// those two commands says so; a mutating command classified here needs the
65    /// same reasoning written down beside it.
66    Freely,
67    /// Mutating and light: repeat it tagged with a `mutation_id`.
68    ///
69    /// The cluster keeps the first response for five to ten minutes and hands
70    /// it back rather than applying the change twice. See [`MutationId`].
71    WithMutationId,
72    /// Mutating outside the master's mutation cache. Sent once, whatever the
73    /// policy says, because there is nothing that would deduplicate a second
74    /// send and the first may already have been applied.
75    Never,
76    /// **Heavy**: table and file data, in either direction.
77    ///
78    /// Sent once, like [`Repeatable::Never`] and for the documented reason —
79    /// the way to make a heavy command atomic is a transaction, not a retry.
80    ///
81    /// It also decides **where** the command goes. A large installation gives
82    /// its proxies roles and refuses a heavy request on a control proxy, so
83    /// the client asks `/hosts` for a pool of proxies that will take one —
84    /// only when a heavy command needs it, and again when the answer outlives
85    /// its refresh interval. Nothing about the call site
86    /// changes: this is the same `isHeavy` bit of the cluster's command
87    /// registry that says the command cannot be repeated, and both answers
88    /// follow from writing it down once. The discovered host is constrained to
89    /// the configured address's own domain — see
90    /// [`Client::with_heavy_proxies_anywhere`](crate::Client::with_heavy_proxies_anywhere).
91    ///
92    /// `write_table`, `read_table`, `write_file`, `read_file`, `get_job_input`
93    /// and `get_job_stderr` are the modelled ones — every one of them declared
94    /// `isHeavy = true` in `REGISTER_ALL`/`REGISTER` in the cluster's
95    /// [driver registry](https://github.com/ytsaurus/ytsaurus/blob/main/yt/yt/client/driver/driver.cpp),
96    /// whose argument order is `(command, name, inDataType, outDataType,
97    /// isVolatile, isHeavy)`. A raw command that streams in either direction is
98    /// sent this way whatever the caller says, because streaming *is* the heavy
99    /// shape.
100    Heavy,
101}
102
103/// YTsaurus error codes worth a second attempt.
104///
105/// Deliberately short. Codes that mean "your request was wrong" — 500 is a
106/// resolve error, 501 an already-existing node — must never end up here: a
107/// retry cannot fix them and only delays the report.
108const RETRIABLE_CODES: &[i64] = &[
109    3,    // request timed out
110    100,  // transport error
111    105,  // RPC unavailable — the scheduler could not reach the master
112    108,  // request queue size limit exceeded
113    904,  // request rate limit exceeded
114    2100, // proxy banned
115];
116
117/// HTTP statuses worth a second attempt, when the cluster sent no error
118/// document to judge by.
119const RETRIABLE_STATUSES: &[u16] = &[429, 500, 502, 503, 504];
120
121/// How `rustls` 0.23 introduces a verdict on the peer's certificate.
122///
123/// Read out of its `Display for Error` rather than collected from messages as
124/// they were seen: `InvalidCertificate(reason)` renders as `invalid peer
125/// certificate: ` followed by the `Debug` of a `CertificateError`. That reason
126/// is what decides whether waiting could help, so it is read rather than
127/// discarded — see [`SETTLED_REJECTIONS`].
128const CERTIFICATE_VERDICT: &str = "invalid peer certificate: ";
129
130/// The certificate verdicts a second attempt cannot change.
131///
132/// Deliberately two, and both decided **here** rather than at the cluster:
133///
134/// - `UnknownIssuer` — the chain does not end in a root this client trusts. The
135///   root store is the same one a second later; only `YT_CA_BUNDLE` or the
136///   `platform-verifier` feature changes it.
137/// - `NotValidForName` — the certificate does not cover the host that was
138///   asked for. The host is the same one a second later too.
139/// - `certificate not valid for name ` — the **same** verdict, spelled the way
140///   it actually arrives. `rustls` renders `InvalidCertificate` with `Display`
141///   rather than `Debug` (`Error::fmt`), and `Display for CertificateError`
142///   gives the context-carrying variants prose instead of their variant name.
143///   The webpki verifier only ever builds `NotValidForNameContext` for a
144///   hostname mismatch — the bare `NotValidForName` above is unreachable in the
145///   default build — so matching the variant name alone would settle nothing
146///   and quietly cost five attempts for a certificate naming another host.
147///
148/// Everything else stays retriable, and the reason is the same in each case:
149/// the answer might genuinely differ next time.
150///
151/// - `Other(..)` is what `rustls-platform-verifier` — the whole point of the
152///   `platform-verifier` feature — maps a *platform* failure to: a revocation
153///   lookup that timed out, a trust store that could not be opened. Those are
154///   transient conditions of this machine, and classifying them here would
155///   turn the feature into a way of making the OS's bad afternoon permanent.
156/// - `Expired` and `NotValidYet` are a property of the certificate that
157///   answered, not of the fleet: a round-robin proxy set mid-rotation has some
158///   members already renewed, and the next connection may reach one of them.
159/// - `invalid certificate revocation list` is a CRL that could not be fetched
160///   or parsed — the same transient class as `Other`.
161/// - `peer sent no certificates` is a proxy that answered wrong once.
162const SETTLED_REJECTIONS: &[&str] = &[
163    "UnknownIssuer",
164    "NotValidForName",
165    "certificate not valid for name ",
166];
167
168/// How often, and how patiently, a failed request is repeated.
169///
170/// The default is five attempts with a doubling delay from one second, capped
171/// at ten — about fifteen seconds of patience, which covers a proxy restart or
172/// a scheduler reconnect without making a genuinely broken cluster feel hung.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub struct RetryPolicy {
175    attempts: u32,
176    initial_backoff: Duration,
177    max_backoff: Duration,
178    /// Whether a retry announces itself at all — on stderr, or as a `WARN`
179    /// event where the `tracing` feature is on. See [`RetryPolicy::quiet`].
180    report: bool,
181}
182
183impl Default for RetryPolicy {
184    fn default() -> Self {
185        Self {
186            attempts: 5,
187            initial_backoff: Duration::from_secs(1),
188            max_backoff: Duration::from_secs(10),
189            report: true,
190        }
191    }
192}
193
194impl RetryPolicy {
195    /// `attempts` tries in total, waiting `initial_backoff` after the first
196    /// failure and doubling up to `max_backoff`.
197    ///
198    /// `attempts` is clamped to at least one: zero attempts would mean never
199    /// sending the request at all.
200    #[must_use]
201    pub fn new(attempts: u32, initial_backoff: Duration, max_backoff: Duration) -> Self {
202        Self {
203            attempts: attempts.max(1),
204            initial_backoff,
205            max_backoff,
206            report: true,
207        }
208    }
209
210    /// Send once, report whatever comes back.
211    #[must_use]
212    pub fn none() -> Self {
213        Self::new(1, Duration::ZERO, Duration::ZERO)
214    }
215
216    /// The same policy, retrying without saying so.
217    ///
218    /// A retry normally announces itself on stderr, so a launcher that pauses
219    /// for fifteen seconds says why rather than looking hung. Inside a **job**
220    /// that same stream is the cluster's diagnostic channel — a bounded buffer
221    /// the operation UI shows, and the one the job's own messages go to — so a
222    /// worker that talks to the cluster while a proxy is flaky would fill it
223    /// with retry chatter.
224    ///
225    /// With the `tracing` feature on the announcement is a `WARN` event rather
226    /// than a line on stderr, and this mutes that too. Same reason: a
227    /// subscriber installed inside a job is, more often than not, writing to
228    /// the very buffer this exists to protect. [`RetryPolicy::loud`] puts the
229    /// messages back whichever form they take.
230    ///
231    /// A [`Client`](crate::Client) built inside a job is quiet already; this is
232    /// for choosing it anywhere else:
233    ///
234    /// ```
235    /// use ytsaurus_client::{Client, RetryPolicy};
236    ///
237    /// let client = Client::new("http://localhost:8000")
238    ///     .with_retries(RetryPolicy::default().quiet());
239    /// ```
240    #[must_use]
241    pub fn quiet(mut self) -> Self {
242        self.report = false;
243        self
244    }
245
246    /// The same policy, announcing each retry.
247    ///
248    /// The default outside a job, and what puts the messages back inside one —
249    /// a job whose stderr nobody else is using may well want them, and so does
250    /// one whose subscriber ships them somewhere other than stderr.
251    #[must_use]
252    pub fn loud(mut self) -> Self {
253        self.report = true;
254        self
255    }
256
257    /// Whether this policy says anything out loud at all.
258    ///
259    /// Read by the transport as well as by [`run`]: the one thing the client
260    /// announces that is not a retry — a `/hosts` answer it declined, see
261    /// [`crate::observe::declined`] — has to be muted by the same switch, for
262    /// the same reason. A job's stderr is the cluster's bounded diagnostic
263    /// buffer whatever the client is talking about.
264    pub(crate) fn reports(self) -> bool {
265        self.report
266    }
267
268    /// How long to wait after the `attempt`-th failure, counting from one.
269    fn backoff(self, attempt: u32) -> Duration {
270        let doubled = self
271            .initial_backoff
272            .checked_mul(1_u32.checked_shl(attempt - 1).unwrap_or(u32::MAX))
273            .unwrap_or(self.max_backoff);
274        doubled.min(self.max_backoff)
275    }
276}
277
278/// A GUID the cluster deduplicates a repeated mutation by.
279///
280/// The client generates one for every mutating command it may have to repeat,
281/// so retries never apply a change twice. Passing your own is for a stronger
282/// guarantee than one process can give itself: persist the ID, and replaying
283/// the command after a crash returns the original result rather than starting
284/// a second operation. See
285/// [`Client::start_operation_with`](crate::Client::start_operation_with).
286///
287/// **A replay must say that it is one.** The ID carries that flag, because the
288/// cluster does not infer it: sending a known ID again without it is refused
289/// with `Duplicate request is not marked as "retry"` rather than deduplicated.
290///
291/// ```
292/// use ytsaurus_client::MutationId;
293///
294/// let first = MutationId::new();       // the original request
295/// let again = first.as_retry();        // the same mutation, sent again
296///
297/// assert_eq!(first.as_str(), again.as_str());
298/// assert!(!first.is_retry() && again.is_retry());
299/// ```
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct MutationId {
302    id: String,
303    retry: bool,
304}
305
306impl MutationId {
307    /// A fresh ID, for an original request.
308    #[must_use]
309    pub fn new() -> Self {
310        Self {
311            id: generate(),
312            retry: false,
313        }
314    }
315
316    /// The same ID, marked as a replay of a request already sent.
317    ///
318    /// This is what makes the cluster return the first response instead of
319    /// refusing the duplicate.
320    #[must_use]
321    pub fn as_retry(&self) -> Self {
322        Self {
323            id: self.id.clone(),
324            retry: true,
325        }
326    }
327
328    /// The ID, as YTsaurus spells a GUID.
329    #[must_use]
330    pub fn as_str(&self) -> &str {
331        &self.id
332    }
333
334    /// Whether this send is a replay.
335    #[must_use]
336    pub fn is_retry(&self) -> bool {
337        self.retry
338    }
339}
340
341impl Default for MutationId {
342    fn default() -> Self {
343        Self::new()
344    }
345}
346
347impl std::fmt::Display for MutationId {
348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        f.write_str(&self.id)
350    }
351}
352
353/// Builds a GUID: four 32-bit numbers in hex, separated by `-`, as the command
354/// reference describes them and as the cluster's own IDs are printed —
355/// `b4ef546-e730447d-103e8-20cfe65`, with no leading zeros.
356///
357/// The bits come from [`crate::unique::word`], which is also where a
358/// [`TraceContext`](crate::TraceContext) draws its ids: the argument for why
359/// they do not repeat is the same one, and it is made once.
360fn generate() -> String {
361    let mut parts = [0_u32; 4];
362    for (i, pair) in parts.chunks_mut(2).enumerate() {
363        let value = crate::unique::word(i as u64);
364        pair[0] = (value >> 32) as u32;
365        pair[1] = value as u32;
366    }
367
368    format!(
369        "{:x}-{:x}-{:x}-{:x}",
370        parts[0], parts[1], parts[2], parts[3]
371    )
372}
373
374/// Whether **waiting** and sending the same request again could plausibly
375/// succeed.
376///
377/// This is the question the retry loop asks, and only that one. "Would asking
378/// somewhere else help?" is a different question with a different answer —
379/// see [`worth_asking_again`].
380pub(crate) fn is_retriable(error: &ClientError) -> bool {
381    match error {
382        // The request never got an answer: a refused connection, a reset, a
383        // timeout. Nothing about it says the command was wrong — unless it was
384        // the certificate that was refused, which no amount of waiting mends.
385        ClientError::Transport { source, .. } => !rejected_the_certificate(source),
386        ClientError::Http { status, .. } => RETRIABLE_STATUSES.contains(status),
387        ClientError::Cluster { code, raw, .. } => {
388            RETRIABLE_CODES.contains(code) || raw_contains_code(raw, RETRIABLE_CODES)
389        }
390        _ => false,
391    }
392}
393
394/// Whether the TLS layer refused the cluster's certificate.
395///
396/// A rejected chain is a settled question: the same roots will reject the same
397/// certificate a second later, and a third time after that. Retrying it turns a
398/// configuration mistake into fifteen seconds of doubling backoff before the
399/// same sentence — which is what a cluster behind a private CA cost, five
400/// attempts at a time, until `YT_CA_BUNDLE` existed to answer it.
401///
402/// It arrives as `ureq::Error::Io` rather than as one of `ureq`'s TLS variants:
403/// `rustls` wraps its own error in an `io::Error` of kind `InvalidData`
404/// (`ConnectionCommon::complete_io`) and `ureq` passes it through untouched.
405/// Reading the `rustls::Error` back out would mean depending on `rustls`
406/// directly, which this crate deliberately does not — `ureq` is its only door
407/// to TLS, and the whole `tls` feature is one line in a manifest because of it.
408/// So the kind narrows the error to the TLS layer (neither `ureq` nor
409/// `ureq-proto` produces `InvalidData`, and a failed decompression has a
410/// variant of its own) and the text says which TLS failure it was.
411///
412/// **Deliberately narrow, in three ways.** The kind confines it to the TLS
413/// layer; the prefix confines it to a verdict about the certificate rather than
414/// about the protocol — a disagreement mid-handshake may well be one busy proxy
415/// out of several; and the reason itself confines it to the two verdicts this
416/// client's own configuration decides, rather than to every unhappy thing a
417/// verifier can say. See [`SETTLED_REJECTIONS`], which is where that last
418/// narrowing is argued: an `Other(..)` from `rustls-platform-verifier` is a
419/// passing condition of this machine, and reading it as a verdict would make
420/// enabling the platform verifier a way of turning the operating system's bad
421/// afternoon into a permanent failure.
422///
423/// All three narrowings live in [`settled_certificate_verdict`], which answers
424/// *which* verdict rather than *whether* there was one. This is that question
425/// asked the way retrying needs it.
426fn rejected_the_certificate(error: &ureq::Error) -> bool {
427    settled_certificate_verdict(error).is_some()
428}
429
430/// Which settled verdict the TLS layer returned, if it returned one.
431///
432/// The three narrowings of [`rejected_the_certificate`], answering *which*
433/// rather than *whether*, because one caller needs to tell the verdicts apart:
434/// [`crate::error::certificate_advice`] has something to say about
435/// `UnknownIssuer` — the root store is not the machine's — and nothing to say
436/// about `NotValidForName`, which no root store mends.
437///
438/// Shared rather than re-derived there. Matching another crate's rendered prose
439/// is a thing to do **once**: a second site that reached for `contains` would
440/// re-open the `Other(OtherError("UnknownIssuer lookup failed"))` hole this one
441/// closes with `starts_with`, and would advise a `platform-verifier` build to
442/// enable the platform verifier.
443pub(crate) fn settled_certificate_verdict(error: &ureq::Error) -> Option<&'static str> {
444    let ureq::Error::Io(io) = error else {
445        return None;
446    };
447
448    if io.kind() != std::io::ErrorKind::InvalidData {
449        return None;
450    }
451
452    let message = io.to_string();
453    let (_, reason) = message.split_once(CERTIFICATE_VERDICT)?;
454
455    // `starts_with` and not `contains`: `Other(..)` wraps a message this crate
456    // did not write, and one that happened to quote `UnknownIssuer` would
457    // otherwise be read as one.
458    SETTLED_REJECTIONS
459        .iter()
460        .find(|settled| reason.starts_with(*settled))
461        .copied()
462}
463
464/// Looks for one of `wanted` anywhere in an error document.
465///
466/// The outer code is often a wrapper — `Request retries failed`, `Error
467/// resolving path` — while the code that decides anything sits in
468/// `inner_errors`. Every classifier in this crate that reads a cluster code
469/// has to walk the document for that reason, so the walk is written once and
470/// the list of codes is the caller's: [`is_retriable`] passes the retriable
471/// ones, and `Client::upload_worker_cached` passes `Access denied`.
472///
473/// A document that is not JSON at all answers `false` rather than failing: the
474/// outer code has already been consulted by then, and a classifier that
475/// returned an error would only be asked to guess again.
476pub(crate) fn raw_contains_code(raw: &str, wanted: &[i64]) -> bool {
477    let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
478        return false;
479    };
480    contains_code(&value, wanted)
481}
482
483/// The walk itself: this error's own code, then every error nested under it.
484fn contains_code(value: &serde_json::Value, wanted: &[i64]) -> bool {
485    if let Some(code) = value.get("code").and_then(serde_json::Value::as_i64)
486        && wanted.contains(&code)
487    {
488        return true;
489    }
490
491    value
492        .get("inner_errors")
493        .and_then(serde_json::Value::as_array)
494        .is_some_and(|inner| inner.iter().any(|error| contains_code(error, wanted)))
495}
496
497/// Whether putting the **question** to the cluster again could plausibly get a
498/// different answer.
499///
500/// Not the same question as [`is_retriable`], and the difference is the whole
501/// reason this exists. `is_retriable` asks *would waiting help?* — it decides
502/// whether to send the same request to the same place after a pause. This one
503/// asks *would asking again ever help?*, and its caller is the place that
504/// decides whether the client keeps or discards what `/hosts` told it:
505/// `Transport::base_for`, judging a failed **lookup** — the initial one and
506/// the periodic refresh alike. It is not going to re-send anything; it is
507/// choosing between "ask again soon" and "ask again an interval from now".
508/// (`Transport::after_heavy`, judging a failed heavy *command*, used to key
509/// on this too and no longer does — dropping a host from the pool turns on
510/// [`attributable_to_the_host`], and the difference between the two is #40.)
511///
512/// They differ for exactly the failures where the *addressee* is what was
513/// wrong rather than the moment. Every reason to wait is also a reason to ask
514/// again — a proxy that was restarting is one the coordinator may well name
515/// differently in a minute — so this starts from `is_retriable` and adds to it.
516///
517/// **Two arms belong here that this branch cannot yet write**, because the
518/// variants they name are introduced by sibling pull requests. Each is one
519/// line, and this function is shaped so that it is:
520///
521/// - **`ClientError::Redirected` → `true`** (#36, redirect credentials). A
522///   balancer that answered with a `Location` this client refuses to follow is
523///   not a permanent verdict on heavy routing: its routing may be different for
524///   the next request, and the thing that must not happen is *following* the
525///   redirect, not *asking* again. Left as `is_retriable`'s `false`, one such
526///   answer would disable heavy routing for the client's whole life.
527/// - **a rejected certificate → `false`** (#39, TLS CA bundle). A host this
528///   process does not trust will not become trusted by being asked twice, and
529///   the fix is a CA bundle rather than another question. That arm needs no
530///   line here: #39 narrows `is_retriable` to answer `false` for one, and
531///   `false` is the right answer on this side too.
532///
533/// Both sibling PRs have now landed, so both arms this function was shaped for
534/// are in place. #39 narrowed [`is_retriable`] to answer `false` for a rejected
535/// certificate, which is the right answer here too and needs no line of its own.
536/// #36's [`ClientError::Redirected`] is the line below: a balancer that answered
537/// `/hosts` with a `Location` this client refuses to follow is not a permanent
538/// verdict on heavy routing — its routing may differ for the next request — so
539/// the coordinator is worth asking again. Left at `is_retriable`'s `false`, one
540/// such answer would disable heavy routing for the client's whole life (#30
541/// behind a new message), which the merge integration test in
542/// `tests/combination.rs` guards against.
543pub(crate) fn worth_asking_again(error: &ClientError) -> bool {
544    is_retriable(error)
545        || refused_for_being_the_wrong_proxy(error)
546        || matches!(error, ClientError::Redirected { .. })
547}
548
549/// Whether a heavy command's failure is plausibly about the **host** it went
550/// to rather than about the request itself.
551///
552/// The third question, asked by exactly one caller: `Transport::after_heavy`,
553/// deciding whether to drop a discovered proxy from the pool so the next
554/// command picks another. It is *not* [`worth_asking_again`] — that one
555/// judges the `/hosts` lookup, and gating the drop on it was a real failure
556/// (#40): the two predicates agree everywhere except about a **rejected
557/// certificate**, which `is_retriable` deliberately answers `false` for
558/// (waiting cannot mend a verdict this client's own roots decided) and
559/// `worth_asking_again` inherits. But `NotValidForName` is a verdict about
560/// *one host's name* — the cluster's other proxies present certificates that
561/// match their own names perfectly well — so a client that would neither
562/// retry, nor re-ask, nor step past that host was pinned to it for the whole
563/// retry window, failing every heavy command against the one bad proxy in the
564/// fleet.
565///
566/// Both settled rejections belong here, not only the name mismatch.
567/// `UnknownIssuer` against one host of several is just as plausibly that
568/// host's own misissued chain, and the cost of being wrong is one dropped
569/// host per command until the pool empties — at which point the fallback and
570/// re-ask take over, which is where a fleet-wide misconfiguration was always
571/// going to end up.
572///
573/// Everything else is [`worth_asking_again`]'s answer unchanged: a refused
574/// connection, a 503, a control proxy refusing by role — the host's fault;
575/// a resolve error, a schema mismatch — the request's, and the pool keeps
576/// the host.
577pub(crate) fn attributable_to_the_host(error: &ClientError) -> bool {
578    matches!(error, ClientError::Transport { source, .. } if rejected_the_certificate(source))
579        || worth_asking_again(error)
580}
581
582/// Whether the proxy refused this because of the **role it has**.
583///
584/// The purest case of "waiting would not help and asking somewhere else would",
585/// and the reason the two predicates are separate. `Control proxy may not serve
586/// heavy requests with input data` arrives as an ordinary cluster error with
587/// code 1, which [`is_retriable`] correctly judges hopeless: sending it there
588/// again will be refused again, forever. Asking the *coordinator* again, on the
589/// other hand, is the entire fix — and a client that has been routed onto a
590/// control proxy (an operator can change `default_role_filter`, and `/hosts`
591/// then lists proxies that refuse this) would otherwise keep that address for
592/// its whole life and fail every heavy command with it.
593fn refused_for_being_the_wrong_proxy(error: &ClientError) -> bool {
594    matches!(
595        error,
596        ClientError::Cluster { message, .. } if message.contains(crate::http::CONTROL_REFUSAL)
597    )
598}
599
600/// Whether a fresh client should announce its retries.
601///
602/// Not inside a job. `YT_JOB_ID` is set by the node that starts one, and a
603/// job's stderr is the cluster's diagnostic channel rather than a terminal: a
604/// bounded buffer the operation UI shows, shared with whatever the job wanted
605/// to say. This crate is linked into worker binaries — that is the whole point
606/// of the one-binary pattern — so the same `Client::from_env()` runs in both
607/// roles, and the default has to be the one the caller cannot easily choose
608/// for itself. [`RetryPolicy::loud`] puts the messages back.
609pub(crate) fn report_by_default() -> bool {
610    !inside_job(std::env::var_os("YT_JOB_ID"))
611}
612
613/// The decision itself, split out so it can be tested without touching the
614/// process environment — which is global, and in edition 2024 unsafe to write.
615fn inside_job(job_id: Option<std::ffi::OsString>) -> bool {
616    job_id.is_some_and(|id| !id.is_empty())
617}
618
619/// Runs `action` until it succeeds, gives up, or fails for a reason a retry
620/// cannot fix.
621///
622/// `action` is told whether this is a retry, which is what a mutating command
623/// puts in its `retry` parameter. Each attempt is timed and named — see
624/// `observe::attempt` — and progress is reported unless the policy is
625/// [`RetryPolicy::quiet`]: a run that pauses for fifteen seconds should say why
626/// rather than look hung.
627pub(crate) fn run<T>(
628    policy: RetryPolicy,
629    repeatable: Repeatable,
630    command: &str,
631    mut action: impl FnMut(bool) -> Result<T>,
632) -> Result<T> {
633    let allowed = match repeatable {
634        Repeatable::Never | Repeatable::Heavy => 1,
635        _ => policy.attempts,
636    };
637
638    let mut attempt = 1;
639    loop {
640        match crate::observe::attempt(command, attempt, || action(attempt > 1)) {
641            Ok(value) => return Ok(value),
642            Err(error) => {
643                if attempt >= allowed || !is_retriable(&error) {
644                    return Err(error);
645                }
646
647                let wait = policy.backoff(attempt);
648                if policy.report {
649                    // `allowed`, not `allowed - 1`: the announcement counts
650                    // attempts, because the span beside it does. Reporting the
651                    // retry *number* against a retry total meant the same
652                    // field name carried two different counters — an event
653                    // saying `attempt=4, of=4` sat next to a span saying
654                    // `attempt=5`, and anything keying on `attempt == of` to
655                    // mean "the last try" fired one attempt early.
656                    crate::observe::retrying(command, &error, wait, attempt, allowed);
657                }
658                std::thread::sleep(wait);
659                attempt += 1;
660            }
661        }
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use std::cell::RefCell;
669
670    /// Zero backoff, so the tests do not sleep.
671    fn instant(attempts: u32) -> RetryPolicy {
672        RetryPolicy::new(attempts, Duration::ZERO, Duration::ZERO)
673    }
674
675    fn cluster_error(code: i64, raw: &str) -> ClientError {
676        ClientError::Cluster {
677            command: "get".to_owned(),
678            code,
679            message: "boom".to_owned(),
680            raw: raw.to_owned(),
681        }
682    }
683
684    #[test]
685    fn an_unavailable_cluster_is_worth_retrying() {
686        // Exactly what a local cluster answered while its scheduler was
687        // reconnecting to the master.
688        assert!(is_retriable(&cluster_error(105, r#"{"code":105}"#)));
689    }
690
691    #[test]
692    fn a_wrapper_error_is_judged_by_what_is_inside_it() {
693        // "Request retries failed" is a wrapper; the reason is one level down.
694        let raw = r#"{"code":1,"message":"Request retries failed",
695                      "inner_errors":[{"code":105,"message":"Master is not connected"}]}"#;
696        assert!(is_retriable(&cluster_error(1, raw)));
697    }
698
699    #[test]
700    fn a_mistake_is_not_retried() {
701        // 500 is a resolve error and 501 an already-existing node: repeating
702        // either just delays the report.
703        assert!(!is_retriable(&cluster_error(500, r#"{"code":500}"#)));
704        assert!(!is_retriable(&cluster_error(501, r#"{"code":501}"#)));
705        assert!(!is_retriable(&cluster_error(1, r#"{"code":1}"#)));
706    }
707
708    #[test]
709    fn an_unparseable_error_document_is_not_retried() {
710        assert!(!is_retriable(&cluster_error(1, "not json at all")));
711    }
712
713    #[test]
714    fn http_statuses_are_split_by_whether_waiting_helps() {
715        let http = |status| ClientError::Http {
716            command: "get".to_owned(),
717            status,
718            body: String::new(),
719        };
720
721        assert!(is_retriable(&http(503)));
722        assert!(is_retriable(&http(429)));
723        assert!(!is_retriable(&http(404)));
724        assert!(!is_retriable(&http(401)));
725    }
726
727    /// A transport failure carrying the `io::Error` `ureq` would have carried.
728    fn transport_error(kind: std::io::ErrorKind, message: &str) -> ClientError {
729        ClientError::Transport {
730            command: "get".to_owned(),
731            source: Box::new(ureq::Error::Io(std::io::Error::new(kind, message))),
732        }
733    }
734
735    #[test]
736    fn a_rejected_certificate_is_not_retried() {
737        // Exactly what a cluster behind a corporate CA answered with, before
738        // there was any way to name that CA: `rustls` wraps its own error in an
739        // `io::Error` of kind `InvalidData`, and `ureq` hands it through. Five
740        // attempts of this is fifteen seconds spent proving that the same roots
741        // still do not contain the same issuer.
742        assert!(!is_retriable(&transport_error(
743            std::io::ErrorKind::InvalidData,
744            "invalid peer certificate: UnknownIssuer"
745        )));
746
747        for rejection in [
748            "invalid peer certificate: NotValidForName",
749            // The form this verdict actually arrives in, and the one that
750            // matters: `rustls` renders `InvalidCertificate` with `Display`,
751            // and `Display for CertificateError` writes prose for the
752            // context-carrying variants rather than their variant name. The
753            // webpki verifier builds *only* `NotValidForNameContext` for a
754            // hostname mismatch, so this string — not the one above — is what
755            // a cluster whose certificate names another host produces.
756            "invalid peer certificate: certificate not valid for name \
757             \"cluster.example.net\"; certificate is only valid for \
758             DnsName(\"other.example.net\")",
759        ] {
760            assert!(
761                !is_retriable(&transport_error(std::io::ErrorKind::InvalidData, rejection)),
762                "{rejection}"
763            );
764        }
765    }
766
767    #[test]
768    fn a_platform_verifier_that_had_a_bad_afternoon_is_retried() {
769        // `rustls-platform-verifier` — which is what the `platform-verifier`
770        // feature turns on — maps every failure of the operating system's own
771        // machinery to `CertificateError::Other`, and that renders under the
772        // same `invalid peer certificate:` prefix as a verdict. A revocation
773        // lookup that timed out or a trust store that was momentarily
774        // unreadable is a condition, not a judgement, and reading it as one
775        // would make enabling the feature a way of turning the OS's bad
776        // afternoon into a permanent failure.
777        for message in [
778            "invalid peer certificate: Other(OtherError(TrustStoreUnavailable))",
779            "invalid peer certificate: Other(OtherError(RevocationLookupTimedOut))",
780            // Nor does quoting a settled reason inside one make it settled.
781            "invalid peer certificate: Other(OtherError(\"UnknownIssuer lookup failed\"))",
782        ] {
783            assert!(
784                is_retriable(&transport_error(std::io::ErrorKind::InvalidData, message)),
785                "{message}"
786            );
787        }
788    }
789
790    #[test]
791    fn a_certificate_that_may_be_one_proxy_out_of_several_is_retried() {
792        // A fleet answers round-robin, so these are properties of the member
793        // that happened to answer rather than of the installation. Mid-rotation
794        // some members are renewed and some are not; the next connection may
795        // reach a renewed one, and fifteen seconds is a cheap price for that
796        // against reporting a working cluster as broken.
797        for message in [
798            "invalid peer certificate: Expired",
799            "invalid peer certificate: NotValidYet",
800            "invalid peer certificate: Revoked",
801            // A revocation list that could not be fetched or parsed is the
802            // same transient class.
803            "invalid certificate revocation list: ParseError",
804            "peer sent no certificates",
805        ] {
806            assert!(
807                is_retriable(&transport_error(std::io::ErrorKind::InvalidData, message)),
808                "{message}"
809            );
810        }
811    }
812
813    #[test]
814    fn every_other_transport_failure_is_still_retried() {
815        // The narrowness is the point. A reset connection is the ordinary case
816        // this whole module exists for, and a TLS error that is not about the
817        // certificate may well be one busy proxy out of several.
818        for (kind, message) in [
819            (
820                std::io::ErrorKind::ConnectionReset,
821                "connection reset by peer",
822            ),
823            (std::io::ErrorKind::ConnectionRefused, "connection refused"),
824            (std::io::ErrorKind::TimedOut, "operation timed out"),
825            (std::io::ErrorKind::UnexpectedEof, "unexpected end of file"),
826            (
827                std::io::ErrorKind::InvalidData,
828                "received corrupt message of type Handshake",
829            ),
830            (
831                std::io::ErrorKind::InvalidData,
832                "peer misbehaved: TooManyEmptyFragments",
833            ),
834            // The right words, the wrong layer: a body that decompressed to
835            // nonsense is not a handshake.
836            (
837                std::io::ErrorKind::Other,
838                "invalid peer certificate: UnknownIssuer",
839            ),
840        ] {
841            assert!(is_retriable(&transport_error(kind, message)), "{message}");
842        }
843
844        // And a failure that never reached the TLS layer at all.
845        assert!(is_retriable(&ClientError::Transport {
846            command: "get".to_owned(),
847            source: Box::new(ureq::Error::HostNotFound),
848        }));
849    }
850
851    #[test]
852    fn a_rejected_certificate_costs_one_attempt_and_not_five() {
853        let calls = std::cell::Cell::new(0);
854
855        let result: Result<()> = run(instant(5), Repeatable::Freely, "get", |_| {
856            calls.set(calls.get() + 1);
857            Err(transport_error(
858                std::io::ErrorKind::InvalidData,
859                "invalid peer certificate: UnknownIssuer",
860            ))
861        });
862
863        assert!(result.is_err());
864        assert_eq!(
865            calls.get(),
866            1,
867            "a certificate is no likelier to be accepted on the fifth try"
868        );
869    }
870
871    #[test]
872    fn asking_again_is_a_different_question_from_waiting() {
873        // Two predicates, two questions. Everything worth waiting for is worth
874        // asking about again — a proxy that was restarting is one the
875        // coordinator may name differently in a minute — so this direction of
876        // the implication is the one that must hold on every branch.
877        for worth_waiting in [
878            ClientError::Transport {
879                command: "write_table".to_owned(),
880                source: Box::new(ureq::Error::HostNotFound),
881            },
882            ClientError::Http {
883                command: "hosts".to_owned(),
884                status: 503,
885                body: String::new(),
886            },
887            cluster_error(2100, r#"{"code":2100}"#),
888        ] {
889            assert!(is_retriable(&worth_waiting), "{worth_waiting}");
890            assert!(worth_asking_again(&worth_waiting), "{worth_waiting}");
891        }
892
893        // And the case that makes the split earn its keep: a proxy refusing a
894        // heavy command because of the role it has. Waiting cannot help — it
895        // will refuse the next one identically, forever — and asking the
896        // coordinator for another proxy is the entire fix. `/hosts` lists
897        // whatever `default_role_filter` says, which an operator can change, so
898        // a control proxy really can turn up in the answer.
899        let wrong_proxy = ClientError::Cluster {
900            command: "write_table".to_owned(),
901            code: 1,
902            message: "Control proxy may not serve heavy requests with input data".to_owned(),
903            raw: r#"{"code":1}"#.to_owned(),
904        };
905        assert!(!is_retriable(&wrong_proxy), "{wrong_proxy}");
906        assert!(worth_asking_again(&wrong_proxy), "{wrong_proxy}");
907
908        // And a settled answer is settled for both. A cluster with no `/hosts`
909        // endpoint answers 404 every time, so the lookup is remembered as
910        // "this cluster serves its own heavy commands" rather than repeated
911        // before every upload.
912        for settled in [
913            ClientError::Http {
914                command: "hosts".to_owned(),
915                status: 404,
916                body: String::new(),
917            },
918            ClientError::Decode {
919                command: "hosts".to_owned(),
920                reason: "not a list of host names".to_owned(),
921            },
922            ClientError::Config("no proxy".to_owned()),
923            cluster_error(500, r#"{"code":500}"#),
924        ] {
925            assert!(!is_retriable(&settled), "{settled}");
926            assert!(!worth_asking_again(&settled), "{settled}");
927        }
928    }
929
930    #[test]
931    fn a_rejected_certificate_is_the_hosts_fault_though_not_worth_waiting_or_asking() {
932        // The three predicates part company exactly here, and the parting is
933        // #40. Waiting cannot mend a verdict this client's own roots and URL
934        // decided, so `is_retriable` says no; the coordinator's list is not
935        // what was wrong, so `worth_asking_again` inherits the no. But
936        // `NotValidForName` is a verdict about *one host's name* — the rest
937        // of the fleet matches its own names fine — so the pool must drop
938        // that host and pick another. Gating the drop on either other
939        // predicate is the mutation this test exists to fail.
940        for spelling in [
941            "invalid peer certificate: UnknownIssuer",
942            "invalid peer certificate: certificate not valid for name \"n0132.example.net\"; \
943             certificate is only valid for [\"cluster.example.net\"]",
944            "invalid peer certificate: NotValidForName",
945        ] {
946            let rejected = transport_error(std::io::ErrorKind::InvalidData, spelling);
947            assert!(!is_retriable(&rejected), "{spelling}");
948            assert!(!worth_asking_again(&rejected), "{spelling}");
949            assert!(attributable_to_the_host(&rejected), "{spelling}");
950        }
951
952        // Everything worth asking the coordinator about again is also the
953        // host's fault — the implication only runs one way, and this is the
954        // direction that must hold on every branch.
955        for hosts_fault in [
956            ClientError::Transport {
957                command: "write_table".to_owned(),
958                source: Box::new(ureq::Error::HostNotFound),
959            },
960            ClientError::Http {
961                command: "write_table".to_owned(),
962                status: 503,
963                body: String::new(),
964            },
965            ClientError::Cluster {
966                command: "write_table".to_owned(),
967                code: 1,
968                message: "Control proxy may not serve heavy requests with input data".to_owned(),
969                raw: r#"{"code":1}"#.to_owned(),
970            },
971        ] {
972            assert!(worth_asking_again(&hosts_fault), "{hosts_fault}");
973            assert!(attributable_to_the_host(&hosts_fault), "{hosts_fault}");
974        }
975
976        // And a failure about the request keeps the host: the same command
977        // will be exactly as wrong at every other proxy in the pool.
978        for requests_fault in [
979            ClientError::Http {
980                command: "write_table".to_owned(),
981                status: 404,
982                body: String::new(),
983            },
984            cluster_error(500, r#"{"code":500}"#),
985            ClientError::Decode {
986                command: "read_table".to_owned(),
987                reason: "cut short".to_owned(),
988            },
989        ] {
990            assert!(
991                !attributable_to_the_host(&requests_fault),
992                "{requests_fault}"
993            );
994        }
995    }
996
997    #[test]
998    fn decode_and_config_errors_are_never_retried() {
999        assert!(!is_retriable(&ClientError::Config("no proxy".to_owned())));
1000        assert!(!is_retriable(&ClientError::Decode {
1001            command: "get".to_owned(),
1002            reason: "not yson".to_owned(),
1003        }));
1004    }
1005
1006    #[test]
1007    fn a_transient_failure_is_survived() {
1008        let calls = RefCell::new(Vec::new());
1009
1010        let result = run(instant(5), Repeatable::Freely, "get", |is_retry| {
1011            calls.borrow_mut().push(is_retry);
1012            if calls.borrow().len() < 3 {
1013                Err(cluster_error(105, r#"{"code":105}"#))
1014            } else {
1015                Ok(42)
1016            }
1017        });
1018
1019        assert_eq!(result.ok(), Some(42));
1020        // The first attempt is not a retry; the ones after it are, which is
1021        // exactly what goes into the `retry` parameter.
1022        assert_eq!(*calls.borrow(), vec![false, true, true]);
1023    }
1024
1025    #[test]
1026    fn attempts_are_bounded() {
1027        let calls = std::cell::Cell::new(0);
1028
1029        let result: Result<()> = run(instant(3), Repeatable::Freely, "get", |_| {
1030            calls.set(calls.get() + 1);
1031            Err(cluster_error(105, r#"{"code":105}"#))
1032        });
1033
1034        assert!(result.is_err());
1035        assert_eq!(calls.get(), 3, "three attempts, not three retries");
1036    }
1037
1038    #[test]
1039    fn a_heavy_command_is_sent_once() {
1040        for once in [Repeatable::Heavy, Repeatable::Never] {
1041            let calls = std::cell::Cell::new(0);
1042
1043            let result: Result<()> = run(instant(5), once, "write_table", |_| {
1044                calls.set(calls.get() + 1);
1045                Err(cluster_error(105, r#"{"code":105}"#))
1046            });
1047
1048            assert!(result.is_err());
1049            assert_eq!(
1050                calls.get(),
1051                1,
1052                "{once:?}: heavy commands cannot be retried, whatever the policy says"
1053            );
1054        }
1055    }
1056
1057    #[test]
1058    fn a_hopeless_error_stops_immediately() {
1059        let calls = std::cell::Cell::new(0);
1060
1061        let result: Result<()> = run(instant(5), Repeatable::Freely, "get", |_| {
1062            calls.set(calls.get() + 1);
1063            Err(cluster_error(500, r#"{"code":500}"#))
1064        });
1065
1066        assert!(result.is_err());
1067        assert_eq!(calls.get(), 1);
1068    }
1069
1070    #[test]
1071    fn no_retries_means_one_attempt() {
1072        let calls = std::cell::Cell::new(0);
1073
1074        let result: Result<()> = run(RetryPolicy::none(), Repeatable::Freely, "get", |_| {
1075            calls.set(calls.get() + 1);
1076            Err(cluster_error(105, r#"{"code":105}"#))
1077        });
1078
1079        assert!(result.is_err());
1080        assert_eq!(calls.get(), 1);
1081    }
1082
1083    #[test]
1084    fn backoff_doubles_and_then_stops_growing() {
1085        let policy = RetryPolicy::new(10, Duration::from_secs(1), Duration::from_secs(8));
1086
1087        assert_eq!(policy.backoff(1), Duration::from_secs(1));
1088        assert_eq!(policy.backoff(2), Duration::from_secs(2));
1089        assert_eq!(policy.backoff(3), Duration::from_secs(4));
1090        assert_eq!(policy.backoff(4), Duration::from_secs(8));
1091        assert_eq!(policy.backoff(5), Duration::from_secs(8));
1092        // A shift wide enough to overflow must saturate, not panic.
1093        assert_eq!(policy.backoff(64), Duration::from_secs(8));
1094        assert_eq!(policy.backoff(u32::MAX), Duration::from_secs(8));
1095    }
1096
1097    #[test]
1098    fn a_job_gets_a_quiet_client_and_a_terminal_a_talkative_one() {
1099        // A worker's stderr is the cluster's bounded diagnostic buffer, shared
1100        // with whatever the job itself writes. A launcher's is a terminal.
1101        assert!(inside_job(Some("55aff293-7ef14284-3fe0384-3e07".into())));
1102        assert!(!inside_job(None));
1103        // An empty variable is not a job, the same reading `ytsaurus-job` takes.
1104        assert!(!inside_job(Some(String::new().into())));
1105    }
1106
1107    #[test]
1108    fn quiet_changes_the_reporting_and_nothing_else() {
1109        let policy = RetryPolicy::default();
1110
1111        assert!(policy.report);
1112        assert!(!policy.quiet().report);
1113        assert!(policy.quiet().loud().report);
1114
1115        // Same patience either way: this is about the messages, not the waiting.
1116        assert_eq!(policy.quiet().attempts, policy.attempts);
1117        assert_eq!(policy.quiet().backoff(3), policy.backoff(3));
1118    }
1119
1120    #[test]
1121    fn a_policy_always_sends_the_request_at_least_once() {
1122        assert_eq!(
1123            RetryPolicy::new(0, Duration::ZERO, Duration::ZERO).attempts,
1124            1
1125        );
1126    }
1127
1128    #[test]
1129    fn a_replay_keeps_the_id_and_says_it_is_one() {
1130        // The cluster refuses a duplicate that does not admit to being one:
1131        // "Duplicate request is not marked as \"retry\"". So the flag travels
1132        // with the ID rather than being inferred.
1133        let original = MutationId::new();
1134        let replay = original.as_retry();
1135
1136        assert_eq!(original.as_str(), replay.as_str());
1137        assert!(!original.is_retry());
1138        assert!(replay.is_retry());
1139        assert_eq!(replay.as_retry().as_str(), original.as_str());
1140    }
1141
1142    #[test]
1143    fn mutation_ids_are_unique_and_shaped_like_guids() {
1144        let ids: std::collections::HashSet<String> =
1145            (0..10_000).map(|_| MutationId::new().id).collect();
1146        assert_eq!(
1147            ids.len(),
1148            10_000,
1149            "a repeated ID would deduplicate two different mutations"
1150        );
1151
1152        for id in ids.iter().take(100) {
1153            let groups: Vec<&str> = id.split('-').collect();
1154            assert_eq!(groups.len(), 4, "{id}");
1155            for group in groups {
1156                assert!(!group.is_empty(), "{id}");
1157                assert!(group.len() <= 8, "{id}");
1158                assert!(group.chars().all(|c| c.is_ascii_hexdigit()), "{id}");
1159            }
1160        }
1161    }
1162}