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}")]
52 ConditionalCheckFailedException(
53 String,
54 Option<HashMap<String, crate::types::AttributeValue>>,
55 ),
56
57 #[error("{0}")]
60 TransactionCanceledException(String, Vec<CancellationReason>),
61
62 #[error("{0}")]
64 ItemCollectionSizeLimitExceededException(String),
65
66 #[error("{0}")]
68 DuplicateItemException(String),
69
70 #[error("{0}")]
72 ProvisionedThroughputExceededException(String),
73
74 #[error("{0}")]
76 SerializationException(String),
77
78 #[error("{0}")]
80 LimitExceededException(String),
81
82 #[error("{0}")]
84 AccessDeniedException(String),
85
86 #[error("{0}")]
88 IdempotentParameterMismatchException(String),
89
90 #[error("{0}")]
92 InternalServerError(String),
93
94 #[error("Conversion error: {0}")]
96 ConversionError(#[from] crate::types::ConversionError),
97
98 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
100 #[error("Internal error: {0}")]
101 SqliteError(#[from] rusqlite::Error),
102
103 #[cfg(feature = "wasm-sqlite")]
107 #[error("{0}")]
108 OpfsUnavailable(String),
109}
110
111impl From<crate::storage_backend::BackendError> for DynoxideError {
127 fn from(err: crate::storage_backend::BackendError) -> Self {
128 use crate::storage_backend::BackendError;
129 match err {
130 BackendError::Validation(msg) => DynoxideError::ValidationException(msg),
131 #[cfg(feature = "wasm-sqlite")]
132 BackendError::OpfsUnavailable(msg) => DynoxideError::OpfsUnavailable(msg),
133 other => DynoxideError::InternalServerError(other.to_string()),
134 }
135 }
136}
137
138impl DynoxideError {
139 pub fn error_type(&self) -> &'static str {
141 match self {
142 DynoxideError::ResourceNotFoundException(_) => {
143 "com.amazonaws.dynamodb.v20120810#ResourceNotFoundException"
144 }
145 DynoxideError::ResourceInUseException(_) => {
146 "com.amazonaws.dynamodb.v20120810#ResourceInUseException"
147 }
148 DynoxideError::ValidationException(_) | DynoxideError::KeyEmptyValueValidation(_) => {
149 "com.amazon.coral.validate#ValidationException"
150 }
151 DynoxideError::ConditionalCheckFailedException(..) => {
152 "com.amazonaws.dynamodb.v20120810#ConditionalCheckFailedException"
153 }
154 DynoxideError::TransactionCanceledException(..) => {
155 "com.amazonaws.dynamodb.v20120810#TransactionCanceledException"
156 }
157 DynoxideError::DuplicateItemException(_) => {
158 "com.amazonaws.dynamodb.v20120810#DuplicateItemException"
159 }
160 DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
161 "com.amazonaws.dynamodb.v20120810#ItemCollectionSizeLimitExceededException"
162 }
163 DynoxideError::ProvisionedThroughputExceededException(_) => {
164 "com.amazonaws.dynamodb.v20120810#ProvisionedThroughputExceededException"
165 }
166 DynoxideError::SerializationException(_) => {
167 "com.amazon.coral.service#SerializationException"
168 }
169 DynoxideError::LimitExceededException(_) => {
170 "com.amazonaws.dynamodb.v20120810#LimitExceededException"
171 }
172 DynoxideError::AccessDeniedException(_) => {
173 "com.amazonaws.dynamodb.v20120810#AccessDeniedException"
174 }
175 DynoxideError::IdempotentParameterMismatchException(_) => {
176 "com.amazonaws.dynamodb.v20120810#IdempotentParameterMismatchException"
177 }
178 DynoxideError::ConversionError(_) => "com.amazon.coral.validate#ValidationException",
179 DynoxideError::InternalServerError(_) => {
180 "com.amazonaws.dynamodb.v20120810#InternalServerError"
181 }
182 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
183 DynoxideError::SqliteError(_) => "com.amazonaws.dynamodb.v20120810#InternalServerError",
184 #[cfg(feature = "wasm-sqlite")]
185 DynoxideError::OpfsUnavailable(_) => "com.dynoxide.wasm#OpfsUnavailable",
186 }
187 }
188
189 pub fn short_error_code(&self) -> &'static str {
194 match self {
195 DynoxideError::ResourceNotFoundException(_) => "ResourceNotFound",
196 DynoxideError::ResourceInUseException(_) => "ResourceInUse",
197 DynoxideError::ValidationException(_)
198 | DynoxideError::KeyEmptyValueValidation(_)
199 | DynoxideError::ConversionError(_) => "ValidationError",
200 DynoxideError::ConditionalCheckFailedException(..) => "ConditionalCheckFailed",
201 DynoxideError::TransactionCanceledException(..) => "TransactionConflict",
202 DynoxideError::DuplicateItemException(_) => "DuplicateItem",
203 DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
204 "ItemCollectionSizeLimitExceeded"
205 }
206 DynoxideError::ProvisionedThroughputExceededException(_) => {
207 "ProvisionedThroughputExceeded"
208 }
209 DynoxideError::AccessDeniedException(_) => "AccessDenied",
210 DynoxideError::IdempotentParameterMismatchException(_) => "IdempotentParameterMismatch",
211 DynoxideError::SerializationException(_) => "SerializationError",
212 DynoxideError::LimitExceededException(_) => "RequestLimitExceeded",
213 DynoxideError::InternalServerError(_) => "InternalServerError",
214 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
215 DynoxideError::SqliteError(_) => "InternalServerError",
216 #[cfg(feature = "wasm-sqlite")]
217 DynoxideError::OpfsUnavailable(_) => "OpfsUnavailable",
218 }
219 }
220
221 pub fn status_code(&self) -> u16 {
223 match self {
224 DynoxideError::InternalServerError(_) => 500,
225 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
226 DynoxideError::SqliteError(_) => 500,
227 _ => 400,
228 }
229 }
230
231 pub fn to_response(&self) -> ErrorResponse {
233 let item = if let DynoxideError::ConditionalCheckFailedException(_, item) = self {
234 item.clone()
235 } else {
236 None
237 };
238 ErrorResponse {
239 error_type: self.error_type().to_string(),
240 message: self.to_string(),
241 item,
242 }
243 }
244
245 pub fn to_json(&self) -> String {
251 let error_type = self.error_type();
252 let message = self.to_string();
253
254 match self {
255 DynoxideError::TransactionCanceledException(_, reasons) => {
256 let mut m = serde_json::Map::new();
257 m.insert(
258 "__type".to_string(),
259 serde_json::Value::String(error_type.to_string()),
260 );
261 m.insert("Message".to_string(), serde_json::Value::String(message));
262 if let Ok(reasons_val) = serde_json::to_value(reasons) {
263 m.insert("CancellationReasons".to_string(), reasons_val);
264 }
265 serde_json::to_string(&m).unwrap_or_default()
266 }
267 DynoxideError::SerializationException(_) => {
268 let mut m = serde_json::Map::new();
269 m.insert(
270 "__type".to_string(),
271 serde_json::Value::String(error_type.to_string()),
272 );
273 m.insert("Message".to_string(), serde_json::Value::String(message));
274 serde_json::to_string(&m).unwrap_or_default()
275 }
276 _ => {
277 let resp = self.to_response();
278 serde_json::to_string(&resp).unwrap_or_default()
279 }
280 }
281 }
282}
283
284#[derive(Debug, Serialize)]
286pub struct ErrorResponse {
287 #[serde(rename = "__type")]
288 pub error_type: String,
289 #[serde(rename = "message")]
290 pub message: String,
291 #[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
292 pub item: Option<HashMap<String, crate::types::AttributeValue>>,
293}
294
295impl fmt::Display for ErrorResponse {
296 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297 write!(f, "{}", serde_json::to_string(self).unwrap_or_default())
298 }
299}
300
301pub type Result<T> = std::result::Result<T, DynoxideError>;
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn test_error_response_format() {
310 let err = DynoxideError::ResourceNotFoundException(
311 "Requested resource not found: Table: NonExistent not found".to_string(),
312 );
313 let resp = err.to_response();
314 let json = serde_json::to_string(&resp).unwrap();
315
316 assert!(json.contains("\"__type\""));
317 assert!(json.contains("ResourceNotFoundException"));
318 assert!(json.contains("NonExistent not found"));
319 }
320
321 #[test]
322 fn test_status_codes() {
323 assert_eq!(
324 DynoxideError::ResourceNotFoundException("".into()).status_code(),
325 400
326 );
327 assert_eq!(
328 DynoxideError::ResourceInUseException("".into()).status_code(),
329 400
330 );
331 assert_eq!(
332 DynoxideError::ValidationException("".into()).status_code(),
333 400
334 );
335 assert_eq!(
336 DynoxideError::ConditionalCheckFailedException("".into(), None).status_code(),
337 400
338 );
339 assert_eq!(
340 DynoxideError::TransactionCanceledException("".into(), vec![]).status_code(),
341 400
342 );
343 assert_eq!(
344 DynoxideError::InternalServerError("".into()).status_code(),
345 500
346 );
347 }
348
349 #[test]
350 fn test_key_empty_value_validation_is_wire_identical_to_validation_exception() {
351 let messages = [
354 "One or more parameter values are not valid. The AttributeValue for a key \
355 attribute cannot contain an empty string value. Key: pk",
356 "One or more parameter values are not valid. The AttributeValue for a key \
357 attribute cannot contain an empty binary value. Key: pk",
358 ];
359 for msg in messages {
360 let empty = DynoxideError::KeyEmptyValueValidation(msg.to_string());
361 let plain = DynoxideError::ValidationException(msg.to_string());
362 assert_eq!(empty.status_code(), plain.status_code());
363 assert_eq!(empty.error_type(), plain.error_type());
364 assert_eq!(empty.short_error_code(), plain.short_error_code());
365 assert_eq!(empty.to_json(), plain.to_json());
366 assert_eq!(empty.to_string(), plain.to_string());
367 }
368 }
369
370 #[test]
371 fn test_error_type_strings() {
372 let err = DynoxideError::ValidationException("bad input".into());
373 assert_eq!(
374 err.error_type(),
375 "com.amazon.coral.validate#ValidationException"
376 );
377 }
378
379 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
380 #[test]
381 fn test_sqlite_error_maps_to_internal() {
382 let sqlite_err = rusqlite::Error::QueryReturnedNoRows;
383 let err = DynoxideError::from(sqlite_err);
384 assert_eq!(err.status_code(), 500);
385 assert!(err.error_type().contains("InternalServerError"));
386 }
387
388 #[test]
397 fn test_backend_error_envelopes_match_native() {
398 use crate::storage_backend::BackendError;
399
400 let v: DynoxideError = BackendError::Validation("too many tags".into()).into();
402 assert_eq!(v.status_code(), 400);
403 assert_eq!(
404 v.error_type(),
405 "com.amazon.coral.validate#ValidationException"
406 );
407
408 let u: DynoxideError = BackendError::Unsupported { capability: "ttl" }.into();
411 assert_eq!(u.status_code(), 500);
412 assert!(u.error_type().contains("InternalServerError"));
413 assert!(u.to_string().contains("ttl"));
414
415 for e in [
418 BackendError::NotADatabase,
419 BackendError::Locked,
420 BackendError::Constraint("constraint".into()),
421 BackendError::Io("io".into()),
422 BackendError::Other("sqlite-wasm: boom".into()),
423 ] {
424 let d: DynoxideError = e.into();
425 assert_eq!(d.status_code(), 500);
426 assert!(d.error_type().contains("InternalServerError"));
427 }
428 }
429
430 #[test]
431 fn test_error_response_json_structure() {
432 let err = DynoxideError::ValidationException("1 validation error detected".to_string());
433 let resp = err.to_response();
434 let json: serde_json::Value = serde_json::to_value(&resp).unwrap();
435
436 assert!(json.get("__type").is_some());
437 assert!(json.get("message").is_some());
438 assert_eq!(
439 json["__type"],
440 "com.amazon.coral.validate#ValidationException"
441 );
442 assert_eq!(json["message"], "1 validation error detected");
443 }
444
445 #[test]
446 fn test_short_error_codes() {
447 assert_eq!(
448 DynoxideError::ResourceNotFoundException("".into()).short_error_code(),
449 "ResourceNotFound"
450 );
451 assert_eq!(
452 DynoxideError::ValidationException("".into()).short_error_code(),
453 "ValidationError"
454 );
455 assert_eq!(
456 DynoxideError::ConditionalCheckFailedException("".into(), None).short_error_code(),
457 "ConditionalCheckFailed"
458 );
459 assert_eq!(
460 DynoxideError::DuplicateItemException("".into()).short_error_code(),
461 "DuplicateItem"
462 );
463 assert_eq!(
464 DynoxideError::InternalServerError("".into()).short_error_code(),
465 "InternalServerError"
466 );
467 }
468
469 #[test]
470 fn test_transaction_cancelled_json_has_cancellation_reasons() {
471 let reasons = vec![
472 CancellationReason {
473 code: "ConditionalCheckFailed".to_string(),
474 message: Some("The conditional request failed".to_string()),
475 item: None,
476 },
477 CancellationReason {
478 code: "None".to_string(),
479 message: None,
480 item: None,
481 },
482 ];
483 let err = DynoxideError::TransactionCanceledException(
484 "Transaction cancelled, please refer cancellation reasons for specific reasons [ConditionalCheckFailed, None]".to_string(),
485 reasons,
486 );
487 let json_str = err.to_json();
488 let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
489
490 assert!(json.get("CancellationReasons").is_some());
492 let reasons = json["CancellationReasons"].as_array().unwrap();
493 assert_eq!(reasons.len(), 2);
494 assert_eq!(reasons[0]["Code"], "ConditionalCheckFailed");
495 assert_eq!(reasons[1]["Code"], "None");
496
497 assert!(json.get("Message").is_some());
499 assert!(json.get("message").is_none());
500 }
501
502 #[test]
503 fn test_backend_error_maps_to_internal() {
504 use crate::storage_backend::BackendError;
505 let err: DynoxideError = BackendError::Locked.into();
506 assert_eq!(err.status_code(), 500);
507 assert!(err.error_type().contains("InternalServerError"));
508 assert!(err.to_string().contains("locked"));
509 }
510}