1use serde::Serialize;
2use std::collections::HashMap;
3use std::fmt;
4
5#[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 pub item: Option<HashMap<String, crate::types::AttributeValue>>,
23}
24
25#[derive(Debug, thiserror::Error)]
34#[non_exhaustive]
35pub enum DynoxideError {
36 #[error("{0}")]
38 ResourceNotFoundException(String),
39
40 #[error("{0}")]
42 ResourceInUseException(String),
43
44 #[error("{0}")]
46 ValidationException(String),
47
48 #[error("{0}")]
53 KeyEmptyValueValidation(String),
54
55 #[error("{0}")]
60 EnvelopedValidation(String),
61
62 #[error("{0}")]
65 ConditionalCheckFailedException(
66 String,
67 Option<HashMap<String, crate::types::AttributeValue>>,
68 ),
69
70 #[error("{0}")]
79 StatementConditionalCheckFailed(String, HashMap<String, crate::types::AttributeValue>),
80
81 #[error("{0}")]
84 TransactionCanceledException(String, Vec<CancellationReason>),
85
86 #[error("{0}")]
88 ItemCollectionSizeLimitExceededException(String),
89
90 #[error("{0}")]
92 DuplicateItemException(String),
93
94 #[error("{0}")]
96 ProvisionedThroughputExceededException(String),
97
98 #[error("{0}")]
100 SerializationException(String),
101
102 #[error("{0}")]
104 LimitExceededException(String),
105
106 #[error("{0}")]
108 AccessDeniedException(String),
109
110 #[error("{0}")]
112 IdempotentParameterMismatchException(String),
113
114 #[error("{0}")]
116 InternalServerError(String),
117
118 #[error("'{0}' is not supported by this build of the engine")]
125 UnsupportedCapability(String),
126
127 #[error("Conversion error: {0}")]
129 ConversionError(#[from] crate::types::ConversionError),
130
131 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
133 #[error("Internal error: {0}")]
134 SqliteError(#[from] rusqlite::Error),
135
136 #[cfg(feature = "wasm-sqlite")]
140 #[error("{0}")]
141 OpfsUnavailable(String),
142}
143
144impl 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
178pub(crate) const UNSUPPORTED_TYPE: &str = "com.dynoxide.wasm#UnsupportedOperation";
184
185impl DynoxideError {
186 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 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 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 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 DynoxideError::UnsupportedCapability(_) => 501,
287 _ => 400,
288 }
289 }
290
291 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 #[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 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 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#[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 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
404pub type Result<T> = std::result::Result<T, DynoxideError>;
406
407#[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 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 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 #[test]
531 fn test_backend_error_envelopes_match_native() {
532 use crate::storage_backend::BackendError;
533
534 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 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 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 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 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 #[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 #[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}