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}