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