Skip to main content

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 => false,
169    }
170}
171
172/// Decide whether to back off before the next attempt based on the last
173/// error. Backoff applies to exactly the transport-failure class (see
174/// [`is_transport_failure`]): those are the "service unavailable"-style
175/// failures where pausing before retrying de-correlates a thundering herd.
176/// Other failures are deterministic — the next endpoint is tried
177/// immediately without sleep.
178pub(crate) fn should_backoff(error: &crate::error::ClientError) -> bool {
179    is_transport_failure(error)
180}
181
182/// Jittered exponential backoff. The returned duration is uniformly
183/// distributed in `[0, base * 2^attempt]`, capped at
184/// [`RetryPolicy::MAX_BACKOFF`]. Attempt 0 gives `[0, base]`; attempt 1
185/// gives `[0, 2*base]`; and so on. Full-jitter (AWS-style) is used
186/// because it minimises thundering-herd retries — every client picks
187/// an independent point in the window rather than clustering at the
188/// upper bound.
189pub(crate) fn jittered_backoff(base: Duration, attempt: u32) -> Duration {
190    if base.is_zero() {
191        return Duration::ZERO;
192    }
193    let shift = attempt.min(20);
194    let upper = base
195        .saturating_mul(1u32.checked_shl(shift).unwrap_or(u32::MAX))
196        .min(RetryPolicy::MAX_BACKOFF);
197    let upper_micros = upper.as_micros().min(u64::MAX as u128) as u64;
198    if upper_micros == 0 {
199        return Duration::ZERO;
200    }
201    let picked = rand::random_range(0..=upper_micros);
202    Duration::from_micros(picked)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn defaults_match_documented_values() {
211        let policy = RetryPolicy::default();
212        assert_eq!(policy.max_attempts, 5);
213        assert_eq!(policy.per_attempt_deadline, Duration::from_secs(2));
214        assert_eq!(policy.overall_deadline, Duration::from_secs(10));
215        assert_eq!(policy.base_backoff, Duration::from_millis(50));
216        assert_eq!(policy.leader_ttl, Duration::from_secs(30));
217    }
218
219    #[test]
220    fn jittered_backoff_zero_base_returns_zero() {
221        for attempt in 0..5 {
222            assert_eq!(jittered_backoff(Duration::ZERO, attempt), Duration::ZERO);
223        }
224    }
225
226    #[test]
227    fn jittered_backoff_respects_window_at_attempt_zero() {
228        let base = Duration::from_millis(10);
229        for _ in 0..200 {
230            let picked = jittered_backoff(base, 0);
231            assert!(
232                picked <= base,
233                "attempt 0 must be in [0, base]; got {picked:?} > {base:?}"
234            );
235        }
236    }
237
238    #[test]
239    fn jittered_backoff_respects_window_at_higher_attempts() {
240        let base = Duration::from_millis(10);
241        for attempt in 1..6u32 {
242            let upper = base
243                .saturating_mul(1u32 << attempt)
244                .min(RetryPolicy::MAX_BACKOFF);
245            for _ in 0..200 {
246                let picked = jittered_backoff(base, attempt);
247                assert!(
248                    picked <= upper,
249                    "attempt {attempt} must be in [0, {upper:?}]; got {picked:?}"
250                );
251            }
252        }
253    }
254
255    #[test]
256    fn jittered_backoff_caps_at_max_backoff() {
257        let base = Duration::from_secs(10);
258        for _ in 0..200 {
259            let picked = jittered_backoff(base, 5);
260            assert!(
261                picked <= RetryPolicy::MAX_BACKOFF,
262                "saturating cap must hold; got {picked:?} > {:?}",
263                RetryPolicy::MAX_BACKOFF
264            );
265        }
266    }
267
268    #[test]
269    fn jittered_backoff_produces_a_distribution_not_a_constant() {
270        // Full-jitter: across many draws at the same attempt count, the
271        // picked duration must vary. If it were a constant (e.g. always
272        // upper bound, never sampled), the property test above would
273        // pass but the de-correlation goal would be defeated.
274        let base = Duration::from_millis(100);
275        let mut min = Duration::MAX;
276        let mut max = Duration::ZERO;
277        for _ in 0..200 {
278            let picked = jittered_backoff(base, 3);
279            if picked < min {
280                min = picked;
281            }
282            if picked > max {
283                max = picked;
284            }
285        }
286        assert!(
287            max > min,
288            "jittered_backoff must produce a distribution; min={min:?}, max={max:?}"
289        );
290    }
291
292    #[test]
293    fn should_backoff_matches_documented_status_codes() {
294        use crate::error::ClientError;
295        assert!(should_backoff(&ClientError::Rpc(
296            tonic::Status::unavailable("u")
297        )));
298        assert!(should_backoff(&ClientError::Rpc(
299            tonic::Status::deadline_exceeded("d")
300        )));
301        assert!(!should_backoff(&ClientError::Rpc(tonic::Status::internal(
302            "i"
303        ))));
304        assert!(!should_backoff(&ClientError::Rpc(
305            tonic::Status::failed_precondition("fp")
306        )));
307        assert!(!should_backoff(&ClientError::NoReachableEndpoints));
308        // `TransportFanout` is the coalesced-waiter copy of a `Transport`
309        // failure and must stay in the transport-failure class — distinct
310        // from the `NoReachableEndpoints` case directly above, which the
311        // fanout previously collapsed into (issue #241).
312        assert!(should_backoff(&ClientError::TransportFanout("t".into())));
313        assert!(!should_backoff(&ClientError::InvalidEndpoint("e".into())));
314        assert!(!should_backoff(&ClientError::InvalidCount(0)));
315        // `DriverGone` is deterministic: the local driver task is dead,
316        // so retrying without sleeping wouldn't gain anything (every
317        // retry returns `DriverGone` immediately until the `Client` is
318        // rebuilt). Sleeping would just delay the inevitable.
319        assert!(!should_backoff(&ClientError::DriverGone));
320    }
321}