tsoracle_client/retry_policy.rs
1//
2// ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
3// ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
4// ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
5//
6// tsoracle — Distributed Timestamp Oracle
7// https://www.tsoracle.rs
8//
9// Copyright (c) 2026 Prisma Risk
10//
11// Licensed under the Apache License, Version 2.0 (the "License");
12// you may not use this file except in compliance with the License.
13// You may obtain a copy of the License at
14//
15// https://www.apache.org/licenses/LICENSE-2.0
16//
17// Unless required by applicable law or agreed to in writing, software
18// distributed under the License is distributed on an "AS IS" BASIS,
19// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20// See the License for the specific language governing permissions and
21// limitations under the License.
22//
23
24//! Retry and deadline policy for client RPCs.
25//!
26//! `per_attempt_deadline` bounds a single endpoint dial + RPC; the same
27//! value is pushed down to `tonic::transport::Endpoint::connect_timeout`
28//! and `Endpoint::timeout` so the transport layer also surfaces
29//! `DeadlineExceeded` rather than parking on an OS-default TCP timeout.
30//! `overall_deadline` bounds the whole `issue_rpc` call across all
31//! candidate endpoints — the retry loop short-circuits once it elapses,
32//! so a flapping leader cannot stretch a single caller's `request.await`
33//! across many RTTs. `max_attempts` caps the number of *failed* attempts
34//! (dialed endpoints that returned an error), floored at the initial worklist
35//! size so a single cold-cache sweep always dials every known endpoint at
36//! least once; it does not charge leader-hint redirects (issue #340).
37//! Pathological leader-hint redirect chains are bounded separately — by the
38//! worklist visited-set, the `overall_deadline`, the per-pass
39//! `MAX_LEADER_REDIRECTS` cap, and the absolute, cross-pass
40//! `MAX_TOTAL_LEADER_REDIRECTS` backstop in `crate::retry`.
41//! `base_backoff` is
42//! the unit for the jittered exponential backoff applied between
43//! attempts when the previous attempt returned `Unavailable` or
44//! `DeadlineExceeded` — see `crate::retry::issue_rpc`. `leader_ttl`
45//! caps how long a cached leader endpoint may be retained without a
46//! successful RPC against it; past the TTL the cache is treated as
47//! empty and the worklist falls back to the configured endpoint order.
48
49use std::time::Duration;
50
51/// Retry and deadline knobs for client RPCs.
52///
53/// Every field has a default chosen for steady-state availability; see
54/// [`RetryPolicy::default`] for the values. Pass a customised policy via
55/// [`crate::ClientBuilder::retry_policy`] when the defaults don't match
56/// your deployment (for example, longer `per_attempt_deadline` for
57/// cross-region servers, or tighter `overall_deadline` for
58/// latency-sensitive paths).
59#[derive(Debug, Clone)]
60pub struct RetryPolicy {
61 /// Maximum number of *failed* attempts `issue_rpc` will make before
62 /// returning the last error — that is, endpoints dialed that returned
63 /// an error, not total loop iterations.
64 ///
65 /// The budget is floored at the size of the initial worklist (the cached
66 /// leader, if fresh, plus the configured endpoints), so a single
67 /// cold-cache sweep always dials every endpoint at least once even when
68 /// `max_attempts` is smaller than the endpoint count. Without this floor,
69 /// the randomly seeded rotation offset in `ChannelPool::iter_round_robin`
70 /// could leave the only reachable endpoint untried whenever a pool has
71 /// more configured endpoints than `max_attempts`. The `overall_deadline`
72 /// still bounds the worst case, so a pool full of dead peers cannot dial
73 /// forever.
74 ///
75 /// Leader-hint redirects are not charged against this budget (they are
76 /// known discovery progress, bounded instead by the per-endpoint
77 /// visited-set, the `overall_deadline`, the per-pass `MAX_LEADER_REDIRECTS`
78 /// cap, and the absolute, cross-pass `MAX_TOTAL_LEADER_REDIRECTS` backstop in
79 /// `crate::retry`), so a legitimate failover redirect chain can be longer
80 /// than `max_attempts` and still reach the live leader (issue #340).
81 pub max_attempts: usize,
82 /// Wall-clock deadline applied to each `(connect, get_ts)` pair.
83 /// Pushed down to `Endpoint::connect_timeout` / `Endpoint::timeout`
84 /// for the built-in transport paths, and enforced by an outer
85 /// `tokio::time::timeout` for user-supplied
86 /// [`crate::ClientBuilder::channel_connector`] closures.
87 pub per_attempt_deadline: Duration,
88 /// Wall-clock deadline for the entire `issue_rpc` call. Once
89 /// elapsed, the retry loop returns the last observed error rather
90 /// than starting another attempt — even if `max_attempts` and the
91 /// worklist still have headroom.
92 pub overall_deadline: Duration,
93 /// Base unit for the jittered exponential backoff applied between
94 /// attempts whose last error was `Unavailable` or `DeadlineExceeded`.
95 /// The actual sleep is `rand_uniform(0, base_backoff * 2^attempt)`,
96 /// capped internally at [`RetryPolicy::MAX_BACKOFF`] so a long
97 /// `base_backoff` plus a high attempt count cannot consume the
98 /// overall deadline in a single sleep.
99 pub base_backoff: Duration,
100 /// Freshness bound on the cached leader endpoint. The cache is
101 /// touched on every successful RPC against the cached leader, so a
102 /// busy steady-state leader is retained indefinitely. The TTL only
103 /// bites when the leader falls quiet — past it, the next RPC
104 /// re-evaluates the configured endpoint list rather than pinning to
105 /// a possibly-dead endpoint that has not been re-validated within
106 /// the window.
107 pub leader_ttl: Duration,
108}
109
110impl RetryPolicy {
111 /// Upper bound on a single backoff sleep, regardless of
112 /// `base_backoff` and attempt count. Prevents the jittered
113 /// exponential from sleeping past the overall deadline in one step.
114 pub const MAX_BACKOFF: Duration = Duration::from_secs(5);
115}
116
117impl Default for RetryPolicy {
118 /// `max_attempts = 5`, `per_attempt_deadline = 2s`,
119 /// `overall_deadline = 10s`, `base_backoff = 50ms`,
120 /// `leader_ttl = 30s`. Five attempts at the per-attempt cap plus
121 /// modest backoff fit inside the overall deadline for the common
122 /// case; the loop returns `NoReachableEndpoints` (or the last
123 /// status) once exhausted. The 30-second TTL is roughly 3× the
124 /// overall deadline — long enough that a steady-state leader is
125 /// retained across normal request gaps, short enough that a leader
126 /// that fell silent at startup is re-evaluated before too many
127 /// callers fail-fast on the cached pin.
128 fn default() -> Self {
129 RetryPolicy {
130 max_attempts: 5,
131 per_attempt_deadline: Duration::from_secs(2),
132 overall_deadline: Duration::from_secs(10),
133 base_backoff: Duration::from_millis(50),
134 leader_ttl: Duration::from_secs(30),
135 }
136 }
137}
138
139/// Classify an error as a transport-layer problem with the connection
140/// itself: the peer was unreachable (`Unavailable`), the attempt timed out
141/// (`DeadlineExceeded` — including a half-open connection that black-holes
142/// until the per-attempt deadline rather than failing fast), or the
143/// transport failed to establish (`Transport`). Deterministic failures
144/// (`Internal`, `Unauthenticated`, a user-supplied connector's own error,
145/// `DriverGone`, etc.) are not transport problems.
146///
147/// This single predicate drives two policies that happen to share the same
148/// trigger today — backing off before the next attempt
149/// ([`should_backoff`]) and evicting the cached channel after the RPC fails
150/// (`crate::attempt::attempt`, issue #239). Keeping one definition means the
151/// two cannot silently drift apart; if they ever need to diverge, that must
152/// be a deliberate edit here.
153pub(crate) fn is_transport_failure(error: &crate::error::ClientError) -> bool {
154 use crate::error::ClientError;
155 match error {
156 ClientError::Rpc(status) => matches!(
157 status.code(),
158 tonic::Code::Unavailable | tonic::Code::DeadlineExceeded
159 ),
160 // `TransportFanout` is the fanned-out copy of a `Transport` failure
161 // (a coalesced sibling waiter's view), so it carries the same
162 // transport-failure semantics.
163 ClientError::Transport(_) | ClientError::TransportFanout(_) => true,
164 ClientError::NoReachableEndpoints
165 | ClientError::InvalidEndpoint(_)
166 | ClientError::InvalidCount(_)
167 | ClientError::Connector(_)
168 | ClientError::DriverGone
169 // Dense-sequence errors are pre-commit-certain deterministic rejections,
170 // not transport failures; they do not trigger backoff or channel eviction.
171 | ClientError::SeqUncertain
172 | ClientError::InvalidSeqKey => false,
173 }
174}
175
176/// Decide whether to back off before the next attempt based on the last
177/// error. Backoff applies to exactly the transport-failure class (see
178/// [`is_transport_failure`]): those are the "service unavailable"-style
179/// failures where pausing before retrying de-correlates a thundering herd.
180/// Other failures are deterministic — the next endpoint is tried
181/// immediately without sleep.
182pub(crate) fn should_backoff(error: &crate::error::ClientError) -> bool {
183 is_transport_failure(error)
184}
185
186/// Jittered exponential backoff. The returned duration is uniformly
187/// distributed in `[0, base * 2^attempt]`, capped at
188/// [`RetryPolicy::MAX_BACKOFF`]. Attempt 0 gives `[0, base]`; attempt 1
189/// gives `[0, 2*base]`; and so on. Full-jitter (AWS-style) is used
190/// because it minimises thundering-herd retries — every client picks
191/// an independent point in the window rather than clustering at the
192/// upper bound.
193pub(crate) fn jittered_backoff(base: Duration, attempt: u32) -> Duration {
194 if base.is_zero() {
195 return Duration::ZERO;
196 }
197 let shift = attempt.min(20);
198 let upper = base
199 .saturating_mul(1u32.checked_shl(shift).unwrap_or(u32::MAX))
200 .min(RetryPolicy::MAX_BACKOFF);
201 let upper_micros = upper.as_micros().min(u64::MAX as u128) as u64;
202 if upper_micros == 0 {
203 return Duration::ZERO;
204 }
205 let picked = rand::random_range(0..=upper_micros);
206 Duration::from_micros(picked)
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn defaults_match_documented_values() {
215 let policy = RetryPolicy::default();
216 assert_eq!(policy.max_attempts, 5);
217 assert_eq!(policy.per_attempt_deadline, Duration::from_secs(2));
218 assert_eq!(policy.overall_deadline, Duration::from_secs(10));
219 assert_eq!(policy.base_backoff, Duration::from_millis(50));
220 assert_eq!(policy.leader_ttl, Duration::from_secs(30));
221 }
222
223 #[test]
224 fn jittered_backoff_zero_base_returns_zero() {
225 for attempt in 0..5 {
226 assert_eq!(jittered_backoff(Duration::ZERO, attempt), Duration::ZERO);
227 }
228 }
229
230 #[test]
231 fn jittered_backoff_respects_window_at_attempt_zero() {
232 let base = Duration::from_millis(10);
233 for _ in 0..200 {
234 let picked = jittered_backoff(base, 0);
235 assert!(
236 picked <= base,
237 "attempt 0 must be in [0, base]; got {picked:?} > {base:?}"
238 );
239 }
240 }
241
242 #[test]
243 fn jittered_backoff_respects_window_at_higher_attempts() {
244 let base = Duration::from_millis(10);
245 for attempt in 1..6u32 {
246 let upper = base
247 .saturating_mul(1u32 << attempt)
248 .min(RetryPolicy::MAX_BACKOFF);
249 for _ in 0..200 {
250 let picked = jittered_backoff(base, attempt);
251 assert!(
252 picked <= upper,
253 "attempt {attempt} must be in [0, {upper:?}]; got {picked:?}"
254 );
255 }
256 }
257 }
258
259 #[test]
260 fn jittered_backoff_caps_at_max_backoff() {
261 let base = Duration::from_secs(10);
262 for _ in 0..200 {
263 let picked = jittered_backoff(base, 5);
264 assert!(
265 picked <= RetryPolicy::MAX_BACKOFF,
266 "saturating cap must hold; got {picked:?} > {:?}",
267 RetryPolicy::MAX_BACKOFF
268 );
269 }
270 }
271
272 #[test]
273 fn jittered_backoff_produces_a_distribution_not_a_constant() {
274 // Full-jitter: across many draws at the same attempt count, the
275 // picked duration must vary. If it were a constant (e.g. always
276 // upper bound, never sampled), the property test above would
277 // pass but the de-correlation goal would be defeated.
278 let base = Duration::from_millis(100);
279 let mut min = Duration::MAX;
280 let mut max = Duration::ZERO;
281 for _ in 0..200 {
282 let picked = jittered_backoff(base, 3);
283 if picked < min {
284 min = picked;
285 }
286 if picked > max {
287 max = picked;
288 }
289 }
290 assert!(
291 max > min,
292 "jittered_backoff must produce a distribution; min={min:?}, max={max:?}"
293 );
294 }
295
296 #[test]
297 fn should_backoff_matches_documented_status_codes() {
298 use crate::error::ClientError;
299 assert!(should_backoff(&ClientError::Rpc(
300 tonic::Status::unavailable("u")
301 )));
302 assert!(should_backoff(&ClientError::Rpc(
303 tonic::Status::deadline_exceeded("d")
304 )));
305 assert!(!should_backoff(&ClientError::Rpc(tonic::Status::internal(
306 "i"
307 ))));
308 assert!(!should_backoff(&ClientError::Rpc(
309 tonic::Status::failed_precondition("fp")
310 )));
311 assert!(!should_backoff(&ClientError::NoReachableEndpoints));
312 // `TransportFanout` is the coalesced-waiter copy of a `Transport`
313 // failure and must stay in the transport-failure class — distinct
314 // from the `NoReachableEndpoints` case directly above, which the
315 // fanout previously collapsed into (issue #241).
316 assert!(should_backoff(&ClientError::TransportFanout("t".into())));
317 assert!(!should_backoff(&ClientError::InvalidEndpoint("e".into())));
318 assert!(!should_backoff(&ClientError::InvalidCount(0)));
319 // `DriverGone` is deterministic: the local driver task is dead,
320 // so retrying without sleeping wouldn't gain anything (every
321 // retry returns `DriverGone` immediately until the `Client` is
322 // rebuilt). Sleeping would just delay the inevitable.
323 assert!(!should_backoff(&ClientError::DriverGone));
324 }
325}