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
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}