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 the JSON body `operation` answers with.
306    ///
307    /// SearchVectors is served by its own endpoint, which writes every error
308    /// as `__type` then a lowercase `message`, a `SerializationException`
309    /// included (captured eu-west-2 and us-east-1, 23 October 2026). Every
310    /// other operation gets [`to_json`](Self::to_json).
311    #[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
312    pub(crate) fn to_json_for(&self, operation: &str) -> String {
313        match self {
314            DynoxideError::SerializationException(_) if operation == "SearchVectors" => {
315                let message = self.to_string();
316                let mut m = serde_json::Map::new();
317                m.insert(
318                    "__type".to_string(),
319                    serde_json::Value::String(self.error_type().to_string()),
320                );
321                if !message.is_empty() {
322                    m.insert("message".to_string(), serde_json::Value::String(message));
323                }
324                serde_json::to_string(&m).unwrap_or_default()
325            }
326            _ => self.to_json(),
327        }
328    }
329
330    /// Serialise to DynamoDB-compatible JSON string.
331    ///
332    /// `SerializationException` and `TransactionCanceledException` use
333    /// `Message` (capital M) while all other errors use `message` (lowercase),
334    /// matching real DynamoDB behaviour.
335    pub fn to_json(&self) -> String {
336        let error_type = self.error_type();
337        let message = self.to_string();
338
339        match self {
340            DynoxideError::TransactionCanceledException(_, reasons) => {
341                let mut m = serde_json::Map::new();
342                m.insert(
343                    "__type".to_string(),
344                    serde_json::Value::String(error_type.to_string()),
345                );
346                m.insert("Message".to_string(), serde_json::Value::String(message));
347                if let Ok(reasons_val) = serde_json::to_value(reasons) {
348                    m.insert("CancellationReasons".to_string(), reasons_val);
349                }
350                serde_json::to_string(&m).unwrap_or_default()
351            }
352            DynoxideError::StatementConditionalCheckFailed(_, item) => {
353                let mut m = serde_json::Map::new();
354                m.insert(
355                    "__type".to_string(),
356                    serde_json::Value::String(error_type.to_string()),
357                );
358                m.insert("Message".to_string(), serde_json::Value::String(message));
359                if let Ok(item) = serde_json::to_value(crate::set_order::HashOrderedItem(item)) {
360                    m.insert("Item".to_string(), item);
361                }
362                serde_json::to_string(&m).unwrap_or_default()
363            }
364            DynoxideError::SerializationException(_) => {
365                let mut m = serde_json::Map::new();
366                m.insert(
367                    "__type".to_string(),
368                    serde_json::Value::String(error_type.to_string()),
369                );
370                // DynamoDB leaves the message out altogether when it has none
371                // to give, as for a list where a boolean belongs.
372                if !message.is_empty() {
373                    m.insert("Message".to_string(), serde_json::Value::String(message));
374                }
375                serde_json::to_string(&m).unwrap_or_default()
376            }
377            _ => {
378                let resp = self.to_response();
379                serde_json::to_string(&resp).unwrap_or_default()
380            }
381        }
382    }
383}
384
385/// DynamoDB JSON error response body.
386#[derive(Debug, Serialize)]
387pub struct ErrorResponse {
388    #[serde(rename = "__type")]
389    pub error_type: String,
390    #[serde(rename = "message")]
391    pub message: String,
392    #[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
393    // Number sets in numeric order here, as DynamoDB returns them;
394    // pinned by set_order's every_response_item_field test.
395    pub item: Option<HashMap<String, crate::types::AttributeValue>>,
396}
397
398impl fmt::Display for ErrorResponse {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        write!(f, "{}", serde_json::to_string(self).unwrap_or_default())
401    }
402}
403
404/// Convenience alias.
405pub type Result<T> = std::result::Result<T, DynoxideError>;
406
407/// The error as `operation` puts it on the wire, for the HTTP and wasm
408/// dispatch seams: `ExecuteStatement`'s condition failure carrying an item is
409/// written in its own shape (see
410/// [`DynoxideError::StatementConditionalCheckFailed`]). Every other error
411/// passes through unchanged.
412#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
413pub(crate) fn for_operation(operation: &str, err: DynoxideError) -> DynoxideError {
414    match (operation, err) {
415        (
416            "ExecuteStatement",
417            DynoxideError::ConditionalCheckFailedException(message, Some(item)),
418        ) => DynoxideError::StatementConditionalCheckFailed(message, item),
419        (_, err) => err,
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn test_error_response_format() {
429        let err = DynoxideError::ResourceNotFoundException(
430            "Requested resource not found: Table: NonExistent not found".to_string(),
431        );
432        let resp = err.to_response();
433        let json = serde_json::to_string(&resp).unwrap();
434
435        assert!(json.contains("\"__type\""));
436        assert!(json.contains("ResourceNotFoundException"));
437        assert!(json.contains("NonExistent not found"));
438    }
439
440    #[test]
441    fn test_status_codes() {
442        assert_eq!(
443            DynoxideError::ResourceNotFoundException("".into()).status_code(),
444            400
445        );
446        assert_eq!(
447            DynoxideError::ResourceInUseException("".into()).status_code(),
448            400
449        );
450        assert_eq!(
451            DynoxideError::ValidationException("".into()).status_code(),
452            400
453        );
454        assert_eq!(
455            DynoxideError::ConditionalCheckFailedException("".into(), None).status_code(),
456            400
457        );
458        assert_eq!(
459            DynoxideError::TransactionCanceledException("".into(), vec![]).status_code(),
460            400
461        );
462        assert_eq!(
463            DynoxideError::InternalServerError("".into()).status_code(),
464            500
465        );
466    }
467
468    #[test]
469    fn test_key_empty_value_validation_is_wire_identical_to_validation_exception() {
470        // The variant must be indistinguishable from ValidationException on every wire
471        // surface, for both the empty-string and empty-binary messages it now carries.
472        let messages = [
473            "One or more parameter values are not valid. The AttributeValue for a key \
474             attribute cannot contain an empty string value. Key: pk",
475            "One or more parameter values are not valid. The AttributeValue for a key \
476             attribute cannot contain an empty binary value. Key: pk",
477        ];
478        for msg in messages {
479            let empty = DynoxideError::KeyEmptyValueValidation(msg.to_string());
480            let plain = DynoxideError::ValidationException(msg.to_string());
481            assert_eq!(empty.status_code(), plain.status_code());
482            assert_eq!(empty.error_type(), plain.error_type());
483            assert_eq!(empty.short_error_code(), plain.short_error_code());
484            assert_eq!(empty.to_json(), plain.to_json());
485            assert_eq!(empty.to_string(), plain.to_string());
486        }
487    }
488
489    #[test]
490    fn test_enveloped_validation_is_wire_identical_to_validation_exception() {
491        // The variant must be indistinguishable from ValidationException on every wire
492        // surface; only the operation boundary treats it specially.
493        let msg = "One or more parameter values were invalid: \
494                   Type mismatch for key pk expected: S actual: N";
495        let enveloped = DynoxideError::EnvelopedValidation(msg.to_string());
496        let plain = DynoxideError::ValidationException(msg.to_string());
497        assert_eq!(enveloped.status_code(), plain.status_code());
498        assert_eq!(enveloped.error_type(), plain.error_type());
499        assert_eq!(enveloped.short_error_code(), plain.short_error_code());
500        assert_eq!(enveloped.to_json(), plain.to_json());
501        assert_eq!(enveloped.to_string(), plain.to_string());
502    }
503
504    #[test]
505    fn test_error_type_strings() {
506        let err = DynoxideError::ValidationException("bad input".into());
507        assert_eq!(
508            err.error_type(),
509            "com.amazon.coral.validate#ValidationException"
510        );
511    }
512
513    #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
514    #[test]
515    fn test_sqlite_error_maps_to_internal() {
516        let sqlite_err = rusqlite::Error::QueryReturnedNoRows;
517        let err = DynoxideError::from(sqlite_err);
518        assert_eq!(err.status_code(), 500);
519        assert!(err.error_type().contains("InternalServerError"));
520    }
521
522    // Error-envelope fidelity for the wasm backend.
523    //
524    // Client-facing envelopes (ResourceNotFound, ConditionalCheckFailed,
525    // Validation, ...) are raised by the shared, generic action handlers, so
526    // they are backend-independent by construction. The only backend-specific
527    // boundary is `From<BackendError> for DynoxideError`, exercised here: the
528    // wasm backend's storage faults must land on the same envelopes the native
529    // rusqlite path produces.
530    #[test]
531    fn test_backend_error_envelopes_match_native() {
532        use crate::storage_backend::BackendError;
533
534        // A client-facing validation limit crosses the boundary as a 400.
535        let v: DynoxideError = BackendError::Validation("too many tags".into()).into();
536        assert_eq!(v.status_code(), 400);
537        assert_eq!(
538            v.error_type(),
539            "com.amazon.coral.validate#ValidationException"
540        );
541
542        // Unsupported (e.g. streams or tags on wasm) surfaces as the same
543        // typed 501 the op-level envelope uses, with a message the conformance
544        // suite classifies as a scope gap ("is not supported") rather than a
545        // server fault. A 500 here would be retried by AWS SDKs, and a retry
546        // of a capability refusal can never succeed.
547        let u: DynoxideError = BackendError::Unsupported { capability: "ttl" }.into();
548        assert_eq!(u.status_code(), 501);
549        assert_eq!(u.error_type(), "com.dynoxide.wasm#UnsupportedOperation");
550        assert!(u.to_string().contains("'ttl' is not supported"));
551
552        // Every other storage fault maps to a 500, matching the native
553        // `rusqlite::Error -> SqliteError -> InternalServerError` path.
554        for e in [
555            BackendError::NotADatabase,
556            BackendError::Locked,
557            BackendError::Constraint("constraint".into()),
558            BackendError::Io("io".into()),
559            BackendError::Other("sqlite-wasm: boom".into()),
560        ] {
561            let d: DynoxideError = e.into();
562            assert_eq!(d.status_code(), 500);
563            assert!(d.error_type().contains("InternalServerError"));
564        }
565    }
566
567    #[test]
568    fn test_error_response_json_structure() {
569        let err = DynoxideError::ValidationException("1 validation error detected".to_string());
570        let resp = err.to_response();
571        let json: serde_json::Value = serde_json::to_value(&resp).unwrap();
572
573        assert!(json.get("__type").is_some());
574        assert!(json.get("message").is_some());
575        assert_eq!(
576            json["__type"],
577            "com.amazon.coral.validate#ValidationException"
578        );
579        assert_eq!(json["message"], "1 validation error detected");
580    }
581
582    #[test]
583    fn test_short_error_codes() {
584        assert_eq!(
585            DynoxideError::ResourceNotFoundException("".into()).short_error_code(),
586            "ResourceNotFound"
587        );
588        assert_eq!(
589            DynoxideError::ValidationException("".into()).short_error_code(),
590            "ValidationError"
591        );
592        assert_eq!(
593            DynoxideError::ConditionalCheckFailedException("".into(), None).short_error_code(),
594            "ConditionalCheckFailed"
595        );
596        assert_eq!(
597            DynoxideError::DuplicateItemException("".into()).short_error_code(),
598            "DuplicateItem"
599        );
600        assert_eq!(
601            DynoxideError::InternalServerError("".into()).short_error_code(),
602            "InternalServerError"
603        );
604    }
605
606    #[test]
607    fn test_transaction_cancelled_json_has_cancellation_reasons() {
608        let reasons = vec![
609            CancellationReason {
610                code: "ConditionalCheckFailed".to_string(),
611                message: Some("The conditional request failed".to_string()),
612                item: None,
613            },
614            CancellationReason {
615                code: "None".to_string(),
616                message: None,
617                item: None,
618            },
619        ];
620        let err = DynoxideError::TransactionCanceledException(
621            "Transaction cancelled, please refer cancellation reasons for specific reasons [ConditionalCheckFailed, None]".to_string(),
622            reasons,
623        );
624        let json_str = err.to_json();
625        let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
626
627        // CancellationReasons must be a top-level field
628        assert!(json.get("CancellationReasons").is_some());
629        let reasons = json["CancellationReasons"].as_array().unwrap();
630        assert_eq!(reasons.len(), 2);
631        assert_eq!(reasons[0]["Code"], "ConditionalCheckFailed");
632        assert_eq!(reasons[1]["Code"], "None");
633
634        // Uses capital Message (not lowercase)
635        assert!(json.get("Message").is_some());
636        assert!(json.get("message").is_none());
637    }
638
639    #[test]
640    fn test_backend_error_maps_to_internal() {
641        use crate::storage_backend::BackendError;
642        let err: DynoxideError = BackendError::Locked.into();
643        assert_eq!(err.status_code(), 500);
644        assert!(err.error_type().contains("InternalServerError"));
645        assert!(err.to_string().contains("locked"));
646    }
647
648    /// SearchVectors' own endpoint writes a SerializationException as
649    /// `__type` then a lowercase `message`, leaving the message out when it
650    /// has none (captured eu-west-2 and us-east-1, 23 October 2026).
651    #[test]
652    fn search_vectors_writes_a_serialization_exception_with_a_lowercase_message() {
653        let err = DynoxideError::SerializationException(
654            "invalid type: null, expected a string at line 1 column 20".to_string(),
655        );
656        assert_eq!(
657            err.to_json_for("SearchVectors"),
658            r#"{"__type":"com.amazon.coral.service#SerializationException","message":"invalid type: null, expected a string at line 1 column 20"}"#
659        );
660        assert_eq!(
661            DynoxideError::SerializationException(String::new()).to_json_for("SearchVectors"),
662            r#"{"__type":"com.amazon.coral.service#SerializationException"}"#
663        );
664    }
665
666    /// Every other operation, and every other error from SearchVectors, keeps
667    /// the body `to_json` writes.
668    #[test]
669    fn other_operations_and_errors_keep_the_shared_body() {
670        let serialization = DynoxideError::SerializationException("Unexpected field type".into());
671        for operation in ["Query", "CreateTable", "GetItem"] {
672            assert_eq!(
673                serialization.to_json_for(operation),
674                serialization.to_json()
675            );
676        }
677        let validation = DynoxideError::ValidationException("bad".into());
678        assert_eq!(
679            validation.to_json_for("SearchVectors"),
680            validation.to_json()
681        );
682        assert!(
683            serialization
684                .to_json()
685                .contains(r#""Message":"Unexpected field type""#)
686        );
687    }
688}