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(
16        rename = "Item",
17        skip_serializing_if = "Option::is_none",
18        serialize_with = "crate::set_order::item_in_hash_order"
19    )]
20    // Number sets in Java hash-set order here, as DynamoDB returns them;
21    // pinned by set_order's every_response_item_field test.
22    pub item: Option<HashMap<String, crate::types::AttributeValue>>,
23}
24
25/// DynamoDB error types.
26///
27/// Each variant corresponds to a DynamoDB API error, carrying a human-readable
28/// message that matches DynamoDB's actual error messages.
29///
30/// Marked `#[non_exhaustive]` as of 0.10.0 (itself a breaking release), so
31/// later variant additions stay non-breaking. Downstream `match` arms over
32/// this enum must include a wildcard.
33#[derive(Debug, thiserror::Error)]
34#[non_exhaustive]
35pub enum DynoxideError {
36    /// Table or resource not found.
37    #[error("{0}")]
38    ResourceNotFoundException(String),
39
40    /// Table or resource already exists / is in use.
41    #[error("{0}")]
42    ResourceInUseException(String),
43
44    /// Input validation failed.
45    #[error("{0}")]
46    ValidationException(String),
47
48    /// An empty-string or empty-binary key value on a write. Serialises identically
49    /// to `ValidationException`, but is a distinct variant so the transaction loops can
50    /// surface it as a top-level error instead of a `ValidationError` cancellation
51    /// reason (#95).
52    #[error("{0}")]
53    KeyEmptyValueValidation(String),
54
55    /// A request-validation error that PutItem and UpdateItem wrap in the
56    /// `1 validation error detected: ` envelope at their operation boundary.
57    /// Serialises identically to `ValidationException` on every surface; the
58    /// distinct variant only tags where the envelope applies.
59    #[error("{0}")]
60    EnvelopedValidation(String),
61
62    /// Conditional check (ConditionExpression) failed on write.
63    /// Optionally carries the existing item when `ReturnValuesOnConditionCheckFailure` is `ALL_OLD`.
64    #[error("{0}")]
65    ConditionalCheckFailedException(
66        String,
67        Option<HashMap<String, crate::types::AttributeValue>>,
68    ),
69
70    /// `ExecuteStatement`'s `ConditionalCheckFailedException` carrying the item
71    /// `ReturnValuesOnConditionCheckFailure` asked for. DynamoDB writes this one
72    /// with a capitalised `Message` and the item's number sets in hash order,
73    /// as it writes the PartiQL operations' other items, where `PutItem`,
74    /// `UpdateItem` and `DeleteItem` write `message` and numeric order
75    /// (captured eu-west-2 and us-east-1, 2026-10-10). Only the HTTP and wasm
76    /// dispatch seams build it, from a `ConditionalCheckFailedException`; the
77    /// in-process API returns that variant, item included.
78    #[error("{0}")]
79    StatementConditionalCheckFailed(String, HashMap<String, crate::types::AttributeValue>),
80
81    /// One or more transaction conditions failed.
82    /// Carries the message and per-item cancellation reasons.
83    #[error("{0}")]
84    TransactionCanceledException(String, Vec<CancellationReason>),
85
86    /// Item collection exceeded size limit (10 GB per partition key value).
87    #[error("{0}")]
88    ItemCollectionSizeLimitExceededException(String),
89
90    /// Duplicate primary key on PartiQL INSERT (distinct from ConditionalCheckFailedException).
91    #[error("{0}")]
92    DuplicateItemException(String),
93
94    /// Throughput exceeded (stored but not enforced: included for API fidelity).
95    #[error("{0}")]
96    ProvisionedThroughputExceededException(String),
97
98    /// Request body deserialisation failed (malformed JSON, wrong types).
99    #[error("{0}")]
100    SerializationException(String),
101
102    /// Too many concurrent operations or index updates.
103    #[error("{0}")]
104    LimitExceededException(String),
105
106    /// Access denied (e.g. non-existent resource ARN in tag operations).
107    #[error("{0}")]
108    AccessDeniedException(String),
109
110    /// Idempotent request token reused with different request content.
111    #[error("{0}")]
112    IdempotentParameterMismatchException(String),
113
114    /// Catch-all for internal / unexpected errors (SQLite failures, etc.).
115    #[error("{0}")]
116    InternalServerError(String),
117
118    /// A capability the active storage backend does not implement (for example
119    /// streams or tags on the wasm backend). Serialises with the same
120    /// `UnsupportedOperation` type and 501 status as an op the engine does not
121    /// serve at all, so a client - and the conformance suite - reads both the
122    /// same way: a scope gap, not a server fault. The 501 also keeps AWS SDK
123    /// retry policies from re-sending a request that can never succeed.
124    #[error("'{0}' is not supported by this build of the engine")]
125    UnsupportedCapability(String),
126
127    /// Type conversion error (e.g. wrong AttributeValue variant).
128    #[error("Conversion error: {0}")]
129    ConversionError(#[from] crate::types::ConversionError),
130
131    /// SQLite error (converted from rusqlite).
132    #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
133    #[error("Internal error: {0}")]
134    SqliteError(#[from] rusqlite::Error),
135
136    /// OPFS is present in the browser but its pool could not be acquired
137    /// (typically another tab holds the database). wasm backend only; carries a
138    /// dynoxide-specific `__type` so a client can detect a busy database.
139    #[cfg(feature = "wasm-sqlite")]
140    #[error("{0}")]
141    OpfsUnavailable(String),
142}
143
144/// Most backend failures (`BackendError`) are storage-level faults: a locked
145/// database, an I/O error, a constraint the application layer did not
146/// anticipate. None of those is part of DynamoDB's client-facing error
147/// contract, so they surface as `InternalServerError` (HTTP 500), matching how
148/// a raw `rusqlite::Error` surfaces via `SqliteError`.
149///
150/// There are two exceptions. The first is `BackendError::Validation`: a
151/// backend method such as `set_tags` enforces a client-facing limit (the
152/// 50-tag cap) and raises a `ValidationException`. That crosses the trait
153/// boundary as `BackendError::Validation` and is restored here to its
154/// `ValidationException` (HTTP 400) so the envelope is unchanged from calling
155/// `Storage` directly. The second is `BackendError::Unsupported`, which maps
156/// to `DynoxideError::UnsupportedCapability` (HTTP 501, the
157/// `com.dynoxide.wasm#UnsupportedOperation` envelope) so a capability gap
158/// reads as scope, not a server fault.
159///
160/// A one-way `From` is deliberate rather than merging the two types:
161/// `BackendError` is the narrow storage vocabulary, `DynoxideError` the wider
162/// API vocabulary. A merge is deferred.
163impl From<crate::storage_backend::BackendError> for DynoxideError {
164    fn from(err: crate::storage_backend::BackendError) -> Self {
165        use crate::storage_backend::BackendError;
166        match err {
167            BackendError::Validation(msg) => DynoxideError::ValidationException(msg),
168            BackendError::Unsupported { capability } => {
169                DynoxideError::UnsupportedCapability(capability.to_string())
170            }
171            #[cfg(feature = "wasm-sqlite")]
172            BackendError::OpfsUnavailable(msg) => DynoxideError::OpfsUnavailable(msg),
173            other => DynoxideError::InternalServerError(other.to_string()),
174        }
175    }
176}
177
178/// The `__type` carried when the engine cannot do what was asked. Shared by
179/// the wasm op-level `UnsupportedOperation` envelope and
180/// `DynoxideError::UnsupportedCapability`, so a client detects "the engine
181/// cannot do this" with one type either way. Namespaced so it cannot collide
182/// with a real DynamoDB `__type`.
183pub(crate) const UNSUPPORTED_TYPE: &str = "com.dynoxide.wasm#UnsupportedOperation";
184
185impl DynoxideError {
186    /// Returns the DynamoDB `__type` string for this error.
187    pub fn error_type(&self) -> &'static str {
188        match self {
189            DynoxideError::ResourceNotFoundException(_) => {
190                "com.amazonaws.dynamodb.v20120810#ResourceNotFoundException"
191            }
192            DynoxideError::ResourceInUseException(_) => {
193                "com.amazonaws.dynamodb.v20120810#ResourceInUseException"
194            }
195            DynoxideError::ValidationException(_)
196            | DynoxideError::KeyEmptyValueValidation(_)
197            | DynoxideError::EnvelopedValidation(_) => {
198                "com.amazon.coral.validate#ValidationException"
199            }
200            DynoxideError::ConditionalCheckFailedException(..)
201            | DynoxideError::StatementConditionalCheckFailed(..) => {
202                "com.amazonaws.dynamodb.v20120810#ConditionalCheckFailedException"
203            }
204            DynoxideError::TransactionCanceledException(..) => {
205                "com.amazonaws.dynamodb.v20120810#TransactionCanceledException"
206            }
207            DynoxideError::DuplicateItemException(_) => {
208                "com.amazonaws.dynamodb.v20120810#DuplicateItemException"
209            }
210            DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
211                "com.amazonaws.dynamodb.v20120810#ItemCollectionSizeLimitExceededException"
212            }
213            DynoxideError::ProvisionedThroughputExceededException(_) => {
214                "com.amazonaws.dynamodb.v20120810#ProvisionedThroughputExceededException"
215            }
216            DynoxideError::SerializationException(_) => {
217                "com.amazon.coral.service#SerializationException"
218            }
219            DynoxideError::LimitExceededException(_) => {
220                "com.amazonaws.dynamodb.v20120810#LimitExceededException"
221            }
222            DynoxideError::AccessDeniedException(_) => {
223                "com.amazonaws.dynamodb.v20120810#AccessDeniedException"
224            }
225            DynoxideError::IdempotentParameterMismatchException(_) => {
226                "com.amazonaws.dynamodb.v20120810#IdempotentParameterMismatchException"
227            }
228            DynoxideError::ConversionError(_) => "com.amazon.coral.validate#ValidationException",
229            DynoxideError::InternalServerError(_) => {
230                "com.amazonaws.dynamodb.v20120810#InternalServerError"
231            }
232            // The same sentinel the op-level 501 envelope carries, so a client
233            // detects "the engine cannot do this" with one type either way.
234            DynoxideError::UnsupportedCapability(_) => UNSUPPORTED_TYPE,
235            #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
236            DynoxideError::SqliteError(_) => "com.amazonaws.dynamodb.v20120810#InternalServerError",
237            #[cfg(feature = "wasm-sqlite")]
238            DynoxideError::OpfsUnavailable(_) => "com.dynoxide.wasm#OpfsUnavailable",
239        }
240    }
241
242    /// Returns the short error code used in `BatchExecuteStatement` per-statement errors.
243    ///
244    /// These are the short-form codes that DynamoDB uses in `BatchStatementError.Code`,
245    /// as opposed to the fully qualified `__type` strings from `error_type()`.
246    pub fn short_error_code(&self) -> &'static str {
247        match self {
248            DynoxideError::ResourceNotFoundException(_) => "ResourceNotFound",
249            DynoxideError::ResourceInUseException(_) => "ResourceInUse",
250            DynoxideError::ValidationException(_)
251            | DynoxideError::KeyEmptyValueValidation(_)
252            | DynoxideError::EnvelopedValidation(_)
253            | DynoxideError::ConversionError(_) => "ValidationError",
254            DynoxideError::ConditionalCheckFailedException(..)
255            | DynoxideError::StatementConditionalCheckFailed(..) => "ConditionalCheckFailed",
256            DynoxideError::TransactionCanceledException(..) => "TransactionConflict",
257            DynoxideError::DuplicateItemException(_) => "DuplicateItem",
258            DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
259                "ItemCollectionSizeLimitExceeded"
260            }
261            DynoxideError::ProvisionedThroughputExceededException(_) => {
262                "ProvisionedThroughputExceeded"
263            }
264            DynoxideError::AccessDeniedException(_) => "AccessDenied",
265            DynoxideError::IdempotentParameterMismatchException(_) => "IdempotentParameterMismatch",
266            DynoxideError::SerializationException(_) => "SerializationError",
267            DynoxideError::LimitExceededException(_) => "RequestLimitExceeded",
268            DynoxideError::InternalServerError(_) => "InternalServerError",
269            DynoxideError::UnsupportedCapability(_) => "UnsupportedOperation",
270            #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
271            DynoxideError::SqliteError(_) => "InternalServerError",
272            #[cfg(feature = "wasm-sqlite")]
273            DynoxideError::OpfsUnavailable(_) => "OpfsUnavailable",
274        }
275    }
276
277    /// Returns the HTTP status code for this error.
278    pub fn status_code(&self) -> u16 {
279        match self {
280            DynoxideError::InternalServerError(_) => 500,
281            #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
282            DynoxideError::SqliteError(_) => 500,
283            // 501 for the same reason the op-level envelope uses it: "not
284            // implemented" is a definite answer about scope, and it sits
285            // outside the status codes AWS SDK retry policies re-send.
286            DynoxideError::UnsupportedCapability(_) => 501,
287            _ => 400,
288        }
289    }
290
291    /// Convert to a DynamoDB-compatible JSON error response body.
292    pub fn to_response(&self) -> ErrorResponse {
293        let item = match self {
294            DynoxideError::ConditionalCheckFailedException(_, item) => item.clone(),
295            DynoxideError::StatementConditionalCheckFailed(_, item) => Some(item.clone()),
296            _ => None,
297        };
298        ErrorResponse {
299            error_type: self.error_type().to_string(),
300            message: self.to_string(),
301            item,
302        }
303    }
304
305    /// Serialise to DynamoDB-compatible JSON string.
306    ///
307    /// `SerializationException` and `TransactionCanceledException` use
308    /// `Message` (capital M) while all other errors use `message` (lowercase),
309    /// matching real DynamoDB behaviour.
310    pub fn to_json(&self) -> String {
311        let error_type = self.error_type();
312        let message = self.to_string();
313
314        match self {
315            DynoxideError::TransactionCanceledException(_, reasons) => {
316                let mut m = serde_json::Map::new();
317                m.insert(
318                    "__type".to_string(),
319                    serde_json::Value::String(error_type.to_string()),
320                );
321                m.insert("Message".to_string(), serde_json::Value::String(message));
322                if let Ok(reasons_val) = serde_json::to_value(reasons) {
323                    m.insert("CancellationReasons".to_string(), reasons_val);
324                }
325                serde_json::to_string(&m).unwrap_or_default()
326            }
327            DynoxideError::StatementConditionalCheckFailed(_, item) => {
328                let mut m = serde_json::Map::new();
329                m.insert(
330                    "__type".to_string(),
331                    serde_json::Value::String(error_type.to_string()),
332                );
333                m.insert("Message".to_string(), serde_json::Value::String(message));
334                if let Ok(item) = serde_json::to_value(crate::set_order::HashOrderedItem(item)) {
335                    m.insert("Item".to_string(), item);
336                }
337                serde_json::to_string(&m).unwrap_or_default()
338            }
339            DynoxideError::SerializationException(_) => {
340                let mut m = serde_json::Map::new();
341                m.insert(
342                    "__type".to_string(),
343                    serde_json::Value::String(error_type.to_string()),
344                );
345                // DynamoDB leaves the message out altogether when it has none
346                // to give, as for a list where a boolean belongs.
347                if !message.is_empty() {
348                    m.insert("Message".to_string(), serde_json::Value::String(message));
349                }
350                serde_json::to_string(&m).unwrap_or_default()
351            }
352            _ => {
353                let resp = self.to_response();
354                serde_json::to_string(&resp).unwrap_or_default()
355            }
356        }
357    }
358}
359
360/// DynamoDB JSON error response body.
361#[derive(Debug, Serialize)]
362pub struct ErrorResponse {
363    #[serde(rename = "__type")]
364    pub error_type: String,
365    #[serde(rename = "message")]
366    pub message: String,
367    #[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
368    // Number sets in numeric order here, as DynamoDB returns them;
369    // pinned by set_order's every_response_item_field test.
370    pub item: Option<HashMap<String, crate::types::AttributeValue>>,
371}
372
373impl fmt::Display for ErrorResponse {
374    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
375        write!(f, "{}", serde_json::to_string(self).unwrap_or_default())
376    }
377}
378
379/// Convenience alias.
380pub type Result<T> = std::result::Result<T, DynoxideError>;
381
382/// The error as `operation` puts it on the wire, for the HTTP and wasm
383/// dispatch seams: `ExecuteStatement`'s condition failure carrying an item is
384/// written in its own shape (see
385/// [`DynoxideError::StatementConditionalCheckFailed`]). Every other error
386/// passes through unchanged.
387#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
388pub(crate) fn for_operation(operation: &str, err: DynoxideError) -> DynoxideError {
389    match (operation, err) {
390        (
391            "ExecuteStatement",
392            DynoxideError::ConditionalCheckFailedException(message, Some(item)),
393        ) => DynoxideError::StatementConditionalCheckFailed(message, item),
394        (_, err) => err,
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_error_response_format() {
404        let err = DynoxideError::ResourceNotFoundException(
405            "Requested resource not found: Table: NonExistent not found".to_string(),
406        );
407        let resp = err.to_response();
408        let json = serde_json::to_string(&resp).unwrap();
409
410        assert!(json.contains("\"__type\""));
411        assert!(json.contains("ResourceNotFoundException"));
412        assert!(json.contains("NonExistent not found"));
413    }
414
415    #[test]
416    fn test_status_codes() {
417        assert_eq!(
418            DynoxideError::ResourceNotFoundException("".into()).status_code(),
419            400
420        );
421        assert_eq!(
422            DynoxideError::ResourceInUseException("".into()).status_code(),
423            400
424        );
425        assert_eq!(
426            DynoxideError::ValidationException("".into()).status_code(),
427            400
428        );
429        assert_eq!(
430            DynoxideError::ConditionalCheckFailedException("".into(), None).status_code(),
431            400
432        );
433        assert_eq!(
434            DynoxideError::TransactionCanceledException("".into(), vec![]).status_code(),
435            400
436        );
437        assert_eq!(
438            DynoxideError::InternalServerError("".into()).status_code(),
439            500
440        );
441    }
442
443    #[test]
444    fn test_key_empty_value_validation_is_wire_identical_to_validation_exception() {
445        // The variant must be indistinguishable from ValidationException on every wire
446        // surface, for both the empty-string and empty-binary messages it now carries.
447        let messages = [
448            "One or more parameter values are not valid. The AttributeValue for a key \
449             attribute cannot contain an empty string value. Key: pk",
450            "One or more parameter values are not valid. The AttributeValue for a key \
451             attribute cannot contain an empty binary value. Key: pk",
452        ];
453        for msg in messages {
454            let empty = DynoxideError::KeyEmptyValueValidation(msg.to_string());
455            let plain = DynoxideError::ValidationException(msg.to_string());
456            assert_eq!(empty.status_code(), plain.status_code());
457            assert_eq!(empty.error_type(), plain.error_type());
458            assert_eq!(empty.short_error_code(), plain.short_error_code());
459            assert_eq!(empty.to_json(), plain.to_json());
460            assert_eq!(empty.to_string(), plain.to_string());
461        }
462    }
463
464    #[test]
465    fn test_enveloped_validation_is_wire_identical_to_validation_exception() {
466        // The variant must be indistinguishable from ValidationException on every wire
467        // surface; only the operation boundary treats it specially.
468        let msg = "One or more parameter values were invalid: \
469                   Type mismatch for key pk expected: S actual: N";
470        let enveloped = DynoxideError::EnvelopedValidation(msg.to_string());
471        let plain = DynoxideError::ValidationException(msg.to_string());
472        assert_eq!(enveloped.status_code(), plain.status_code());
473        assert_eq!(enveloped.error_type(), plain.error_type());
474        assert_eq!(enveloped.short_error_code(), plain.short_error_code());
475        assert_eq!(enveloped.to_json(), plain.to_json());
476        assert_eq!(enveloped.to_string(), plain.to_string());
477    }
478
479    #[test]
480    fn test_error_type_strings() {
481        let err = DynoxideError::ValidationException("bad input".into());
482        assert_eq!(
483            err.error_type(),
484            "com.amazon.coral.validate#ValidationException"
485        );
486    }
487
488    #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
489    #[test]
490    fn test_sqlite_error_maps_to_internal() {
491        let sqlite_err = rusqlite::Error::QueryReturnedNoRows;
492        let err = DynoxideError::from(sqlite_err);
493        assert_eq!(err.status_code(), 500);
494        assert!(err.error_type().contains("InternalServerError"));
495    }
496
497    // Error-envelope fidelity for the wasm backend.
498    //
499    // Client-facing envelopes (ResourceNotFound, ConditionalCheckFailed,
500    // Validation, ...) are raised by the shared, generic action handlers, so
501    // they are backend-independent by construction. The only backend-specific
502    // boundary is `From<BackendError> for DynoxideError`, exercised here: the
503    // wasm backend's storage faults must land on the same envelopes the native
504    // rusqlite path produces.
505    #[test]
506    fn test_backend_error_envelopes_match_native() {
507        use crate::storage_backend::BackendError;
508
509        // A client-facing validation limit crosses the boundary as a 400.
510        let v: DynoxideError = BackendError::Validation("too many tags".into()).into();
511        assert_eq!(v.status_code(), 400);
512        assert_eq!(
513            v.error_type(),
514            "com.amazon.coral.validate#ValidationException"
515        );
516
517        // Unsupported (e.g. streams or tags on wasm) surfaces as the same
518        // typed 501 the op-level envelope uses, with a message the conformance
519        // suite classifies as a scope gap ("is not supported") rather than a
520        // server fault. A 500 here would be retried by AWS SDKs, and a retry
521        // of a capability refusal can never succeed.
522        let u: DynoxideError = BackendError::Unsupported { capability: "ttl" }.into();
523        assert_eq!(u.status_code(), 501);
524        assert_eq!(u.error_type(), "com.dynoxide.wasm#UnsupportedOperation");
525        assert!(u.to_string().contains("'ttl' is not supported"));
526
527        // Every other storage fault maps to a 500, matching the native
528        // `rusqlite::Error -> SqliteError -> InternalServerError` path.
529        for e in [
530            BackendError::NotADatabase,
531            BackendError::Locked,
532            BackendError::Constraint("constraint".into()),
533            BackendError::Io("io".into()),
534            BackendError::Other("sqlite-wasm: boom".into()),
535        ] {
536            let d: DynoxideError = e.into();
537            assert_eq!(d.status_code(), 500);
538            assert!(d.error_type().contains("InternalServerError"));
539        }
540    }
541
542    #[test]
543    fn test_error_response_json_structure() {
544        let err = DynoxideError::ValidationException("1 validation error detected".to_string());
545        let resp = err.to_response();
546        let json: serde_json::Value = serde_json::to_value(&resp).unwrap();
547
548        assert!(json.get("__type").is_some());
549        assert!(json.get("message").is_some());
550        assert_eq!(
551            json["__type"],
552            "com.amazon.coral.validate#ValidationException"
553        );
554        assert_eq!(json["message"], "1 validation error detected");
555    }
556
557    #[test]
558    fn test_short_error_codes() {
559        assert_eq!(
560            DynoxideError::ResourceNotFoundException("".into()).short_error_code(),
561            "ResourceNotFound"
562        );
563        assert_eq!(
564            DynoxideError::ValidationException("".into()).short_error_code(),
565            "ValidationError"
566        );
567        assert_eq!(
568            DynoxideError::ConditionalCheckFailedException("".into(), None).short_error_code(),
569            "ConditionalCheckFailed"
570        );
571        assert_eq!(
572            DynoxideError::DuplicateItemException("".into()).short_error_code(),
573            "DuplicateItem"
574        );
575        assert_eq!(
576            DynoxideError::InternalServerError("".into()).short_error_code(),
577            "InternalServerError"
578        );
579    }
580
581    #[test]
582    fn test_transaction_cancelled_json_has_cancellation_reasons() {
583        let reasons = vec![
584            CancellationReason {
585                code: "ConditionalCheckFailed".to_string(),
586                message: Some("The conditional request failed".to_string()),
587                item: None,
588            },
589            CancellationReason {
590                code: "None".to_string(),
591                message: None,
592                item: None,
593            },
594        ];
595        let err = DynoxideError::TransactionCanceledException(
596            "Transaction cancelled, please refer cancellation reasons for specific reasons [ConditionalCheckFailed, None]".to_string(),
597            reasons,
598        );
599        let json_str = err.to_json();
600        let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
601
602        // CancellationReasons must be a top-level field
603        assert!(json.get("CancellationReasons").is_some());
604        let reasons = json["CancellationReasons"].as_array().unwrap();
605        assert_eq!(reasons.len(), 2);
606        assert_eq!(reasons[0]["Code"], "ConditionalCheckFailed");
607        assert_eq!(reasons[1]["Code"], "None");
608
609        // Uses capital Message (not lowercase)
610        assert!(json.get("Message").is_some());
611        assert!(json.get("message").is_none());
612    }
613
614    #[test]
615    fn test_backend_error_maps_to_internal() {
616        use crate::storage_backend::BackendError;
617        let err: DynoxideError = BackendError::Locked.into();
618        assert_eq!(err.status_code(), 500);
619        assert!(err.error_type().contains("InternalServerError"));
620        assert!(err.to_string().contains("locked"));
621    }
622}