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(rename = "Item", skip_serializing_if = "Option::is_none")]
16 pub item: Option<HashMap<String, crate::types::AttributeValue>>,
17}
18
19#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum DynoxideError {
30 #[error("{0}")]
32 ResourceNotFoundException(String),
33
34 #[error("{0}")]
36 ResourceInUseException(String),
37
38 #[error("{0}")]
40 ValidationException(String),
41
42 #[error("{0}")]
47 KeyEmptyValueValidation(String),
48
49 #[error("{0}")]
54 EnvelopedValidation(String),
55
56 #[error("{0}")]
59 ConditionalCheckFailedException(
60 String,
61 Option<HashMap<String, crate::types::AttributeValue>>,
62 ),
63
64 #[error("{0}")]
67 TransactionCanceledException(String, Vec<CancellationReason>),
68
69 #[error("{0}")]
71 ItemCollectionSizeLimitExceededException(String),
72
73 #[error("{0}")]
75 DuplicateItemException(String),
76
77 #[error("{0}")]
79 ProvisionedThroughputExceededException(String),
80
81 #[error("{0}")]
83 SerializationException(String),
84
85 #[error("{0}")]
87 LimitExceededException(String),
88
89 #[error("{0}")]
91 AccessDeniedException(String),
92
93 #[error("{0}")]
95 IdempotentParameterMismatchException(String),
96
97 #[error("{0}")]
99 InternalServerError(String),
100
101 #[error("'{0}' is not supported by this build of the engine")]
108 UnsupportedCapability(String),
109
110 #[error("Conversion error: {0}")]
112 ConversionError(#[from] crate::types::ConversionError),
113
114 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
116 #[error("Internal error: {0}")]
117 SqliteError(#[from] rusqlite::Error),
118
119 #[cfg(feature = "wasm-sqlite")]
123 #[error("{0}")]
124 OpfsUnavailable(String),
125}
126
127impl 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
161pub(crate) const UNSUPPORTED_TYPE: &str = "com.dynoxide.wasm#UnsupportedOperation";
167
168impl DynoxideError {
169 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 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 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 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 DynoxideError::UnsupportedCapability(_) => 501,
268 _ => 400,
269 }
270 }
271
272 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 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#[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
342pub 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 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 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 #[test]
453 fn test_backend_error_envelopes_match_native() {
454 use crate::storage_backend::BackendError;
455
456 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 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 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 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 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}