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