Skip to main content

provide_telemetry/
resilience.rs

1// SPDX-FileCopyrightText: Copyright (C) 2026 provide.io llc
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-Comment: Part of provide-telemetry.
4//
5
6use std::collections::BTreeMap;
7use std::future::Future;
8use std::sync::{Mutex, OnceLock};
9use std::time::{Duration, Instant};
10
11use crate::errors::TelemetryError;
12use crate::health::{increment_retries, record_export_failure, record_export_latency};
13use crate::sampling::Signal;
14
15pub(crate) const CIRCUIT_BREAKER_THRESHOLD: u32 = 3;
16pub(crate) const CIRCUIT_COOLDOWN: Duration = Duration::from_secs(30);
17/// Attempts ceiling: the first attempt plus [`crate::config::MAX_EXPORTER_RETRIES`]
18/// retries. Mirrors TypeScript's `MAX_EXPORT_ATTEMPTS = 101`.
19pub(crate) const MAX_EXPORT_ATTEMPTS: u32 = crate::config::MAX_EXPORTER_RETRIES as u32 + 1;
20
21/// Attempts for a retry policy, capped at [`MAX_EXPORT_ATTEMPTS`].
22///
23/// Config validation already rejects retries above the ceiling, but a policy
24/// can also be set programmatically via `set_exporter_policy`, and
25/// `retries + 1` on an unchecked `u32::MAX` would overflow. Defense in the
26/// same place TypeScript applies it (`resilience.ts`).
27fn capped_attempts(retries: u32) -> u32 {
28    retries.saturating_add(1).min(MAX_EXPORT_ATTEMPTS)
29}
30
31#[derive(Clone, Debug, PartialEq)]
32pub struct ExporterPolicy {
33    pub retries: u32,
34    pub backoff_seconds: f64,
35    pub timeout_seconds: f64,
36    pub fail_open: bool,
37    pub allow_blocking_in_event_loop: bool,
38}
39
40impl Default for ExporterPolicy {
41    fn default() -> Self {
42        Self {
43            retries: 0,
44            backoff_seconds: 0.0,
45            timeout_seconds: 10.0,
46            fail_open: true,
47            allow_blocking_in_event_loop: false,
48        }
49    }
50}
51
52#[derive(Clone, Debug, Default)]
53struct CircuitState {
54    consecutive_timeouts: u32,
55    tripped_at: Option<Instant>,
56    open_count: u32,
57    /// True while exactly one half-open probe is in flight.
58    half_open_probing: bool,
59}
60
61static POLICIES: OnceLock<Mutex<BTreeMap<Signal, ExporterPolicy>>> = OnceLock::new();
62static CIRCUITS: OnceLock<Mutex<BTreeMap<Signal, CircuitState>>> = OnceLock::new();
63
64fn default_policies_mutex() -> Mutex<BTreeMap<Signal, ExporterPolicy>> {
65    Mutex::new(BTreeMap::from([
66        (Signal::Logs, ExporterPolicy::default()),
67        (Signal::Traces, ExporterPolicy::default()),
68        (Signal::Metrics, ExporterPolicy::default()),
69    ]))
70}
71
72fn policies() -> &'static Mutex<BTreeMap<Signal, ExporterPolicy>> {
73    POLICIES.get_or_init(default_policies_mutex)
74}
75
76fn default_circuits_mutex() -> Mutex<BTreeMap<Signal, CircuitState>> {
77    Mutex::new(BTreeMap::from([
78        (Signal::Logs, CircuitState::default()),
79        (Signal::Traces, CircuitState::default()),
80        (Signal::Metrics, CircuitState::default()),
81    ]))
82}
83
84fn circuits() -> &'static Mutex<BTreeMap<Signal, CircuitState>> {
85    CIRCUITS.get_or_init(default_circuits_mutex)
86}
87
88fn backoff_duration(backoff_seconds: f64, has_tokio_reactor: bool) -> Option<Duration> {
89    if backoff_seconds <= 0.0 || !has_tokio_reactor {
90        None
91    } else {
92        Some(Duration::from_secs_f64(backoff_seconds))
93    }
94}
95
96async fn wait_before_retry(
97    signal: Signal,
98    attempt: u32,
99    backoff_seconds: f64,
100    has_tokio_reactor: bool,
101) {
102    if attempt == 0 {
103        return;
104    }
105    if let Some(backoff) = backoff_duration(backoff_seconds, has_tokio_reactor) {
106        tokio::time::sleep(backoff).await;
107    }
108    increment_retries(signal, 1);
109}
110
111pub fn set_exporter_policy(
112    signal: Signal,
113    policy: ExporterPolicy,
114) -> Result<ExporterPolicy, TelemetryError> {
115    crate::_lock::lock(policies()).insert(signal, policy.clone());
116    Ok(policy)
117}
118
119pub fn get_exporter_policy(signal: Signal) -> Result<ExporterPolicy, TelemetryError> {
120    let policy_lock = crate::_lock::lock(policies());
121    match policy_lock.get(&signal).cloned() {
122        Some(policy) => Ok(policy),
123        None => Err(TelemetryError::new("unknown signal")),
124    }
125}
126
127pub fn get_circuit_state(signal: Signal) -> Result<(String, u32, f64), TelemetryError> {
128    let circuits = crate::_lock::lock(circuits());
129    let state = match circuits.get(&signal).cloned() {
130        Some(state) => state,
131        None => return Err(TelemetryError::new("unknown signal")),
132    };
133    Ok(describe_circuit_state(&state))
134}
135
136/// Generic resilience primitive: wraps `operation` in the per-signal
137/// retry/timeout/circuit-breaker policy. Used by both [`run_with_resilience`]
138/// (which works in `TelemetryError`) and the OTel exporter wrappers in
139/// `otel/resilient.rs` (which work in `OTelSdkResult`). The error-type
140/// callbacks let each caller plug in its own variants — there is exactly one
141/// loop body, so retry/timeout/backoff/circuit semantics cannot drift between
142/// the two callsites.
143///
144/// * `timeout_err(d)` — synthesise the error returned when the wrapper-imposed
145///   `tokio::time::timeout` fires (operation never completed within `d`).
146/// * `is_sdk_timeout(&e)` — return true for SDK-reported timeouts that should
147///   also count toward the circuit breaker. Returns false for `TelemetryError`
148///   (which carries no timeout discriminator).
149/// * `circuit_open_err()` — synthesise the error returned when the breaker
150///   refuses an attempt (fail_open=false path only).
151pub(crate) async fn run_with_resilience_inner<F, Fut, T, E>(
152    signal: Signal,
153    policy: &ExporterPolicy,
154    operation: F,
155    timeout_err: impl Fn(Duration) -> E,
156    is_sdk_timeout: impl Fn(&E) -> bool,
157    circuit_open_err: impl Fn() -> E,
158) -> Result<Option<T>, E>
159where
160    F: Fn() -> Fut,
161    Fut: Future<Output = Result<T, E>>,
162{
163    let timeout = Duration::from_secs_f64(policy.timeout_seconds.max(0.0));
164    // tokio::time::timeout / sleep require an active tokio reactor. When the
165    // caller is the OTel SDK's dedicated BatchProcessor thread (which is NOT
166    // a tokio runtime), the timeout wrapper would panic with "there is no
167    // reactor running". Detect that case and fall through to a plain await:
168    // the underlying HTTP exporter still has its own timeout from
169    // SpanExporter::with_timeout, so the operation cannot hang forever.
170    let has_tokio_reactor = tokio::runtime::Handle::try_current().is_ok();
171    let timeout_active = if timeout.is_zero() {
172        false
173    } else {
174        has_tokio_reactor
175    };
176    // Circuit-breaker gate is only consulted when timeout enforcement is on,
177    // matching Python (resilience.py:177) and Go (resilience.go:170). When
178    // timeout=0 the policy explicitly opts out of timeout-driven failure
179    // accounting, so the breaker has no signal to act on.
180    let should_probe = if timeout_active {
181        _check_and_start_probe_for_wrappers(signal)
182    } else {
183        false
184    };
185    if should_probe {
186        return if policy.fail_open {
187            Ok(None)
188        } else {
189            Err(circuit_open_err())
190        };
191    }
192
193    let max_attempts = capped_attempts(policy.retries);
194    let mut last_err: Option<E> = None;
195    for attempt in 0..max_attempts {
196        wait_before_retry(signal, attempt, policy.backoff_seconds, has_tokio_reactor).await;
197
198        let started = Instant::now();
199        let (result, wrapper_timeout) = if !timeout_active {
200            (operation().await, false)
201        } else {
202            match tokio::time::timeout(timeout, operation()).await {
203                Ok(inner) => (inner, false),
204                Err(_) => (Err(timeout_err(timeout)), true),
205            }
206        };
207
208        match result {
209            Ok(value) => {
210                record_export_latency(signal, started.elapsed().as_secs_f64() * 1000.0);
211                _record_circuit_success_for_wrappers(signal);
212                return Ok(Some(value));
213            }
214            Err(err) => {
215                record_export_failure(signal);
216                // Only timeouts contribute to the breaker counter; other
217                // failures reset it. Mirrors Python (resilience.py:154),
218                // Go (resilience.go:118), and TS (resilience.ts:180).
219                let is_timeout = if wrapper_timeout {
220                    true
221                } else {
222                    is_sdk_timeout(&err)
223                };
224                _record_circuit_failure_for_wrappers(signal, is_timeout);
225                last_err = Some(err);
226            }
227        }
228    }
229
230    if policy.fail_open {
231        Ok(None)
232    } else {
233        // Safe: max_attempts >= 1, so the loop body has run at least once and
234        // populated last_err on any error path that escapes the loop.
235        Err(last_err.expect("retry loop ran at least once"))
236    }
237}
238
239fn cooldown_remaining(tripped_at: Option<Instant>) -> f64 {
240    match tripped_at {
241        Some(instant) => CIRCUIT_COOLDOWN
242            .saturating_sub(instant.elapsed())
243            .as_secs_f64(),
244        None => 0.0,
245    }
246}
247
248fn describe_circuit_state(state: &CircuitState) -> (String, u32, f64) {
249    if state.half_open_probing {
250        return ("half-open".to_string(), state.open_count, 0.0);
251    }
252    if state.consecutive_timeouts >= CIRCUIT_BREAKER_THRESHOLD {
253        let remaining = cooldown_remaining(state.tripped_at);
254        if remaining > 0.0 {
255            return ("open".to_string(), state.open_count, remaining);
256        }
257        return ("half-open".to_string(), state.open_count, 0.0);
258    }
259    ("closed".to_string(), state.open_count, 0.0)
260}
261
262pub async fn run_with_resilience<F, Fut, T>(
263    signal: Signal,
264    operation: F,
265) -> Result<Option<T>, TelemetryError>
266where
267    F: Fn() -> Fut,
268    Fut: Future<Output = Result<T, TelemetryError>>,
269{
270    let policy = get_exporter_policy(signal)?;
271    run_with_resilience_inner(
272        signal,
273        &policy,
274        operation,
275        |_| TelemetryError::new("operation timed out"),
276        |_| false,
277        || TelemetryError::new("circuit breaker open"),
278    )
279    .await
280}
281
282/// Record a failed export attempt into the shared circuit-breaker state.
283/// Called by resilient exporter wrappers in `otel/resilient.rs` which cannot
284/// use `run_with_resilience` directly (RPIT futures prevent `Fn() -> Fut`).
285/// Handles both normal (increment + trip) and half-open probe (re-open) paths.
286///
287/// `is_timeout` discriminates timeout failures from other errors. Only
288/// timeouts increment the breaker counter; other failures reset it. Mirrors
289/// Python (resilience.py:154), Go (resilience.go:118), and TS (resilience.ts:180).
290pub(crate) fn _record_circuit_failure_for_wrappers(signal: Signal, is_timeout: bool) {
291    let mut circuit_lock = crate::_lock::lock(circuits());
292    let Some(state) = circuit_lock.get_mut(&signal) else {
293        return;
294    };
295    if state.half_open_probing {
296        state.half_open_probing = false;
297        state.open_count += 1;
298        state.tripped_at = Some(Instant::now());
299        return;
300    }
301    if !is_timeout {
302        state.consecutive_timeouts = 0;
303        return;
304    }
305    state.consecutive_timeouts += 1;
306    if state.consecutive_timeouts >= CIRCUIT_BREAKER_THRESHOLD {
307        state.open_count += 1;
308        state.tripped_at = Some(Instant::now());
309    }
310}
311
312/// Record a successful export attempt — reset consecutive_timeouts so the
313/// circuit can close again. Handles half-open probe close. Called by resilient
314/// exporter wrappers.
315pub(crate) fn _record_circuit_success_for_wrappers(signal: Signal) {
316    let mut circuit_lock = crate::_lock::lock(circuits());
317    let Some(state) = circuit_lock.get_mut(&signal) else {
318        return;
319    };
320    if state.half_open_probing {
321        state.half_open_probing = false;
322    }
323    state.consecutive_timeouts = 0;
324}
325
326fn circuit_cooldown_is_active(elapsed: Duration) -> bool {
327    elapsed < CIRCUIT_COOLDOWN
328}
329
330/// Check whether the circuit for `signal` should be entered for a probe attempt,
331/// and if so mark the probe as in-flight. Returns `true` if the circuit is open
332/// (fully — cooldown still active) or if a probe is already running (concurrent
333/// callers should be rejected). Returns `false` if the operation may proceed
334/// (either circuit closed, or cooldown elapsed and this call starts the probe).
335pub(crate) fn _check_and_start_probe_for_wrappers(signal: Signal) -> bool {
336    let mut circuit_lock = crate::_lock::lock(circuits());
337    let Some(state) = circuit_lock.get_mut(&signal) else {
338        return false;
339    };
340    if state.consecutive_timeouts < CIRCUIT_BREAKER_THRESHOLD {
341        return false;
342    }
343    let cooldown_active = state
344        .tripped_at
345        .map(|instant| circuit_cooldown_is_active(instant.elapsed()))
346        .unwrap_or(false);
347    if cooldown_active {
348        return true; // Still open — reject.
349    }
350    if state.half_open_probing {
351        return true; // Probe already in flight — reject concurrent caller.
352    }
353    // Cooldown elapsed, no probe running — start one.
354    state.half_open_probing = true;
355    false
356}
357
358pub fn _reset_resilience_for_tests() {
359    *crate::_lock::lock(policies()) = BTreeMap::from([
360        (Signal::Logs, ExporterPolicy::default()),
361        (Signal::Traces, ExporterPolicy::default()),
362        (Signal::Metrics, ExporterPolicy::default()),
363    ]);
364    *crate::_lock::lock(circuits()) = BTreeMap::from([
365        (Signal::Logs, CircuitState::default()),
366        (Signal::Traces, CircuitState::default()),
367        (Signal::Metrics, CircuitState::default()),
368    ]);
369}
370
371pub fn _clear_resilience_state_for_tests() {
372    crate::_lock::lock(policies()).clear();
373    crate::_lock::lock(circuits()).clear();
374}
375
376#[cfg(test)]
377#[path = "resilience_tests.rs"]
378mod tests;
379
380#[cfg(test)]
381#[path = "resilience_inner_callback_tests.rs"]
382mod inner_callback_tests;
383
384#[cfg(test)]
385#[path = "resilience_state_tests.rs"]
386mod state_tests;