Repository: getmoto/moto

Issue:
DynamoDB2 transact_write_items() raises wrong error on ConditionExpression failure
The implementation of `transact_write_items()` in #2879 appears to work well in my application (thank you!) except for one detail. Behavior differs from boto3 on transaction failure due to a failed `ConditionExpression`.

On failure of a `ConditionExpression` within a transact item, boto will raise a `TransactionCanceledException` with a message with stringified `CancellationReason`s. This allows a client to determine which of the transact items in the submitted in the transaction actually failed. Moto `1.3.15.dev842` raises a `ConditionalCheckFailedException` instead.

Here's a test case demonstrating the issue. This passes when run against dynamo via boto, but fails when using moto:

```python
@mock_dynamodb2
def test_transact_write_items_fails_with_transaction_canceled_exception():
    table_schema = {
        "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
        "AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"},],
    }
    dynamodb = boto3.client("dynamodb", region_name="us-east-1")
    dynamodb.create_table(
        TableName="test-table", BillingMode="PAY_PER_REQUEST", **table_schema
    )
    # Insert one item
    dynamodb.put_item(TableName="test-table", Item={"id": {"S": "foo"}})
    # Update two items, the one that exists and another that doesn't
    with assert_raises(ClientError) as ex:
        dynamodb.transact_write_items(
            TransactItems=[
                {
                    "Update": {
                        "Key": {"id": {"S": "foo"}},
                        "TableName": "test-table",
                        "UpdateExpression": "SET #k = :v",
                        "ConditionExpression": "attribute_exists(id)",
                        "ExpressionAttributeNames": {"#k": "key"},
                        "ExpressionAttributeValues": {":v": {"S": "value"}},
                    }
                },
                {
                    "Update": {
                        "Key": {"id": {"S": "doesnotexist"}},
                        "TableName": "test-table",
                        "UpdateExpression": "SET #e = :v",
                        "ConditionExpression": "attribute_exists(id)",
                        "ExpressionAttributeNames": {"#e": "key"},
                        "ExpressionAttributeValues": {":v": {"S": "value"}},
                    }
                }
            ]
        )
    ex.exception.response["Error"]["Code"].should.equal(
        "TransactionCanceledException"
    )
    ex.exception.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
    ex.exception.response["Error"]["Message"].should.equal(
        "Transaction cancelled, please refer cancellation reasons for specific reasons [None, ConditionalCheckFailed]"
    )
```



Discussion:

Resolving patch:
diff --git a/moto/dynamodb2/exceptions.py b/moto/dynamodb2/exceptions.py
--- a/moto/dynamodb2/exceptions.py
+++ b/moto/dynamodb2/exceptions.py
@@ -149,3 +149,18 @@ class IncorrectDataType(MockValidationException):
 
     def __init__(self):
         super(IncorrectDataType, self).__init__(self.inc_data_type_msg)
+
+
+class ConditionalCheckFailed(ValueError):
+    msg = "The conditional request failed"
+
+    def __init__(self):
+        super(ConditionalCheckFailed, self).__init__(self.msg)
+
+
+class TransactionCanceledException(ValueError):
+    cancel_reason_msg = "Transaction cancelled, please refer cancellation reasons for specific reasons [{}]"
+
+    def __init__(self, errors):
+        msg = self.cancel_reason_msg.format(", ".join([str(err) for err in errors]))
+        super(TransactionCanceledException, self).__init__(msg)
diff --git a/moto/dynamodb2/models/__init__.py b/moto/dynamodb2/models/__init__.py
--- a/moto/dynamodb2/models/__init__.py
+++ b/moto/dynamodb2/models/__init__.py
@@ -18,6 +18,8 @@
     InvalidIndexNameError,
     ItemSizeTooLarge,
     ItemSizeToUpdateTooLarge,
+    ConditionalCheckFailed,
+    TransactionCanceledException,
 )
 from moto.dynamodb2.models.utilities import bytesize
 from moto.dynamodb2.models.dynamo_type import DynamoType
