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