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}