@@ -459,14 +461,14 @@ def put_item(
 
         if not overwrite:
             if not get_expected(expected).expr(current):
-                raise ValueError("The conditional request failed")
+                raise ConditionalCheckFailed
             condition_op = get_filter_expression(
                 condition_expression,
                 expression_attribute_names,
                 expression_attribute_values,
             )
             if not condition_op.expr(current):
-                raise ValueError("The conditional request failed")
+                raise ConditionalCheckFailed
 
         if range_value:
             self.items[hash_value][range_value] = item
@@ -1076,14 +1078,14 @@ def update_item(
             expected = {}
 
         if not get_expected(expected).expr(item):
-            raise ValueError("The conditional request failed")
+            raise ConditionalCheckFailed
         condition_op = get_filter_expression(
             condition_expression,
             expression_attribute_names,
             expression_attribute_values,
         )
         if not condition_op.expr(item):
-            raise ValueError("The conditional request failed")
+            raise ConditionalCheckFailed
 
         # Update does not fail on new items, so create one
         if item is None:
@@ -1136,7 +1138,7 @@ def delete_item(
             expression_attribute_values,
         )
         if not condition_op.expr(item):
-            raise ValueError("The conditional request failed")
+            raise ConditionalCheckFailed
 
         return table.delete_item(hash_value, range_value)
 
@@ -1167,8 +1169,9 @@ def describe_ttl(self, table_name):
     def transact_write_items(self, transact_items):
         # Create a backup in case any of the transactions fail
         original_table_state = copy.deepcopy(self.tables)
-        try:
-            for item in transact_items:
+        errors = []
+        for item in transact_items:
+            try:
                 if "ConditionCheck" in item:
                     item = item["ConditionCheck"]
                     key = item["Key"]
@@ -1188,7 +1191,7 @@ def transact_write_items(self, transact_items):
                         expression_attribute_values,
                     )
                     if not condition_op.expr(current):
-                        raise ValueError("The conditional request failed")
+                        raise ConditionalCheckFailed()
                 elif "Put" in item:
                     item = item["Put"]
                     attrs = item["Item"]
@@ -1247,10 +1250,13 @@ def transact_write_items(self, transact_items):
                     )
                 else:
                     raise ValueError
-        except:  # noqa: E722 Do not use bare except
-            # Rollback to the original state, and reraise the error
+                errors.append(None)
+            except Exception as e:  # noqa: E722 Do not use bare except
+                errors.append(type(e).__name__)
+        if any(errors):
+            # Rollback to the original state, and reraise the errors
             self.tables = original_table_state
-            raise
+            raise TransactionCanceledException(errors)
 
     def describe_continuous_backups(self, table_name):
         table = self.get_table(table_name)
diff --git a/moto/dynamodb2/responses.py b/moto/dynamodb2/responses.py
--- a/moto/dynamodb2/responses.py
+++ b/moto/dynamodb2/responses.py
@@ -9,7 +9,12 @@
 
 from moto.core.responses import BaseResponse
 from moto.core.utils import camelcase_to_underscores, amzn_request_id
-from .exceptions import InvalidIndexNameError, ItemSizeTooLarge, MockValidationException
+from .exceptions import (
+    InvalidIndexNameError,
+    ItemSizeTooLarge,
+    MockValidationException,
+    TransactionCanceledException,
+)
 from moto.dynamodb2.models import dynamodb_backends, dynamo_json_dump
 
 
@@ -929,11 +934,9 @@ def transact_write_items(self):
         transact_items = self.body["TransactItems"]
         try:
             self.dynamodb_backend.transact_write_items(transact_items)
-        except ValueError:
-            er = "com.amazonaws.dynamodb.v20111205#ConditionalCheckFailedException"
-            return self.error(
-                er, "A condition specified in the operation could not be evaluated."
-            )
+        except TransactionCanceledException as e:
+            er = "com.amazonaws.dynamodb.v20111205#TransactionCanceledException"
+            return self.error(er, str(e))
         response = {"ConsumedCapacity": [], "ItemCollectionMetrics": {}}
         return dynamo_json_dump(response)
 


Test patch:
diff --git a/tests/test_dynamodb2/test_dynamodb.py b/tests/test_dynamodb2/test_dynamodb.py
--- a/tests/test_dynamodb2/test_dynamodb.py
+++ b/tests/test_dynamodb2/test_dynamodb.py
@@ -4434,13 +4434,8 @@ def test_transact_write_items_put_conditional_expressions():
             ]
         )
     # Assert the exception is correct
