Skip to main content

durable_lambda_core/
error.rs

1//! SDK error types for the durable-lambda framework.
2//!
3//! Provide a typed [`DurableError`] enum covering all failure modes:
4//! replay mismatches, checkpoint failures, serialization errors, and
5//! AWS SDK errors. All variants are constructed via static methods,
6//! never raw struct syntax.
7
8use std::error::Error as StdError;
9
10/// Represent all errors that can occur within the durable-lambda SDK.
11///
12/// Each variant carries rich context for diagnosing failures. Variants
13/// are constructed via static methods (e.g., [`DurableError::replay_mismatch`])
14/// to keep internal fields private.
15///
16/// # Examples
17///
18/// ```
19/// use durable_lambda_core::error::DurableError;
20///
21/// // Create a replay mismatch error.
22/// let err = DurableError::replay_mismatch("Step", "Wait", 3);
23/// assert!(err.to_string().contains("position 3"));
24/// assert!(err.to_string().contains("expected Step"));
25/// ```
26#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum DurableError {
29    /// A replay operation encountered a different operation type or name
30    /// than what was recorded in the execution history.
31    #[error("replay mismatch at position {position}: expected {expected}, got {actual}")]
32    #[non_exhaustive]
33    ReplayMismatch {
34        expected: String,
35        actual: String,
36        position: usize,
37    },
38
39    /// A checkpoint write or read operation failed.
40    #[error("checkpoint failed for operation '{operation_name}': {source}")]
41    #[non_exhaustive]
42    CheckpointFailed {
43        operation_name: String,
44        source: Box<dyn StdError + Send + Sync>,
45    },
46
47    /// Serialization of a value to JSON failed.
48    #[error("failed to serialize type '{type_name}': {source}")]
49    #[non_exhaustive]
50    Serialization {
51        type_name: String,
52        #[source]
53        source: serde_json::Error,
54    },
55
56    /// Deserialization of a value from JSON failed.
57    #[error("failed to deserialize type '{type_name}': {source}")]
58    #[non_exhaustive]
59    Deserialization {
60        type_name: String,
61        #[source]
62        source: serde_json::Error,
63    },
64
65    /// A general AWS SDK error occurred.
66    #[error("AWS SDK error: {0}")]
67    AwsSdk(Box<aws_sdk_lambda::Error>),
68
69    /// A specific AWS API operation error occurred.
70    #[error("AWS operation error: {0}")]
71    AwsSdkOperation(#[source] Box<dyn StdError + Send + Sync>),
72
73    /// A step retry has been scheduled — the function should exit.
74    ///
75    /// The SDK has checkpointed a RETRY action. The durable execution server
76    /// will re-invoke the Lambda after the configured delay. The handler must
77    /// propagate this error to exit cleanly.
78    #[error("step retry scheduled for operation '{operation_name}' — function should exit")]
79    #[non_exhaustive]
80    StepRetryScheduled { operation_name: String },
81
82    /// A wait operation has been checkpointed — the function should exit.
83    ///
84    /// The SDK has sent a START checkpoint with the wait duration. The durable
85    /// execution server will re-invoke the Lambda after the timer expires.
86    /// The handler must propagate this error to exit cleanly.
87    #[error("wait suspended for operation '{operation_name}' — function should exit")]
88    #[non_exhaustive]
89    WaitSuspended { operation_name: String },
90
91    /// A callback is pending — the function should exit and wait for an
92    /// external signal.
93    ///
94    /// The callback has been registered but no success/failure signal has
95    /// been received yet. The handler must propagate this error to exit.
96    /// The server will re-invoke the Lambda when the callback is signaled.
97    #[error("callback suspended for operation '{operation_name}' (callback_id: {callback_id}) — function should exit")]
98    #[non_exhaustive]
99    CallbackSuspended {
100        operation_name: String,
101        callback_id: String,
102    },
103
104    /// A callback failed, was cancelled, or timed out.
105    ///
106    /// The external system signaled failure, or the callback exceeded its
107    /// configured timeout. The error message contains details from the
108    /// callback's error object.
109    #[error("callback failed for operation '{operation_name}' (callback_id: {callback_id}): {error_message}")]
110    #[non_exhaustive]
111    CallbackFailed {
112        operation_name: String,
113        callback_id: String,
114        error_message: String,
115    },
116
117    /// An invoke operation is pending — the function should exit while
118    /// the target Lambda executes.
119    ///
120    /// The invoke START checkpoint has been sent. The server will invoke
121    /// the target function asynchronously and re-invoke this Lambda when
122    /// the target completes.
123    #[error("invoke suspended for operation '{operation_name}' — function should exit")]
124    #[non_exhaustive]
125    InvokeSuspended { operation_name: String },
126
127    /// An invoke operation failed, timed out, or was stopped.
128    ///
129    /// The target Lambda function returned an error, or the invoke
130    /// exceeded its configured timeout.
131    #[error("invoke failed for operation '{operation_name}': {error_message}")]
132    #[non_exhaustive]
133    InvokeFailed {
134        operation_name: String,
135        error_message: String,
136    },
137
138    /// A parallel operation failed.
139    ///
140    /// One or more branches encountered an unrecoverable error during
141    /// concurrent execution.
142    #[error("parallel failed for operation '{operation_name}': {error_message}")]
143    #[non_exhaustive]
144    ParallelFailed {
145        operation_name: String,
146        error_message: String,
147    },
148
149    /// A map operation failed.
150    ///
151    /// An unrecoverable error occurred during map collection processing.
152    /// Individual item failures are captured in the [`BatchResult`](crate::types::BatchResult)
153    /// rather than propagated as this error.
154    #[error("map failed for operation '{operation_name}': {error_message}")]
155    #[non_exhaustive]
156    MapFailed {
157        operation_name: String,
158        error_message: String,
159    },
160
161    /// A child context operation failed.
162    ///
163    /// The child context's closure returned an error, or the child context
164    /// was found in a failed/cancelled/timed-out state during replay.
165    ///
166    /// # Examples
167    ///
168    /// ```
169    /// use durable_lambda_core::error::DurableError;
170    ///
171    /// let err = DurableError::child_context_failed("sub_workflow", "closure returned error");
172    /// assert!(err.to_string().contains("sub_workflow"));
173    /// assert!(err.to_string().contains("closure returned error"));
174    /// ```
175    #[error("child context failed for operation '{operation_name}': {error_message}")]
176    #[non_exhaustive]
177    ChildContextFailed {
178        operation_name: String,
179        error_message: String,
180    },
181
182    /// A step exceeded its configured timeout.
183    ///
184    /// The step closure did not complete within the duration configured via
185    /// [`StepOptions::timeout_seconds`](crate::types::StepOptions::timeout_seconds).
186    /// The spawned task is aborted and this error is returned immediately.
187    ///
188    /// # Examples
189    ///
190    /// ```
191    /// use durable_lambda_core::error::DurableError;
192    ///
193    /// let err = DurableError::step_timeout("my_op");
194    /// assert!(err.to_string().contains("my_op"));
195    /// assert!(err.to_string().contains("timed out"));
196    /// assert_eq!(err.code(), "STEP_TIMEOUT");
197    /// ```
198    #[error("step timed out for operation '{operation_name}'")]
199    #[non_exhaustive]
200    StepTimeout { operation_name: String },
201
202    /// One or more compensation steps failed during saga rollback.
203    ///
204    /// The saga/compensation pattern ran `run_compensations()` and one or more
205    /// compensation closures returned an error. All compensations are attempted
206    /// regardless; this error captures the first/combined failure.
207    ///
208    /// # Examples
209    ///
210    /// ```
211    /// use durable_lambda_core::error::DurableError;
212    ///
213    /// let err = DurableError::compensation_failed("charge_payment", "payment reversal failed");
214    /// assert!(err.to_string().contains("charge_payment"));
215    /// assert!(err.to_string().contains("payment reversal failed"));
216    /// assert_eq!(err.code(), "COMPENSATION_FAILED");
217    /// ```
218    #[error("compensation failed for operation '{operation_name}': {error_message}")]
219    #[non_exhaustive]
220    CompensationFailed {
221        operation_name: String,
222        error_message: String,
223    },
224}
225
226impl DurableError {
227    /// Create a replay mismatch error.
228    ///
229    /// Use when the replay engine encounters an operation at a history position
230    /// that doesn't match the expected operation type or name.
231    ///
232    /// # Examples
233    ///
234    /// ```
235    /// use durable_lambda_core::error::DurableError;
236    ///
237    /// let err = DurableError::replay_mismatch("Step", "Wait", 5);
238    /// assert!(err.to_string().contains("expected Step"));
239    /// assert!(err.to_string().contains("got Wait"));
240    /// assert!(err.to_string().contains("position 5"));
241    /// ```
242    pub fn replay_mismatch(
243        expected: impl Into<String>,
244        actual: impl Into<String>,
245        position: usize,
246    ) -> Self {
247        Self::ReplayMismatch {
248            expected: expected.into(),
249            actual: actual.into(),
250            position,
251        }
252    }
253
254    /// Create a checkpoint failure error.
255    ///
256    /// Use when writing or reading a checkpoint to/from the durable
257    /// execution backend fails.
258    ///
259    /// # Examples
260    ///
261    /// ```
262    /// use durable_lambda_core::error::DurableError;
263    /// use std::io;
264    ///
265    /// let source = io::Error::new(io::ErrorKind::TimedOut, "connection timed out");
266    /// let err = DurableError::checkpoint_failed("charge_payment", source);
267    /// assert!(err.to_string().contains("charge_payment"));
268    /// ```
269    pub fn checkpoint_failed(
270        operation_name: impl Into<String>,
271        source: impl StdError + Send + Sync + 'static,
272    ) -> Self {
273        Self::CheckpointFailed {
274            operation_name: operation_name.into(),
275            source: Box::new(source),
276        }
277    }
278
279    /// Create a serialization error.
280    ///
281    /// Use when `serde_json::to_value` or `serde_json::to_string` fails
282    /// for a checkpoint value.
283    ///
284    /// # Examples
285    ///
286    /// ```
287    /// use durable_lambda_core::error::DurableError;
288    ///
289    /// // Simulate a serde error by deserializing invalid JSON.
290    /// let serde_err = serde_json::from_str::<i32>("not a number").unwrap_err();
291    /// let err = DurableError::serialization("OrderPayload", serde_err);
292    /// assert!(err.to_string().contains("OrderPayload"));
293    /// ```
294    pub fn serialization(type_name: impl Into<String>, source: serde_json::Error) -> Self {
295        Self::Serialization {
296            type_name: type_name.into(),
297            source,
298        }
299    }
300
301    /// Create a deserialization error.
302    ///
303    /// Use when `serde_json::from_value` or `serde_json::from_str` fails
304    /// for a cached checkpoint value during replay.
305    ///
306    /// # Examples
307    ///
308    /// ```
309    /// use durable_lambda_core::error::DurableError;
310    ///
311    /// // Simulate a serde error by deserializing invalid JSON.
312    /// let serde_err = serde_json::from_str::<i32>("not a number").unwrap_err();
313    /// let err = DurableError::deserialization("OrderResult", serde_err);
314    /// assert!(err.to_string().contains("OrderResult"));
315    /// assert!(err.to_string().contains("deserialize"));
316    /// ```
317    pub fn deserialization(type_name: impl Into<String>, source: serde_json::Error) -> Self {
318        Self::Deserialization {
319            type_name: type_name.into(),
320            source,
321        }
322    }
323
324    /// Create an AWS API operation error.
325    ///
326    /// Use for specific AWS API call failures (e.g., `SdkError` from
327    /// individual operations) that are distinct from the general
328    /// `aws_sdk_lambda::Error`.
329    ///
330    /// # Examples
331    ///
332    /// ```
333    /// use durable_lambda_core::error::DurableError;
334    /// use std::io;
335    ///
336    /// let source = io::Error::new(io::ErrorKind::Other, "service unavailable");
337    /// let err = DurableError::aws_sdk_operation(source);
338    /// assert!(err.to_string().contains("service unavailable"));
339    /// ```
340    pub fn aws_sdk_operation(source: impl StdError + Send + Sync + 'static) -> Self {
341        Self::AwsSdkOperation(Box::new(source))
342    }
343
344    /// Create a step retry scheduled signal.
345    ///
346    /// Use when a step has been checkpointed with RETRY and the function
347    /// should exit so the server can re-invoke after the configured delay.
348    ///
349    /// # Examples
350    ///
351    /// ```
352    /// use durable_lambda_core::error::DurableError;
353    ///
354    /// let err = DurableError::step_retry_scheduled("charge_payment");
355    /// assert!(err.to_string().contains("charge_payment"));
356    /// ```
357    pub fn step_retry_scheduled(operation_name: impl Into<String>) -> Self {
358        Self::StepRetryScheduled {
359            operation_name: operation_name.into(),
360        }
361    }
362
363    /// Create a wait suspended signal.
364    ///
365    /// Use when a wait operation has been checkpointed with START and the
366    /// function should exit so the server can re-invoke after the timer.
367    ///
368    /// # Examples
369    ///
370    /// ```
371    /// use durable_lambda_core::error::DurableError;
372    ///
373    /// let err = DurableError::wait_suspended("cooldown_delay");
374    /// assert!(err.to_string().contains("cooldown_delay"));
375    /// ```
376    pub fn wait_suspended(operation_name: impl Into<String>) -> Self {
377        Self::WaitSuspended {
378            operation_name: operation_name.into(),
379        }
380    }
381
382    /// Create a callback suspended signal.
383    ///
384    /// Use when a callback has been registered but not yet signaled.
385    /// The handler must propagate this to exit so the server can wait
386    /// for the external signal.
387    ///
388    /// # Examples
389    ///
390    /// ```
391    /// use durable_lambda_core::error::DurableError;
392    ///
393    /// let err = DurableError::callback_suspended("approval", "cb-123");
394    /// assert!(err.to_string().contains("approval"));
395    /// assert!(err.to_string().contains("cb-123"));
396    /// ```
397    pub fn callback_suspended(
398        operation_name: impl Into<String>,
399        callback_id: impl Into<String>,
400    ) -> Self {
401        Self::CallbackSuspended {
402            operation_name: operation_name.into(),
403            callback_id: callback_id.into(),
404        }
405    }
406
407    /// Create a callback failed error.
408    ///
409    /// Use when a callback was signaled with failure, cancelled, or timed out.
410    ///
411    /// # Examples
412    ///
413    /// ```
414    /// use durable_lambda_core::error::DurableError;
415    ///
416    /// let err = DurableError::callback_failed("approval", "cb-123", "rejected by reviewer");
417    /// assert!(err.to_string().contains("approval"));
418    /// assert!(err.to_string().contains("cb-123"));
419    /// assert!(err.to_string().contains("rejected by reviewer"));
420    /// ```
421    pub fn callback_failed(
422        operation_name: impl Into<String>,
423        callback_id: impl Into<String>,
424        error_message: impl Into<String>,
425    ) -> Self {
426        Self::CallbackFailed {
427            operation_name: operation_name.into(),
428            callback_id: callback_id.into(),
429            error_message: error_message.into(),
430        }
431    }
432
433    /// Create an invoke suspended signal.
434    ///
435    /// Use when an invoke START checkpoint has been sent and the function
436    /// should exit while the target Lambda executes.
437    ///
438    /// # Examples
439    ///
440    /// ```
441    /// use durable_lambda_core::error::DurableError;
442    ///
443    /// let err = DurableError::invoke_suspended("call_processor");
444    /// assert!(err.to_string().contains("call_processor"));
445    /// ```
446    pub fn invoke_suspended(operation_name: impl Into<String>) -> Self {
447        Self::InvokeSuspended {
448            operation_name: operation_name.into(),
449        }
450    }
451
452    /// Create an invoke failed error.
453    ///
454    /// Use when the target Lambda returned an error, timed out, or was stopped.
455    ///
456    /// # Examples
457    ///
458    /// ```
459    /// use durable_lambda_core::error::DurableError;
460    ///
461    /// let err = DurableError::invoke_failed("call_processor", "target function timed out");
462    /// assert!(err.to_string().contains("call_processor"));
463    /// assert!(err.to_string().contains("timed out"));
464    /// ```
465    pub fn invoke_failed(
466        operation_name: impl Into<String>,
467        error_message: impl Into<String>,
468    ) -> Self {
469        Self::InvokeFailed {
470            operation_name: operation_name.into(),
471            error_message: error_message.into(),
472        }
473    }
474
475    /// Create a parallel failed error.
476    ///
477    /// Use when a parallel operation encounters an unrecoverable error.
478    ///
479    /// # Examples
480    ///
481    /// ```
482    /// use durable_lambda_core::error::DurableError;
483    ///
484    /// let err = DurableError::parallel_failed("fan_out", "branch 2 panicked");
485    /// assert!(err.to_string().contains("fan_out"));
486    /// ```
487    pub fn parallel_failed(
488        operation_name: impl Into<String>,
489        error_message: impl Into<String>,
490    ) -> Self {
491        Self::ParallelFailed {
492            operation_name: operation_name.into(),
493            error_message: error_message.into(),
494        }
495    }
496
497    /// Create a map failed error.
498    ///
499    /// Use when a map operation encounters an unrecoverable error (e.g.,
500    /// checkpoint failure, task panic). Individual item failures are captured
501    /// in [`BatchResult`](crate::types::BatchResult), not as this error.
502    ///
503    /// # Examples
504    ///
505    /// ```
506    /// use durable_lambda_core::error::DurableError;
507    ///
508    /// let err = DurableError::map_failed("process_orders", "item 3 panicked");
509    /// assert!(err.to_string().contains("process_orders"));
510    /// assert!(err.to_string().contains("item 3 panicked"));
511    /// ```
512    pub fn map_failed(operation_name: impl Into<String>, error_message: impl Into<String>) -> Self {
513        Self::MapFailed {
514            operation_name: operation_name.into(),
515            error_message: error_message.into(),
516        }
517    }
518
519    /// Create a child context failed error.
520    ///
521    /// Use when a child context operation fails during execution or is
522    /// found in a failed state during replay.
523    ///
524    /// # Examples
525    ///
526    /// ```
527    /// use durable_lambda_core::error::DurableError;
528    ///
529    /// let err = DurableError::child_context_failed("sub_workflow", "closure returned error");
530    /// assert!(err.to_string().contains("sub_workflow"));
531    /// assert!(err.to_string().contains("closure returned error"));
532    /// ```
533    pub fn child_context_failed(
534        operation_name: impl Into<String>,
535        error_message: impl Into<String>,
536    ) -> Self {
537        Self::ChildContextFailed {
538            operation_name: operation_name.into(),
539            error_message: error_message.into(),
540        }
541    }
542
543    /// Create a step timeout error.
544    ///
545    /// Use when a step closure exceeds the configured `timeout_seconds` duration.
546    ///
547    /// # Examples
548    ///
549    /// ```
550    /// use durable_lambda_core::error::DurableError;
551    ///
552    /// let err = DurableError::step_timeout("my_op");
553    /// assert!(err.to_string().contains("my_op"));
554    /// assert_eq!(err.code(), "STEP_TIMEOUT");
555    /// ```
556    pub fn step_timeout(operation_name: impl Into<String>) -> Self {
557        Self::StepTimeout {
558            operation_name: operation_name.into(),
559        }
560    }
561
562    /// Create a compensation failed error.
563    ///
564    /// Use when one or more compensation closures in `run_compensations()` fail.
565    /// All compensations are attempted even when one fails; this error is returned
566    /// when the [`CompensationResult`](crate::types::CompensationResult) shows failures.
567    ///
568    /// # Examples
569    ///
570    /// ```
571    /// use durable_lambda_core::error::DurableError;
572    ///
573    /// let err = DurableError::compensation_failed("charge_payment", "reversal failed");
574    /// assert!(err.to_string().contains("charge_payment"));
575    /// assert_eq!(err.code(), "COMPENSATION_FAILED");
576    /// ```
577    pub fn compensation_failed(
578        operation_name: impl Into<String>,
579        error_message: impl Into<String>,
580    ) -> Self {
581        Self::CompensationFailed {
582            operation_name: operation_name.into(),
583            error_message: error_message.into(),
584        }
585    }
586
587    /// Return a stable, programmatic error code for this error variant.
588    ///
589    /// Codes are SCREAMING_SNAKE_CASE and stable across versions.
590    /// Use these for programmatic error matching instead of parsing
591    /// display messages.
592    ///
593    /// # Examples
594    ///
595    /// ```
596    /// use durable_lambda_core::error::DurableError;
597    ///
598    /// let err = DurableError::replay_mismatch("Step", "Wait", 0);
599    /// assert_eq!(err.code(), "REPLAY_MISMATCH");
600    /// ```
601    pub fn code(&self) -> &'static str {
602        match self {
603            Self::ReplayMismatch { .. } => "REPLAY_MISMATCH",
604            Self::CheckpointFailed { .. } => "CHECKPOINT_FAILED",
605            Self::Serialization { .. } => "SERIALIZATION",
606            Self::Deserialization { .. } => "DESERIALIZATION",
607            Self::AwsSdk(_) => "AWS_SDK",
608            Self::AwsSdkOperation(_) => "AWS_SDK_OPERATION",
609            Self::StepRetryScheduled { .. } => "STEP_RETRY_SCHEDULED",
610            Self::WaitSuspended { .. } => "WAIT_SUSPENDED",
611            Self::CallbackSuspended { .. } => "CALLBACK_SUSPENDED",
612            Self::CallbackFailed { .. } => "CALLBACK_FAILED",
613            Self::InvokeSuspended { .. } => "INVOKE_SUSPENDED",
614            Self::InvokeFailed { .. } => "INVOKE_FAILED",
615            Self::ParallelFailed { .. } => "PARALLEL_FAILED",
616            Self::MapFailed { .. } => "MAP_FAILED",
617            Self::ChildContextFailed { .. } => "CHILD_CONTEXT_FAILED",
618            Self::StepTimeout { .. } => "STEP_TIMEOUT",
619            Self::CompensationFailed { .. } => "COMPENSATION_FAILED",
620        }
621    }
622}
623
624impl From<aws_sdk_lambda::Error> for DurableError {
625    fn from(err: aws_sdk_lambda::Error) -> Self {
626        Self::AwsSdk(Box::new(err))
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use std::collections::HashSet;
634    use std::error::Error;
635
636    // --- TDD RED: .code() method tests ---
637
638    #[test]
639    fn error_code_replay_mismatch() {
640        let err = DurableError::replay_mismatch("A", "B", 0);
641        assert_eq!(err.code(), "REPLAY_MISMATCH");
642    }
643
644    #[test]
645    fn all_error_variants_have_unique_codes() {
646        let serde_err = || serde_json::from_str::<i32>("bad").unwrap_err();
647        let io_err = || std::io::Error::new(std::io::ErrorKind::Other, "test");
648
649        // Construct all 16 testable variants (AwsSdk excluded — no public constructor).
650        let variants: &[(DurableError, &str)] = &[
651            (
652                DurableError::replay_mismatch("A", "B", 0),
653                "REPLAY_MISMATCH",
654            ),
655            (
656                DurableError::checkpoint_failed("op", io_err()),
657                "CHECKPOINT_FAILED",
658            ),
659            (
660                DurableError::serialization("T", serde_err()),
661                "SERIALIZATION",
662            ),
663            (
664                DurableError::deserialization("T", serde_err()),
665                "DESERIALIZATION",
666            ),
667            (
668                DurableError::aws_sdk_operation(io_err()),
669                "AWS_SDK_OPERATION",
670            ),
671            (
672                DurableError::step_retry_scheduled("op"),
673                "STEP_RETRY_SCHEDULED",
674            ),
675            (DurableError::wait_suspended("op"), "WAIT_SUSPENDED"),
676            (
677                DurableError::callback_suspended("op", "cb-1"),
678                "CALLBACK_SUSPENDED",
679            ),
680            (
681                DurableError::callback_failed("op", "cb-1", "msg"),
682                "CALLBACK_FAILED",
683            ),
684            (DurableError::invoke_suspended("op"), "INVOKE_SUSPENDED"),
685            (DurableError::invoke_failed("op", "msg"), "INVOKE_FAILED"),
686            (
687                DurableError::parallel_failed("op", "msg"),
688                "PARALLEL_FAILED",
689            ),
690            (DurableError::map_failed("op", "msg"), "MAP_FAILED"),
691            (
692                DurableError::child_context_failed("op", "msg"),
693                "CHILD_CONTEXT_FAILED",
694            ),
695            (DurableError::step_timeout("op"), "STEP_TIMEOUT"),
696            (
697                DurableError::compensation_failed("op", "msg"),
698                "COMPENSATION_FAILED",
699            ),
700        ];
701
702        let mut codes = HashSet::new();
703        for (err, expected_code) in variants {
704            let actual = err.code();
705            assert_eq!(
706                actual, *expected_code,
707                "Expected code {:?} for variant but got {:?}",
708                expected_code, actual
709            );
710            let inserted = codes.insert(actual);
711            assert!(inserted, "Duplicate error code found: {:?}", actual);
712        }
713
714        // Verify AWS_SDK code is also unique (compile-time exhaustive match guarantees it exists).
715        // We add it manually to the uniqueness check.
716        assert!(
717            !codes.contains("AWS_SDK"),
718            "AWS_SDK code must be unique among all codes"
719        );
720    }
721
722    // --- StepTimeout tests (TDD RED) ---
723
724    #[test]
725    fn step_timeout_error_code() {
726        let err = DurableError::step_timeout("my_op");
727        assert_eq!(err.code(), "STEP_TIMEOUT");
728    }
729
730    #[test]
731    fn step_timeout_display_contains_op_and_timed_out() {
732        let err = DurableError::step_timeout("my_op");
733        let msg = err.to_string();
734        assert!(
735            msg.contains("my_op"),
736            "display should contain operation name, got: {msg}"
737        );
738        assert!(
739            msg.contains("timed out"),
740            "display should contain 'timed out', got: {msg}"
741        );
742    }
743
744    // --- existing tests ---
745
746    #[test]
747    fn replay_mismatch_display() {
748        let err = DurableError::replay_mismatch("Step", "Wait", 3);
749        let msg = err.to_string();
750        assert!(msg.contains("position 3"));
751        assert!(msg.contains("expected Step"));
752        assert!(msg.contains("got Wait"));
753    }
754
755    #[test]
756    fn replay_mismatch_accepts_string_types() {
757        let err = DurableError::replay_mismatch(String::from("Step"), "Wait".to_string(), 0);
758        assert!(err.to_string().contains("expected Step"));
759    }
760
761    #[test]
762    fn checkpoint_failed_display_and_source() {
763        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
764        let err = DurableError::checkpoint_failed("charge_payment", io_err);
765        let msg = err.to_string();
766        assert!(msg.contains("charge_payment"));
767        assert!(msg.contains("timed out"));
768        assert!(err.source().is_some());
769    }
770
771    #[test]
772    fn serialization_display_and_source() {
773        let serde_err = serde_json::from_str::<i32>("bad").unwrap_err();
774        let err = DurableError::serialization("MyType", serde_err);
775        let msg = err.to_string();
776        assert!(msg.contains("serialize"));
777        assert!(msg.contains("MyType"));
778        assert!(err.source().is_some());
779    }
780
781    #[test]
782    fn deserialization_display_and_source() {
783        let serde_err = serde_json::from_str::<i32>("bad").unwrap_err();
784        let err = DurableError::deserialization("MyType", serde_err);
785        let msg = err.to_string();
786        assert!(msg.contains("deserialize"));
787        assert!(msg.contains("MyType"));
788        assert!(err.source().is_some());
789    }
790
791    #[test]
792    fn aws_sdk_operation_display_and_source() {
793        let source = std::io::Error::new(std::io::ErrorKind::Other, "service unavailable");
794        let err = DurableError::aws_sdk_operation(source);
795        let msg = err.to_string();
796        assert!(msg.contains("service unavailable"));
797        assert!(err.source().is_some());
798    }
799
800    #[test]
801    fn aws_sdk_error_from_conversion() {
802        // Verify that aws_sdk_lambda::Error can be converted via From.
803        // We can't easily construct a real aws_sdk_lambda::Error, but we can
804        // verify the From impl exists at compile time by checking the type.
805        fn _assert_from_impl<T: From<aws_sdk_lambda::Error>>() {}
806        _assert_from_impl::<DurableError>();
807    }
808
809    #[test]
810    fn error_is_send_sync() {
811        fn assert_send_sync<T: Send + Sync>() {}
812        assert_send_sync::<DurableError>();
813    }
814
815    // --- CompensationFailed tests ---
816
817    #[test]
818    fn compensation_failed_error_code() {
819        let err = DurableError::compensation_failed("op", "msg");
820        assert_eq!(err.code(), "COMPENSATION_FAILED");
821    }
822
823    #[test]
824    fn compensation_failed_display_contains_operation_and_message() {
825        let err = DurableError::compensation_failed("charge_payment", "payment reversal failed");
826        let msg = err.to_string();
827        assert!(
828            msg.contains("charge_payment"),
829            "display should contain operation_name, got: {msg}"
830        );
831        assert!(
832            msg.contains("payment reversal failed"),
833            "display should contain error_message, got: {msg}"
834        );
835    }
836}