Skip to main content

ruststream/runtime/
failure.rs

1//! One failure policy for the two unhappy dispatch paths: a handler that panics and a payload
2//! that fails to decode. Both funnel into the same [`FailurePolicy`] vocabulary, but carry
3//! separate per-subscriber defaults: a panic is an internal bug, so it
4//! [fails fast](FailurePolicy::FailFast); a decode failure is usually bad external input, so it
5//! [drops](FailurePolicy::Drop) the one bad message rather than letting any producer take the
6//! consumer down.
7
8use std::any::Any;
9use std::time::Duration;
10
11use super::handler::HandlerResult;
12use super::shutdown::Shutdown;
13
14/// What a subscriber does when it cannot process a message.
15///
16/// Set per subscriber, separately for a handler panic and for a decode failure, with the
17/// `#[subscriber(.., on_failure(panic = .., decode = ..))]` clause. Each maps onto a settlement
18/// of the offending message, except [`FailFast`](Self::FailFast), which tears the service down so
19/// an orchestrator restarts it.
20///
21/// # Examples
22///
23/// ```
24/// # #[cfg(all(feature = "macros", feature = "json"))]
25/// # mod demo {
26/// use std::time::Duration;
27///
28/// use ruststream::prelude::*;
29/// use serde::Deserialize;
30///
31/// #[derive(Deserialize)]
32/// struct Order {
33///     id: u64,
34/// }
35///
36/// /// A schema change rolls out producer-first: a payload this version cannot read yet comes
37/// /// back in a minute rather than being dropped.
38/// #[subscriber("orders", on_failure(decode = retry_after(Duration::from_secs(60))))]
39/// async fn accept(order: &Order) -> HandlerOutcome {
40///     tracing::info!(order.id, "accepted");
41///     HandlerOutcome::ack()
42/// }
43/// # }
44/// # fn main() {}
45/// ```
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum FailurePolicy {
49    /// Log a loud error naming the subscription, then initiate a graceful app shutdown and make
50    /// [`run`](super::RustStream::run) return `Err`, so an orchestrator restarts the service and
51    /// the failure lands in the logs. The default for a handler panic. Only takes effect under an
52    /// unwinding panic profile; with `panic = "abort"` the process is already gone and there is
53    /// nothing to catch.
54    FailFast,
55    /// Drop the message: `nack(false)`. The broker discards or dead-letters it. The default for a
56    /// decode failure - one malformed payload should not stop the consumer.
57    Drop,
58    /// Requeue the message: `nack(true)`. Useful when the failure is transient (a dependency not
59    /// yet available, a schema not yet propagated).
60    Retry,
61    /// Requeue the message, but ask the broker to redeliver no sooner than the given delay:
62    /// `nack_after(..)`. A broker without native delayed redelivery takes the copy the runtime
63    /// publishes instead - see
64    /// [`HandlerOutcome::retry_after`](super::HandlerOutcome::retry_after).
65    RetryAfter(Duration),
66    /// Acknowledge the failed message to move past it: `ack`. A deliberate poison-message escape
67    /// hatch - it advances past a message that could not be processed. This is not success; the
68    /// message is gone and was never handled.
69    Skip,
70}
71
72impl FailurePolicy {
73    /// Maps every non-fail-fast policy onto the [`HandlerResult`] that settles the message.
74    ///
75    /// [`FailFast`](Self::FailFast) has no settlement of its own (it tears the service down), so it
76    /// is excluded here; callers handle it before reaching this point.
77    pub(crate) const fn settlement(self) -> Option<HandlerResult> {
78        match self {
79            Self::FailFast => None,
80            Self::Drop => Some(HandlerResult::drop()),
81            Self::Retry => Some(HandlerResult::retry()),
82            Self::RetryAfter(delay) => Some(HandlerResult::retry_after(delay)),
83            Self::Skip => Some(HandlerResult::Ack),
84        }
85    }
86}
87
88/// The pair of failure policies a subscriber carries: one for a handler panic, one for a decode
89/// failure. Threaded from the def to the dispatch loop alongside the
90/// [`Workers`](super::Workers) policy.
91///
92/// # Examples
93///
94/// ```
95/// # #[cfg(all(feature = "macros", feature = "memory", feature = "json"))]
96/// # mod demo {
97/// use ruststream::memory::prelude::*;
98/// use serde::Deserialize;
99///
100/// #[derive(Deserialize)]
101/// struct Order {
102///     id: u64,
103/// }
104///
105/// #[subscriber]
106/// async fn ingest(order: &Order) -> HandlerOutcome {
107///     tracing::info!(order.id, "ingested");
108///     HandlerOutcome::ack()
109/// }
110///
111/// fn app() -> RustStream {
112///     RustStream::new(AppInfo::new("ingest", "0.1.0")).with_broker(MemoryBroker::new(), |b| {
113///         // An untrusted topic: a malformed payload is requeued, a handler bug still stops the
114///         // service (the default panic policy).
115///         b.include(
116///             ingest
117///                 .name("ingest")
118///                 .on_failure(FailurePolicies::default().with_decode(FailurePolicy::Retry)),
119///         );
120///     })
121/// }
122/// # }
123/// # fn main() {}
124/// ```
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126#[non_exhaustive]
127pub struct FailurePolicies {
128    /// What to do when the handler panics. Defaults to [`FailurePolicy::FailFast`].
129    pub panic: FailurePolicy,
130    /// What to do when a delivery's typed view cannot be materialized: the codec fails to decode
131    /// the payload, a [`Headers`](super::Headers) contract fails to parse, the delivery carries
132    /// no value ([`Carries`](crate::Carries)), or the broker reports that it could not read the
133    /// delivery ([`IncomingMessage::decode_error`](crate::IncomingMessage::decode_error)). One
134    /// policy covers all four - each is the same class of bad external input as a payload that
135    /// does not decode. Defaults to [`FailurePolicy::Drop`].
136    pub decode: FailurePolicy,
137}
138
139impl FailurePolicies {
140    /// Overrides the handler-panic policy, leaving the decode policy unchanged. The builder the
141    /// `#[subscriber]` macro uses to fill in the keys named in `on_failure(..)`.
142    #[must_use]
143    pub fn with_panic(mut self, policy: FailurePolicy) -> Self {
144        self.panic = policy;
145        self
146    }
147
148    /// Overrides the decode-failure policy, leaving the panic policy unchanged.
149    #[must_use]
150    pub fn with_decode(mut self, policy: FailurePolicy) -> Self {
151        self.decode = policy;
152        self
153    }
154}
155
156impl Default for FailurePolicies {
157    fn default() -> Self {
158        Self {
159            panic: FailurePolicy::FailFast,
160            decode: FailurePolicy::Drop,
161        }
162    }
163}
164
165/// What one dispatch loop needs to apply a [`FailurePolicy`]: the per-subscriber [policies] and the
166/// app-level [shutdown signal](Shutdown). The policies are captured when the subscriber
167/// is mounted (like [`Workers`](super::Workers)); the handle is supplied once at run time, shared
168/// by every loop so any of them can tear the service down.
169///
170/// [policies]: FailurePolicies
171#[derive(Debug, Clone)]
172pub(crate) struct DispatchFailure {
173    pub(crate) policies: FailurePolicies,
174    pub(crate) shutdown: Shutdown,
175}
176
177impl DispatchFailure {
178    /// Bundles the per-subscriber `policies` with the app-level error-shutdown `shutdown` handle.
179    pub(crate) fn new(policies: FailurePolicies, shutdown: Shutdown) -> Self {
180        Self { policies, shutdown }
181    }
182}
183
184/// Renders the payload of a caught panic as a human-readable reason, recovering the message a
185/// `panic!` / `unwrap` / `expect` carries (`&str` or `String`) and falling back to a placeholder
186/// for anything else.
187pub(crate) fn panic_reason(payload: &(dyn Any + Send)) -> String {
188    payload.downcast_ref::<&'static str>().map_or_else(
189        || {
190            payload
191                .downcast_ref::<String>()
192                .cloned()
193                .unwrap_or_else(|| "handler panicked".to_owned())
194        },
195        |s| (*s).to_owned(),
196    )
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn settlement_maps_every_non_fail_fast_policy() {
205        assert_eq!(FailurePolicy::FailFast.settlement(), None);
206        assert_eq!(
207            FailurePolicy::Drop.settlement(),
208            Some(HandlerResult::drop())
209        );
210        assert_eq!(
211            FailurePolicy::Retry.settlement(),
212            Some(HandlerResult::retry())
213        );
214        assert_eq!(
215            FailurePolicy::RetryAfter(Duration::from_secs(2)).settlement(),
216            Some(HandlerResult::retry_after(Duration::from_secs(2)))
217        );
218        assert_eq!(FailurePolicy::Skip.settlement(), Some(HandlerResult::Ack));
219    }
220
221    #[test]
222    fn defaults_are_fail_fast_panic_and_drop_decode() {
223        let policies = FailurePolicies::default();
224        assert_eq!(policies.panic, FailurePolicy::FailFast);
225        assert_eq!(policies.decode, FailurePolicy::Drop);
226    }
227
228    #[test]
229    fn builders_override_one_key_each() {
230        let policies = FailurePolicies::default()
231            .with_panic(FailurePolicy::Drop)
232            .with_decode(FailurePolicy::Skip);
233        assert_eq!(policies.panic, FailurePolicy::Drop);
234        assert_eq!(policies.decode, FailurePolicy::Skip);
235    }
236
237    #[test]
238    fn panic_reason_recovers_the_message() {
239        let as_str: &(dyn Any + Send) = &"boom";
240        assert_eq!(panic_reason(as_str), "boom");
241
242        let owned = String::from("owned boom");
243        let as_string: &(dyn Any + Send) = &owned;
244        assert_eq!(panic_reason(as_string), "owned boom");
245
246        let other: &(dyn Any + Send) = &42_u8;
247        assert_eq!(panic_reason(other), "handler panicked");
248    }
249}