Skip to main content

durable_lambda_core/
response.rs

1//! Durable execution invocation output formatting.
2//!
3//! The AWS Lambda Durable Execution service requires Lambda handlers to return
4//! a specific JSON envelope as their response, rather than the user's plain JSON
5//! value. This module provides [`wrap_handler_result`] to convert the handler's
6//! `Result<serde_json::Value, DurableError>` into the required format.
7//!
8//! # Protocol
9//!
10//! The durable execution service validates the Lambda response and expects one of:
11//!
12//! - `{"Status": "SUCCEEDED", "Result": "<serialized-user-result>"}` — execution completed
13//! - `{"Status": "FAILED", "Error": {"ErrorMessage": "...", "ErrorType": "..."}}` — execution failed
14//! - `{"Status": "PENDING"}` — execution suspended (wait/callback/retry/invoke)
15//!
16//! The `Result` field in the SUCCEEDED case is a **JSON string** (the user's result
17//! value serialized to a JSON string, then included as a string field).
18
19use serde_json::json;
20
21use crate::error::DurableError;
22
23/// Wrap a handler result into the durable execution invocation output format.
24///
25/// Converts `Result<serde_json::Value, DurableError>` into the `{"Status": ...}`
26/// envelope that the AWS Lambda Durable Execution service expects as the Lambda
27/// function response.
28///
29/// # Status Mapping
30///
31/// | Input | Output |
32/// |-------|--------|
33/// | `Ok(value)` | `{"Status": "SUCCEEDED", "Result": "<serialized value>"}` |
34/// | `Err(StepRetryScheduled \| WaitSuspended \| CallbackSuspended \| InvokeSuspended)` | `{"Status": "PENDING"}` |
35/// | `Err(other)` | `{"Status": "FAILED", "Error": {"ErrorMessage": "...", "ErrorType": "..."}}` |
36///
37/// # Returns
38///
39/// Always returns `Ok(serde_json::Value)` — the caller must never treat this as
40/// an error, since the durable execution protocol uses the response body to signal
41/// all outcomes including failures.
42///
43/// # Examples
44///
45/// ```
46/// use durable_lambda_core::response::wrap_handler_result;
47/// use durable_lambda_core::error::DurableError;
48///
49/// // Success case
50/// let result = Ok(serde_json::json!({"order_id": "123"}));
51/// let output = wrap_handler_result(result).unwrap();
52/// assert_eq!(output["Status"], "SUCCEEDED");
53///
54/// // Suspension case (PENDING)
55/// let result: Result<serde_json::Value, DurableError> =
56///     Err(DurableError::wait_suspended("cooldown"));
57/// let output = wrap_handler_result(result).unwrap();
58/// assert_eq!(output["Status"], "PENDING");
59///
60/// // Failure case
61/// let result: Result<serde_json::Value, DurableError> =
62///     Err(DurableError::step_timeout("slow_op"));
63/// let output = wrap_handler_result(result).unwrap();
64/// assert_eq!(output["Status"], "FAILED");
65/// ```
66pub fn wrap_handler_result(
67    result: Result<serde_json::Value, DurableError>,
68) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
69    match result {
70        Ok(value) => {
71            // Serialize the user result as a JSON string (double-encoded).
72            // The durable execution service expects Result to be a JSON string,
73            // not a nested JSON object.
74            let result_str = serde_json::to_string(&value)
75                .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
76            Ok(json!({
77                "Status": "SUCCEEDED",
78                "Result": result_str,
79            }))
80        }
81
82        Err(ref err) if is_suspension(err) => {
83            // Suspension signals: function should exit cleanly so the durable
84            // execution service can re-invoke after the timer/callback/retry.
85            Ok(json!({ "Status": "PENDING" }))
86        }
87
88        Err(err) => {
89            // All other errors: execution failed.
90            let error_type = err.code().to_string();
91            let error_message = err.to_string();
92            Ok(json!({
93                "Status": "FAILED",
94                "Error": {
95                    "ErrorType": error_type,
96                    "ErrorMessage": error_message,
97                },
98            }))
99        }
100    }
101}
102
103/// Returns `true` for error variants that indicate the function should suspend
104/// rather than fail. Suspended executions return `{"Status": "PENDING"}` so the
105/// durable execution service knows to re-invoke after the triggering condition
106/// (timer, callback signal, retry delay, or async invoke completion).
107fn is_suspension(err: &DurableError) -> bool {
108    matches!(
109        err,
110        DurableError::StepRetryScheduled { .. }
111            | DurableError::WaitSuspended { .. }
112            | DurableError::CallbackSuspended { .. }
113            | DurableError::InvokeSuspended { .. }
114    )
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::error::DurableError;
121
122    #[test]
123    fn success_wraps_in_succeeded_envelope() {
124        let value = serde_json::json!({"order_id": "123", "status": "ok"});
125        let output = wrap_handler_result(Ok(value.clone())).unwrap();
126
127        assert_eq!(output["Status"], "SUCCEEDED");
128        // Result is a JSON string
129        let result_str = output["Result"]
130            .as_str()
131            .expect("Result should be a string");
132        let parsed: serde_json::Value = serde_json::from_str(result_str).unwrap();
133        assert_eq!(parsed["order_id"], "123");
134    }
135
136    #[test]
137    fn step_retry_scheduled_returns_pending() {
138        let err = DurableError::step_retry_scheduled("charge_payment");
139        let output = wrap_handler_result(Err(err)).unwrap();
140        assert_eq!(output["Status"], "PENDING");
141        assert!(output.get("Error").is_none());
142        assert!(output.get("Result").is_none());
143    }
144
145    #[test]
146    fn wait_suspended_returns_pending() {
147        let err = DurableError::wait_suspended("cooldown");
148        let output = wrap_handler_result(Err(err)).unwrap();
149        assert_eq!(output["Status"], "PENDING");
150    }
151
152    #[test]
153    fn callback_suspended_returns_pending() {
154        let err = DurableError::callback_suspended("approval", "cb-123");
155        let output = wrap_handler_result(Err(err)).unwrap();
156        assert_eq!(output["Status"], "PENDING");
157    }
158
159    #[test]
160    fn invoke_suspended_returns_pending() {
161        let err = DurableError::invoke_suspended("call_processor");
162        let output = wrap_handler_result(Err(err)).unwrap();
163        assert_eq!(output["Status"], "PENDING");
164    }
165
166    #[test]
167    fn step_timeout_returns_failed() {
168        let err = DurableError::step_timeout("slow_op");
169        let output = wrap_handler_result(Err(err)).unwrap();
170        assert_eq!(output["Status"], "FAILED");
171        assert!(output.get("Error").is_some());
172        let error_obj = &output["Error"];
173        assert_eq!(error_obj["ErrorType"], "STEP_TIMEOUT");
174        assert!(
175            error_obj["ErrorMessage"]
176                .as_str()
177                .unwrap()
178                .contains("timed out"),
179            "ErrorMessage should contain 'timed out'"
180        );
181    }
182
183    #[test]
184    fn checkpoint_failed_returns_failed() {
185        let err = DurableError::checkpoint_failed(
186            "op",
187            std::io::Error::new(std::io::ErrorKind::Other, "network error"),
188        );
189        let output = wrap_handler_result(Err(err)).unwrap();
190        assert_eq!(output["Status"], "FAILED");
191        assert_eq!(output["Error"]["ErrorType"], "CHECKPOINT_FAILED");
192    }
193
194    #[test]
195    fn replay_mismatch_returns_failed() {
196        let err = DurableError::replay_mismatch("Step", "Wait", 3);
197        let output = wrap_handler_result(Err(err)).unwrap();
198        assert_eq!(output["Status"], "FAILED");
199        assert_eq!(output["Error"]["ErrorType"], "REPLAY_MISMATCH");
200    }
201
202    #[test]
203    fn failed_status_has_no_result_field() {
204        let err = DurableError::step_timeout("op");
205        let output = wrap_handler_result(Err(err)).unwrap();
206        assert!(output.get("Result").is_none());
207    }
208
209    #[test]
210    fn succeeded_status_result_is_json_string() {
211        // The Result field must be a JSON string, not a nested object.
212        let output = wrap_handler_result(Ok(serde_json::json!({"key": "value"}))).unwrap();
213        assert_eq!(output["Status"], "SUCCEEDED");
214        assert!(
215            output["Result"].is_string(),
216            "Result must be a JSON string, not an object"
217        );
218    }
219
220    #[test]
221    fn null_value_wraps_correctly() {
222        let output = wrap_handler_result(Ok(serde_json::Value::Null)).unwrap();
223        assert_eq!(output["Status"], "SUCCEEDED");
224        assert_eq!(output["Result"].as_str().unwrap(), "null");
225    }
226}