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}