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///
24/// Marked `#[non_exhaustive]` as of 0.10.0 (itself a breaking release), so
25/// later variant additions stay non-breaking. Downstream `match` arms over
26/// this enum must include a wildcard.
27#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum DynoxideError {
30    /// Table or resource not found.
31    #[error("{0}")]
32    ResourceNotFoundException(String),
33
34    /// Table or resource already exists / is in use.
35    #[error("{0}")]
36    ResourceInUseException(String),
37
38    /// Input validation failed.
39    #[error("{0}")]
40    ValidationException(String),
41
42    /// Conditional check (ConditionExpression) failed on write.
43    /// Optionally carries the existing item when `ReturnValuesOnConditionCheckFailure` is `ALL_OLD`.
44    #[error("{0}")]
45    ConditionalCheckFailedException(
46        String,
47        Option<HashMap<String, crate::types::AttributeValue>>,
48    ),
49
50    /// One or more transaction conditions failed.
51    /// Carries the message and per-item cancellation reasons.
52    #[error("{0}")]
53    TransactionCanceledException(String, Vec<CancellationReason>),
54
55    /// Item collection exceeded size limit (10 GB per partition key value).
56    #[error("{0}")]
57    ItemCollectionSizeLimitExceededException(String),
58
59    /// Duplicate primary key on PartiQL INSERT (distinct from ConditionalCheckFailedException).
60    #[error("{0}")]
61    DuplicateItemException(String),
62
63    /// Throughput exceeded (stored but not enforced — included for API fidelity).
64    #[error("{0}")]
65    ProvisionedThroughputExceededException(String),
66
67    /// Request body deserialisation failed (malformed JSON, wrong types).
68    #[error("{0}")]
69    SerializationException(String),
70
71    /// Too many concurrent operations or index updates.
72    #[error("{0}")]
73    LimitExceededException(String),
74
75    /// Access denied (e.g. non-existent resource ARN in tag operations).
76    #[error("{0}")]
77    AccessDeniedException(String),
78
79    /// Idempotent request token reused with different request content.
80    #[error("{0}")]
81    IdempotentParameterMismatchException(String),
82
83    /// Catch-all for internal / unexpected errors (SQLite failures, etc.).
84    #[error("{0}")]
85    InternalServerError(String),
86
87    /// Type conversion error (e.g. wrong AttributeValue variant).
88    #[error("Conversion error: {0}")]
89    ConversionError(#[from] crate::types::ConversionError),
90
91    /// SQLite error (converted from rusqlite).
92    #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
93    #[error("Internal error: {0}")]
94    SqliteError(#[from] rusqlite::Error),
95}
96
97/// Most backend failures (`BackendError`) are storage-level faults: a locked
98/// database, an I/O error, a constraint the application layer did not
99/// anticipate. None of those is part of DynamoDB's client-facing error
100/// contract, so they surface as `InternalServerError` (HTTP 500), matching how
101/// a raw `rusqlite::Error` surfaces via `SqliteError`.
102///
103/// The one exception is `BackendError::Validation`: a backend method such as
104/// `set_tags` enforces a client-facing limit (the 50-tag cap) and raises a
105/// `ValidationException`. That crosses the trait boundary as
106/// `BackendError::Validation` and is restored here to its `ValidationException`
107/// (HTTP 400) so the envelope is unchanged from calling `Storage` directly.
108///
109/// A one-way `From` is deliberate rather than merging the two types:
110/// `BackendError` is the narrow storage vocabulary, `DynoxideError` the wider
111/// API vocabulary. A merge is deferred.
112impl From<crate::storage_backend::BackendError> for DynoxideError {
113    fn from(err: crate::storage_backend::BackendError) -> Self {
114        use crate::storage_backend::BackendError;
115        match err {
116            BackendError::Validation(msg) => DynoxideError::ValidationException(msg),
117            other => DynoxideError::InternalServerError(other.to_string()),
118        }
119    }
120}
121
122impl DynoxideError {
123    /// Returns the DynamoDB `__type` string for this error.
124    pub fn error_type(&self) -> &'static str {
125        match self {
126            DynoxideError::ResourceNotFoundException(_) => {
127                "com.amazonaws.dynamodb.v20120810#ResourceNotFoundException"
128            }
129            DynoxideError::ResourceInUseException(_) => {
130                "com.amazonaws.dynamodb.v20120810#ResourceInUseException"
131            }
132            DynoxideError::ValidationException(_) => {
133                "com.amazon.coral.validate#ValidationException"
134            }
135            DynoxideError::ConditionalCheckFailedException(..) => {
136                "com.amazonaws.dynamodb.v20120810#ConditionalCheckFailedException"
137            }
138            DynoxideError::TransactionCanceledException(..) => {
139                "com.amazonaws.dynamodb.v20120810#TransactionCanceledException"
140            }
141            DynoxideError::DuplicateItemException(_) => {
142                "com.amazonaws.dynamodb.v20120810#DuplicateItemException"
143            }
144            DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
145                "com.amazonaws.dynamodb.v20120810#ItemCollectionSizeLimitExceededException"
146            }
147            DynoxideError::ProvisionedThroughputExceededException(_) => {
148                "com.amazonaws.dynamodb.v20120810#ProvisionedThroughputExceededException"
149            }
150            DynoxideError::SerializationException(_) => {
151                "com.amazon.coral.service#SerializationException"
152            }
153            DynoxideError::LimitExceededException(_) => {
154                "com.amazonaws.dynamodb.v20120810#LimitExceededException"
155            }
156            DynoxideError::AccessDeniedException(_) => {
157                "com.amazonaws.dynamodb.v20120810#AccessDeniedException"
158            }
159            DynoxideError::IdempotentParameterMismatchException(_) => {
160                "com.amazonaws.dynamodb.v20120810#IdempotentParameterMismatchException"
161            }
162            DynoxideError::ConversionError(_) => "com.amazon.coral.validate#ValidationException",
163            DynoxideError::InternalServerError(_) => {
164                "com.amazonaws.dynamodb.v20120810#InternalServerError"
165            }
166            #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
167            DynoxideError::SqliteError(_) => "com.amazonaws.dynamodb.v20120810#InternalServerError",
168        }
169    }
170
171    /// Returns the short error code used in `BatchExecuteStatement` per-statement errors.
172    ///
173    /// These are the short-form codes that DynamoDB uses in `BatchStatementError.Code`,
174    /// as opposed to the fully qualified `__type` strings from `error_type()`.
175    pub fn short_error_code(&self) -> &'static str {
176        match self {
177            DynoxideError::ResourceNotFoundException(_) => "ResourceNotFound",
178            DynoxideError::ResourceInUseException(_) => "ResourceInUse",
179            DynoxideError::ValidationException(_) | DynoxideError::ConversionError(_) => {
180                "ValidationError"
181            }
182            DynoxideError::ConditionalCheckFailedException(..) => "ConditionalCheckFailed",
183            DynoxideError::TransactionCanceledException(..) => "TransactionConflict",
184            DynoxideError::DuplicateItemException(_) => "DuplicateItem",
185            DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
186                "ItemCollectionSizeLimitExceeded"
187            }
188            DynoxideError::ProvisionedThroughputExceededException(_) => {
189                "ProvisionedThroughputExceeded"
190            }
191            DynoxideError::AccessDeniedException(_) => "AccessDenied",
192            DynoxideError::IdempotentParameterMismatchException(_) => "IdempotentParameterMismatch",
193            DynoxideError::SerializationException(_) => "SerializationError",
194            DynoxideError::LimitExceededException(_) => "RequestLimitExceeded",
195            DynoxideError::InternalServerError(_) => "InternalServerError",
196            #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
197            DynoxideError::SqliteError(_) => "InternalServerError",
198        }
199    }
200
201    /// Returns the HTTP status code for this error.
202    pub fn status_code(&self) -> u16 {
203        match self {
204            DynoxideError::InternalServerError(_) => 500,
205            #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
206            DynoxideError::SqliteError(_) => 500,
207            _ => 400,
208        }
209    }
210
211    /// Convert to a DynamoDB-compatible JSON error response body.
212    pub fn to_response(&self) -> ErrorResponse {
213        let item = if let DynoxideError::ConditionalCheckFailedException(_, item) = self {
214            item.clone()
215        } else {
216            None
217        };
218        ErrorResponse {
219            error_type: self.error_type().to_string(),
220            message: self.to_string(),
221            item,
222        }
223    }
224
225    /// Serialise to DynamoDB-compatible JSON string.
226    ///
227    /// `SerializationException` and `TransactionCanceledException` use
228    /// `Message` (capital M) while all other errors use `message` (lowercase),
229    /// matching real DynamoDB behaviour.
230    pub fn to_json(&self) -> String {
231        let error_type = self.error_type();
232        let message = self.to_string();
233
234        match self {
235            DynoxideError::TransactionCanceledException(_, reasons) => {
236                let mut m = serde_json::Map::new();
237                m.insert(
238                    "__type".to_string(),
239                    serde_json::Value::String(error_type.to_string()),
240                );
241                m.insert("Message".to_string(), serde_json::Value::String(message));
242                if let Ok(reasons_val) = serde_json::to_value(reasons) {
243                    m.insert("CancellationReasons".to_string(), reasons_val);
244                }
245                serde_json::to_string(&m).unwrap_or_default()
246            }
247            DynoxideError::SerializationException(_) => {
248                let mut m = serde_json::Map::new();
249                m.insert(
250                    "__type".to_string(),
251                    serde_json::Value::String(error_type.to_string()),
252                );
253                m.insert("Message".to_string(), serde_json::Value::String(message));
254                serde_json::to_string(&m).unwrap_or_default()
255            }
256            _ => {
257                let resp = self.to_response();
258                serde_json::to_string(&resp).unwrap_or_default()
259            }
260        }
261    }
262}
263
264/// DynamoDB JSON error response body.
265#[derive(Debug, Serialize)]
266pub struct ErrorResponse {
267    #[serde(rename = "__type")]
268    pub error_type: String,
269    #[serde(rename = "message")]
270    pub message: String,
271    #[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
272    pub item: Option<HashMap<String, crate::types::AttributeValue>>,
273}
274
275impl fmt::Display for ErrorResponse {
276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277        write!(f, "{}", serde_json::to_string(self).unwrap_or_default())
278    }
279}
280
281/// Convenience alias.
282pub type Result<T> = std::result::Result<T, DynoxideError>;
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_error_response_format() {
290        let err = DynoxideError::ResourceNotFoundException(
291            "Requested resource not found: Table: NonExistent not found".to_string(),
292        );
293        let resp = err.to_response();
294        let json = serde_json::to_string(&resp).unwrap();
295
296        assert!(json.contains("\"__type\""));
297        assert!(json.contains("ResourceNotFoundException"));
298        assert!(json.contains("NonExistent not found"));
299    }
300
301    #[test]
302    fn test_status_codes() {
303        assert_eq!(
304            DynoxideError::ResourceNotFoundException("".into()).status_code(),
305            400
306        );
307        assert_eq!(
308            DynoxideError::ResourceInUseException("".into()).status_code(),
309            400
310        );
311        assert_eq!(
312            DynoxideError::ValidationException("".into()).status_code(),
313            400
314        );
315        assert_eq!(
316            DynoxideError::ConditionalCheckFailedException("".into(), None).status_code(),
317            400
318        );
319        assert_eq!(
320            DynoxideError::TransactionCanceledException("".into(), vec![]).status_code(),
321            400
322        );
323        assert_eq!(
324            DynoxideError::InternalServerError("".into()).status_code(),
325            500
326        );
327    }
328
329    #[test]
330    fn test_error_type_strings() {
331        let err = DynoxideError::ValidationException("bad input".into());
332        assert_eq!(
333            err.error_type(),
334            "com.amazon.coral.validate#ValidationException"
335        );
336    }
337
338    #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
339    #[test]
340    fn test_sqlite_error_maps_to_internal() {
341        let sqlite_err = rusqlite::Error::QueryReturnedNoRows;
342        let err = DynoxideError::from(sqlite_err);
343        assert_eq!(err.status_code(), 500);
344        assert!(err.error_type().contains("InternalServerError"));
345    }
346
347    // Error-envelope fidelity for the wasm backend.
348    //
349    // Client-facing envelopes (ResourceNotFound, ConditionalCheckFailed,
350    // Validation, ...) are raised by the shared, generic action handlers, so
351    // they are backend-independent by construction. The only backend-specific
352    // boundary is `From<BackendError> for DynoxideError`, exercised here: the
353    // wasm backend's storage faults must land on the same envelopes the native
354    // rusqlite path produces.
355    #[test]
356    fn test_backend_error_envelopes_match_native() {
357        use crate::storage_backend::BackendError;
358
359        // A client-facing validation limit crosses the boundary as a 400.
360        let v: DynoxideError = BackendError::Validation("too many tags".into()).into();
361        assert_eq!(v.status_code(), 400);
362        assert_eq!(
363            v.error_type(),
364            "com.amazon.coral.validate#ValidationException"
365        );
366
367        // Unsupported (e.g. TTL on wasm) surfaces as a 500 carrying the
368        // capability tag, the documented AWS-style code for the preview.
369        let u: DynoxideError = BackendError::Unsupported { capability: "ttl" }.into();
370        assert_eq!(u.status_code(), 500);
371        assert!(u.error_type().contains("InternalServerError"));
372        assert!(u.to_string().contains("ttl"));
373
374        // Every other storage fault maps to a 500, matching the native
375        // `rusqlite::Error -> SqliteError -> InternalServerError` path.
376        for e in [
377            BackendError::NotADatabase,
378            BackendError::Locked,
379            BackendError::Constraint("constraint".into()),
380            BackendError::Io("io".into()),
381            BackendError::Other("wa-sqlite: boom".into()),
382        ] {
383            let d: DynoxideError = e.into();
384            assert_eq!(d.status_code(), 500);
385            assert!(d.error_type().contains("InternalServerError"));
386        }
387    }
388
389    #[test]
390    fn test_error_response_json_structure() {
391        let err = DynoxideError::ValidationException("1 validation error detected".to_string());
392        let resp = err.to_response();
393        let json: serde_json::Value = serde_json::to_value(&resp).unwrap();
394
395        assert!(json.get("__type").is_some());
396        assert!(json.get("message").is_some());
397        assert_eq!(
398            json["__type"],
399            "com.amazon.coral.validate#ValidationException"
400        );
401        assert_eq!(json["message"], "1 validation error detected");
402    }
403
404    #[test]
405    fn test_short_error_codes() {
406        assert_eq!(
407            DynoxideError::ResourceNotFoundException("".into()).short_error_code(),
408            "ResourceNotFound"
409        );
410        assert_eq!(
411            DynoxideError::ValidationException("".into()).short_error_code(),
412            "ValidationError"
413        );
414        assert_eq!(
415            DynoxideError::ConditionalCheckFailedException("".into(), None).short_error_code(),
416            "ConditionalCheckFailed"
417        );
418        assert_eq!(
419            DynoxideError::DuplicateItemException("".into()).short_error_code(),
420            "DuplicateItem"
421        );
422        assert_eq!(
423            DynoxideError::InternalServerError("".into()).short_error_code(),
424            "InternalServerError"
425        );
426    }
427
428    #[test]
429    fn test_transaction_cancelled_json_has_cancellation_reasons() {
430        let reasons = vec![
431            CancellationReason {
432                code: "ConditionalCheckFailed".to_string(),
433                message: Some("The conditional request failed".to_string()),
434                item: None,
435            },
436            CancellationReason {
437                code: "None".to_string(),
438                message: None,
439                item: None,
440            },
441        ];
442        let err = DynoxideError::TransactionCanceledException(
443            "Transaction cancelled, please refer cancellation reasons for specific reasons [ConditionalCheckFailed, None]".to_string(),
444            reasons,
445        );
446        let json_str = err.to_json();
447        let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
448
449        // CancellationReasons must be a top-level field
450        assert!(json.get("CancellationReasons").is_some());
451        let reasons = json["CancellationReasons"].as_array().unwrap();
452        assert_eq!(reasons.len(), 2);
453        assert_eq!(reasons[0]["Code"], "ConditionalCheckFailed");
454        assert_eq!(reasons[1]["Code"], "None");
455
456        // Uses capital Message (not lowercase)
457        assert!(json.get("Message").is_some());
458        assert!(json.get("message").is_none());
459    }
460
461    #[test]
462    fn test_backend_error_maps_to_internal() {
463        use crate::storage_backend::BackendError;
464        let err: DynoxideError = BackendError::Locked.into();
465        assert_eq!(err.status_code(), 500);
466        assert!(err.error_type().contains("InternalServerError"));
467        assert!(err.to_string().contains("locked"));
468    }
469}