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}