Skip to main content

dynoxide/
errors.rs

1use serde::Serialize;
2use std::collections::HashMap;
3use std::fmt;
4
5/// Per-item cancellation reason in a `TransactionCanceledException` response.
6///
7/// Real DynamoDB returns one reason per `TransactItem`, with `Code: "None"` for
8/// items that would have succeeded.
9#[derive(Debug, Clone, Default, Serialize)]
10pub struct CancellationReason {
11    #[serde(rename = "Code")]
12    pub code: String,
13    #[serde(rename = "Message", skip_serializing_if = "Option::is_none")]
14    pub message: Option<String>,
15    #[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
16    pub item: Option<HashMap<String, crate::types::AttributeValue>>,
17}
18
19/// DynamoDB error types.
20///
21/// Each variant corresponds to a DynamoDB API error, carrying a human-readable
22/// message that matches DynamoDB's actual error messages.
23#[derive(Debug, thiserror::Error)]
24pub enum DynoxideError {
25    /// Table or resource not found.
26    #[error("{0}")]
27    ResourceNotFoundException(String),
28
29    /// Table or resource already exists / is in use.
30    #[error("{0}")]
31    ResourceInUseException(String),
32
33    /// Input validation failed.
34    #[error("{0}")]
35    ValidationException(String),
36
37    /// Conditional check (ConditionExpression) failed on write.
38    /// Optionally carries the existing item when `ReturnValuesOnConditionCheckFailure` is `ALL_OLD`.
39    #[error("{0}")]
40    ConditionalCheckFailedException(
41        String,
42        Option<HashMap<String, crate::types::AttributeValue>>,
43    ),
44
45    /// One or more transaction conditions failed.
46    /// Carries the message and per-item cancellation reasons.
47    #[error("{0}")]
48    TransactionCanceledException(String, Vec<CancellationReason>),
49
50    /// Item collection exceeded size limit (10 GB per partition key value).
51    #[error("{0}")]
52    ItemCollectionSizeLimitExceededException(String),
53
54    /// Duplicate primary key on PartiQL INSERT (distinct from ConditionalCheckFailedException).
55    #[error("{0}")]
56    DuplicateItemException(String),
57
58    /// Throughput exceeded (stored but not enforced — included for API fidelity).
59    #[error("{0}")]
60    ProvisionedThroughputExceededException(String),
61
62    /// Request body deserialisation failed (malformed JSON, wrong types).
63    #[error("{0}")]
64    SerializationException(String),
65
66    /// Too many concurrent operations or index updates.
67    #[error("{0}")]
68    LimitExceededException(String),
69
70    /// Access denied (e.g. non-existent resource ARN in tag operations).
71    #[error("{0}")]
72    AccessDeniedException(String),
73
74    /// Idempotent request token reused with different request content.
75    #[error("{0}")]
76    IdempotentParameterMismatchException(String),
77
78    /// Catch-all for internal / unexpected errors (SQLite failures, etc.).
79    #[error("{0}")]
80    InternalServerError(String),
81
82    /// Type conversion error (e.g. wrong AttributeValue variant).
83    #[error("Conversion error: {0}")]
84    ConversionError(#[from] crate::types::ConversionError),
85
86    /// SQLite error (converted from rusqlite).
87    #[error("Internal error: {0}")]
88    SqliteError(#[from] rusqlite::Error),
89}
90
91impl DynoxideError {
92    /// Returns the DynamoDB `__type` string for this error.
93    pub fn error_type(&self) -> &'static str {
94        match self {
95            DynoxideError::ResourceNotFoundException(_) => {
96                "com.amazonaws.dynamodb.v20120810#ResourceNotFoundException"
97            }
98            DynoxideError::ResourceInUseException(_) => {
99                "com.amazonaws.dynamodb.v20120810#ResourceInUseException"
100            }
101            DynoxideError::ValidationException(_) => {
102                "com.amazon.coral.validate#ValidationException"
103            }
104            DynoxideError::ConditionalCheckFailedException(..) => {
105                "com.amazonaws.dynamodb.v20120810#ConditionalCheckFailedException"
106            }
107            DynoxideError::TransactionCanceledException(..) => {
108                "com.amazonaws.dynamodb.v20120810#TransactionCanceledException"
109            }
110            DynoxideError::DuplicateItemException(_) => {
111                "com.amazonaws.dynamodb.v20120810#DuplicateItemException"
112            }
113            DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
114                "com.amazonaws.dynamodb.v20120810#ItemCollectionSizeLimitExceededException"
115            }
116            DynoxideError::ProvisionedThroughputExceededException(_) => {
117                "com.amazonaws.dynamodb.v20120810#ProvisionedThroughputExceededException"
118            }
119            DynoxideError::SerializationException(_) => {
120                "com.amazon.coral.service#SerializationException"
121            }
122            DynoxideError::LimitExceededException(_) => {
123                "com.amazonaws.dynamodb.v20120810#LimitExceededException"
124            }
125            DynoxideError::AccessDeniedException(_) => {
126                "com.amazonaws.dynamodb.v20120810#AccessDeniedException"
127            }
128            DynoxideError::IdempotentParameterMismatchException(_) => {
129                "com.amazonaws.dynamodb.v20120810#IdempotentParameterMismatchException"
130            }
131            DynoxideError::ConversionError(_) => "com.amazon.coral.validate#ValidationException",
132            DynoxideError::InternalServerError(_) | DynoxideError::SqliteError(_) => {
133                "com.amazonaws.dynamodb.v20120810#InternalServerError"
134            }
135        }
136    }
137
138    /// Returns the short error code used in `BatchExecuteStatement` per-statement errors.
139    ///
140    /// These are the short-form codes that DynamoDB uses in `BatchStatementError.Code`,
141    /// as opposed to the fully qualified `__type` strings from `error_type()`.
142    pub fn short_error_code(&self) -> &'static str {
143        match self {
144            DynoxideError::ResourceNotFoundException(_) => "ResourceNotFound",
145            DynoxideError::ResourceInUseException(_) => "ResourceInUse",
146            DynoxideError::ValidationException(_) | DynoxideError::ConversionError(_) => {
147                "ValidationError"
148            }
149            DynoxideError::ConditionalCheckFailedException(..) => "ConditionalCheckFailed",
150            DynoxideError::TransactionCanceledException(..) => "TransactionConflict",
151            DynoxideError::DuplicateItemException(_) => "DuplicateItem",
152            DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
153                "ItemCollectionSizeLimitExceeded"
154            }
155            DynoxideError::ProvisionedThroughputExceededException(_) => {
156                "ProvisionedThroughputExceeded"
157            }
158            DynoxideError::AccessDeniedException(_) => "AccessDenied",
159            DynoxideError::IdempotentParameterMismatchException(_) => "IdempotentParameterMismatch",
160            DynoxideError::SerializationException(_) => "SerializationError",
161            DynoxideError::LimitExceededException(_) => "RequestLimitExceeded",
162            DynoxideError::InternalServerError(_) | DynoxideError::SqliteError(_) => {
163                "InternalServerError"
164            }
165        }
166    }
167
168    /// Returns the HTTP status code for this error.
169    pub fn status_code(&self) -> u16 {
170        match self {
171            DynoxideError::InternalServerError(_) | DynoxideError::SqliteError(_) => 500,
172            _ => 400,
173        }
174    }
175
176    /// Convert to a DynamoDB-compatible JSON error response body.
177    pub fn to_response(&self) -> ErrorResponse {
178        let item = if let DynoxideError::ConditionalCheckFailedException(_, item) = self {
179            item.clone()
180        } else {
181            None
182        };
183        ErrorResponse {
184            error_type: self.error_type().to_string(),
185            message: self.to_string(),
186            item,
187        }
188    }
189
190    /// Serialise to DynamoDB-compatible JSON string.
191    ///
192    /// `SerializationException` and `TransactionCanceledException` use
193    /// `Message` (capital M) while all other errors use `message` (lowercase),
194    /// matching real DynamoDB behaviour.
195    pub fn to_json(&self) -> String {
196        let error_type = self.error_type();
197        let message = self.to_string();
198
199        match self {
200            DynoxideError::TransactionCanceledException(_, reasons) => {
201                let mut m = serde_json::Map::new();
202                m.insert(
203                    "__type".to_string(),
204                    serde_json::Value::String(error_type.to_string()),
205                );
206                m.insert("Message".to_string(), serde_json::Value::String(message));
207                if let Ok(reasons_val) = serde_json::to_value(reasons) {
208                    m.insert("CancellationReasons".to_string(), reasons_val);
209                }
210                serde_json::to_string(&m).unwrap_or_default()
211            }
212            DynoxideError::SerializationException(_) => {
213                let mut m = serde_json::Map::new();
214                m.insert(
215                    "__type".to_string(),
216                    serde_json::Value::String(error_type.to_string()),
217                );
218                m.insert("Message".to_string(), serde_json::Value::String(message));
219                serde_json::to_string(&m).unwrap_or_default()
220            }
221            _ => {
222                let resp = self.to_response();
223                serde_json::to_string(&resp).unwrap_or_default()
224            }
225        }
226    }
227}
228
229/// DynamoDB JSON error response body.
230#[derive(Debug, Serialize)]
231pub struct ErrorResponse {
232    #[serde(rename = "__type")]
233    pub error_type: String,
234    #[serde(rename = "message")]
235    pub message: String,
236    #[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
237    pub item: Option<HashMap<String, crate::types::AttributeValue>>,
238}
239
240impl fmt::Display for ErrorResponse {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        write!(f, "{}", serde_json::to_string(self).unwrap_or_default())
243    }
244}
245
246/// Convenience alias.
247pub type Result<T> = std::result::Result<T, DynoxideError>;
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_error_response_format() {
255        let err = DynoxideError::ResourceNotFoundException(
256            "Requested resource not found: Table: NonExistent not found".to_string(),
257        );
258        let resp = err.to_response();
259        let json = serde_json::to_string(&resp).unwrap();
260
261        assert!(json.contains("\"__type\""));
262        assert!(json.contains("ResourceNotFoundException"));
263        assert!(json.contains("NonExistent not found"));
264    }
265
266    #[test]
267    fn test_status_codes() {
268        assert_eq!(
269            DynoxideError::ResourceNotFoundException("".into()).status_code(),
270            400
271        );
272        assert_eq!(
273            DynoxideError::ResourceInUseException("".into()).status_code(),
274            400
275        );
276        assert_eq!(
277            DynoxideError::ValidationException("".into()).status_code(),
278            400
279        );
280        assert_eq!(
281            DynoxideError::ConditionalCheckFailedException("".into(), None).status_code(),
282            400
283        );
284        assert_eq!(
285            DynoxideError::TransactionCanceledException("".into(), vec![]).status_code(),
286            400
287        );
288        assert_eq!(
289            DynoxideError::InternalServerError("".into()).status_code(),
290            500
291        );
292    }
293
294    #[test]
295    fn test_error_type_strings() {
296        let err = DynoxideError::ValidationException("bad input".into());
297        assert_eq!(
298            err.error_type(),
299            "com.amazon.coral.validate#ValidationException"
300        );
301    }
302
303    #[test]
304    fn test_sqlite_error_maps_to_internal() {
305        let sqlite_err = rusqlite::Error::QueryReturnedNoRows;
306        let err = DynoxideError::from(sqlite_err);
307        assert_eq!(err.status_code(), 500);
308        assert!(err.error_type().contains("InternalServerError"));
309    }
310
311    #[test]
312    fn test_error_response_json_structure() {
313        let err = DynoxideError::ValidationException("1 validation error detected".to_string());
314        let resp = err.to_response();
315        let json: serde_json::Value = serde_json::to_value(&resp).unwrap();
316
317        assert!(json.get("__type").is_some());
318        assert!(json.get("message").is_some());
319        assert_eq!(
320            json["__type"],
321            "com.amazon.coral.validate#ValidationException"
322        );
323        assert_eq!(json["message"], "1 validation error detected");
324    }
325
326    #[test]
327    fn test_short_error_codes() {
328        assert_eq!(
329            DynoxideError::ResourceNotFoundException("".into()).short_error_code(),
330            "ResourceNotFound"
331        );
332        assert_eq!(
333            DynoxideError::ValidationException("".into()).short_error_code(),
334            "ValidationError"
335        );
336        assert_eq!(
337            DynoxideError::ConditionalCheckFailedException("".into(), None).short_error_code(),
338            "ConditionalCheckFailed"
339        );
340        assert_eq!(
341            DynoxideError::DuplicateItemException("".into()).short_error_code(),
342            "DuplicateItem"
343        );
344        assert_eq!(
345            DynoxideError::InternalServerError("".into()).short_error_code(),
346            "InternalServerError"
347        );
348    }
349
350    #[test]
351    fn test_transaction_cancelled_json_has_cancellation_reasons() {
352        let reasons = vec![
353            CancellationReason {
354                code: "ConditionalCheckFailed".to_string(),
355                message: Some("The conditional request failed".to_string()),
356                item: None,
357            },
358            CancellationReason {
359                code: "None".to_string(),
360                message: None,
361                item: None,
362            },
363        ];
364        let err = DynoxideError::TransactionCanceledException(
365            "Transaction cancelled, please refer cancellation reasons for specific reasons [ConditionalCheckFailed, None]".to_string(),
366            reasons,
367        );
368        let json_str = err.to_json();
369        let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
370
371        // CancellationReasons must be a top-level field
372        assert!(json.get("CancellationReasons").is_some());
373        let reasons = json["CancellationReasons"].as_array().unwrap();
374        assert_eq!(reasons.len(), 2);
375        assert_eq!(reasons[0]["Code"], "ConditionalCheckFailed");
376        assert_eq!(reasons[1]["Code"], "None");
377
378        // Uses capital Message (not lowercase)
379        assert!(json.get("Message").is_some());
380        assert!(json.get("message").is_none());
381    }
382}