-    ex.exception.response["Error"]["Code"].should.equal(
-        "ConditionalCheckFailedException"
-    )
+    ex.exception.response["Error"]["Code"].should.equal("TransactionCanceledException")
     ex.exception.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
-    ex.exception.response["Error"]["Message"].should.equal(
-        "A condition specified in the operation could not be evaluated."
-    )
     # Assert all are present
     items = dynamodb.scan(TableName="test-table")["Items"]
     items.should.have.length_of(1)
@@ -4529,13 +4524,8 @@ def test_transact_write_items_conditioncheck_fails():
             ]
         )
     # Assert the exception is correct
-    ex.exception.response["Error"]["Code"].should.equal(
-        "ConditionalCheckFailedException"
-    )
+    ex.exception.response["Error"]["Code"].should.equal("TransactionCanceledException")
     ex.exception.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
-    ex.exception.response["Error"]["Message"].should.equal(
-        "A condition specified in the operation could not be evaluated."
-    )
 
     # Assert the original email address is still present
     items = dynamodb.scan(TableName="test-table")["Items"]
@@ -4631,13 +4621,8 @@ def test_transact_write_items_delete_with_failed_condition_expression():
             ]
         )
     # Assert the exception is correct
-    ex.exception.response["Error"]["Code"].should.equal(
-        "ConditionalCheckFailedException"
-    )
+    ex.exception.response["Error"]["Code"].should.equal("TransactionCanceledException")
     ex.exception.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
-    ex.exception.response["Error"]["Message"].should.equal(
-        "A condition specified in the operation could not be evaluated."
-    )
     # Assert the original item is still present
     items = dynamodb.scan(TableName="test-table")["Items"]
     items.should.have.length_of(1)
@@ -4709,13 +4694,8 @@ def test_transact_write_items_update_with_failed_condition_expression():
             ]
         )
     # Assert the exception is correct
-    ex.exception.response["Error"]["Code"].should.equal(
-        "ConditionalCheckFailedException"
-    )
+    ex.exception.response["Error"]["Code"].should.equal("TransactionCanceledException")
     ex.exception.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
-    ex.exception.response["Error"]["Message"].should.equal(
-        "A condition specified in the operation could not be evaluated."
-    )
     # Assert the original item is still present
     items = dynamodb.scan(TableName="test-table")["Items"]
     items.should.have.length_of(1)
@@ -5243,3 +5223,48 @@ def test_update_item_add_to_non_existent_number_set():
     )
     updated_item = ddb_mock.get_item(TableName=table, Key=key)["Item"]
     assert updated_item["s_i"]["NS"] == ["3"]
+
+
+@mock_dynamodb2
+def test_transact_write_items_fails_with_transaction_canceled_exception():
+    table_schema = {
+        "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
+        "AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"},],
+    }
+    dynamodb = boto3.client("dynamodb", region_name="us-east-1")
+    dynamodb.create_table(
+        TableName="test-table", BillingMode="PAY_PER_REQUEST", **table_schema
+    )
+    # Insert one item
+    dynamodb.put_item(TableName="test-table", Item={"id": {"S": "foo"}})
+    # Update two items, the one that exists and another that doesn't
+    with assert_raises(ClientError) as ex:
+        dynamodb.transact_write_items(
+            TransactItems=[
+                {
+                    "Update": {
+                        "Key": {"id": {"S": "foo"}},
+                        "TableName": "test-table",
+                        "UpdateExpression": "SET #k = :v",
+                        "ConditionExpression": "attribute_exists(id)",
+                        "ExpressionAttributeNames": {"#k": "key"},
+                        "ExpressionAttributeValues": {":v": {"S": "value"}},
+                    }
+                },
+                {
+                    "Update": {
+                        "Key": {"id": {"S": "doesnotexist"}},
+                        "TableName": "test-table",
+                        "UpdateExpression": "SET #e = :v",
+                        "ConditionExpression": "attribute_exists(id)",
+                        "ExpressionAttributeNames": {"#e": "key"},
+                        "ExpressionAttributeValues": {":v": {"S": "value"}},
+                    }
+                },
+            ]
+        )
+    ex.exception.response["Error"]["Code"].should.equal("TransactionCanceledException")
+    ex.exception.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
+    ex.exception.response["Error"]["Message"].should.equal(
+        "Transaction cancelled, please refer cancellation reasons for specific reasons [None, ConditionalCheckFailed]"
+    )