Skip to content

Commit e59d9bb

Browse files
authored
Expose transaction cancellation reasons (#1156)
This will allow the caller to know e.g. which of the transaction items ran into a conflict, which can sometimes allow app logic to better handle the error without needing to make more requests.
1 parent c835789 commit e59d9bb

File tree

7 files changed

+133
-14
lines changed

7 files changed

+133
-14
lines changed

docs/api.rst

+6-1
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,9 @@ Exceptions
3838
.. autoexception:: pynamodb.exceptions.TableError
3939
.. autoexception:: pynamodb.exceptions.TableDoesNotExist
4040
.. autoexception:: pynamodb.exceptions.DoesNotExist
41-
41+
.. autoexception:: pynamodb.exceptions.TransactWriteError
42+
.. autoexception:: pynamodb.exceptions.TransactGetError
43+
.. autoexception:: pynamodb.exceptions.InvalidStateError
44+
.. autoexception:: pynamodb.exceptions.AttributeDeserializationError
45+
.. autoexception:: pynamodb.exceptions.AttributeNullError
46+
.. autoclass:: pynamodb.exceptions.CancellationReason

docs/release_notes.rst

+7
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
Release Notes
44
=============
55

6+
v5.4.0
7+
----------
8+
* Expose transaction cancellation reasons in
9+
:meth:`~pynamodb.exceptions.TransactWriteError.cancellation_reasons` and
10+
:meth:`~pynamodb.exceptions.TransactGetError.cancellation_reasons` (#1144).
11+
12+
613
v5.3.5
714
----------
815
* Fix message of some exceptions derived from :class:`~pynamodb.exceptions.PynamoDBException` (#1113).

docs/transaction.rst

+4
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ Now, say you make another attempt to debit one of the accounts when they don't h
105105
# Because the condition check on the account balance failed,
106106
# the entire transaction should be cancelled
107107
assert e.cause_response_code == 'TransactionCanceledException'
108+
# the first 'update' was a reason for the cancellation
109+
assert e.cancellation_reasons[0].code == 'ConditionalCheckFailed'
110+
# the second 'update' wasn't a reason, but was cancelled too
111+
assert e.cancellation_reasons[1] is None
108112
109113
user1_statement.refresh()
110114
user2_statement.refresh()

pynamodb/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
"""
88
__author__ = 'Jharrod LaFon'
99
__license__ = 'MIT'
10-
__version__ = '5.3.5'
10+
__version__ = '5.4.0'

pynamodb/connection/base.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@
4848
from pynamodb.exceptions import (
4949
TableError, QueryError, PutError, DeleteError, UpdateError, GetError, ScanError, TableDoesNotExist,
5050
VerboseClientError,
51-
TransactGetError, TransactWriteError)
51+
TransactGetError, TransactWriteError, CancellationReason,
52+
)
5253
from pynamodb.expressions.condition import Condition
5354
from pynamodb.expressions.operand import Path
5455
from pynamodb.expressions.projection import create_projection_expression
@@ -465,7 +466,20 @@ def _make_api_call(self, operation_name: str, operation_kwargs: Dict, settings:
465466
verbose_properties['table_name'] = operation_kwargs.get(TABLE_NAME)
466467

467468
try:
468-
raise VerboseClientError(botocore_expected_format, operation_name, verbose_properties)
469+
raise VerboseClientError(
470+
botocore_expected_format,
471+
operation_name,
472+
verbose_properties,
473+
cancellation_reasons=(
474+
(
475+
CancellationReason(
476+
code=d['Code'],
477+
message=d.get('Message'),
478+
) if d['Code'] != 'None' else None
479+
)
480+
for d in data.get('CancellationReasons', [])
481+
),
482+
)
469483
except VerboseClientError as e:
470484
if is_last_attempt_for_exceptions:
471485
log.debug('Reached the maximum number of retry attempts: %s', attempt_number)

pynamodb/exceptions.py

+72-7
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""
22
PynamoDB exceptions
33
"""
4-
5-
from typing import Any, Optional
4+
from typing import Any
5+
from typing import Dict
6+
from typing import Iterable
7+
from typing import List
8+
from typing import Optional
69

710
import botocore.exceptions
811

@@ -99,18 +102,59 @@ def __init__(self, table_name: str) -> None:
99102
super(TableDoesNotExist, self).__init__(msg)
100103

101104

105+
class CancellationReason:
106+
"""
107+
A reason for a transaction cancellation.
108+
"""
109+
def __init__(self, *, code: str, message: Optional[str]) -> None:
110+
self.code = code
111+
self.message = message
112+
113+
def __eq__(self, other: Any) -> bool:
114+
return isinstance(other, CancellationReason) and self.code == other.code and self.message == other.message
115+
116+
102117
class TransactWriteError(PynamoDBException):
103118
"""
104119
Raised when a TransactWrite operation fails
105120
"""
106-
pass
121+
122+
@property
123+
def cancellation_reasons(self) -> List[Optional[CancellationReason]]:
124+
"""
125+
When :attr:`.cause_response_code` is ``TransactionCanceledException``, this property lists
126+
cancellation reasons in the same order as the transaction items (one-to-one).
127+
Items which were not part of the reason for cancellation would have :code:`None` as the value.
128+
129+
For a list of possible cancellation reasons and their semantics,
130+
see `TransactWriteItems`_ in the AWS documentation.
131+
132+
.. _TransactWriteItems: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html
133+
"""
134+
if not isinstance(self.cause, VerboseClientError):
135+
return []
136+
return self.cause.cancellation_reasons
107137

108138

109139
class TransactGetError(PynamoDBException):
110140
"""
111141
Raised when a TransactGet operation fails
112142
"""
113-
pass
143+
@property
144+
def cancellation_reasons(self) -> List[Optional[CancellationReason]]:
145+
"""
146+
When :attr:`.cause_response_code` is ``TransactionCanceledException``, this property lists
147+
cancellation reasons in the same order as the transaction items (one-to-one).
148+
Items which were not part of the reason for cancellation would have :code:`None` as the value.
149+
150+
For a list of possible cancellation reasons and their semantics,
151+
see `TransactGetItems`_ in the AWS documentation.
152+
153+
.. _TransactGetItems: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactGetItems.html
154+
"""
155+
if not isinstance(self.cause, VerboseClientError):
156+
return []
157+
return self.cause.cancellation_reasons
114158

115159

116160
class InvalidStateError(PynamoDBException):
@@ -141,8 +185,24 @@ def prepend_path(self, attr_name: str) -> None:
141185

142186

143187
class VerboseClientError(botocore.exceptions.ClientError):
144-
def __init__(self, error_response: Any, operation_name: str, verbose_properties: Optional[Any] = None):
145-
""" Modify the message template to include the desired verbose properties """
188+
def __init__(
189+
self,
190+
error_response: Dict[str, Any],
191+
operation_name: str,
192+
verbose_properties: Optional[Any] = None,
193+
*,
194+
cancellation_reasons: Iterable[Optional[CancellationReason]] = (),
195+
) -> None:
196+
"""
197+
Like ClientError, but with a verbose message.
198+
199+
:param error_response: Error response in shape expected by ClientError.
200+
:param operation_name: The name of the operation that failed.
201+
:param verbose_properties: A dict of properties to include in the verbose message.
202+
:param cancellation_reasons: For `TransactionCanceledException` error code,
203+
a list of cancellation reasons in the same order as the transaction's items (one to one).
204+
For items which were not a reason for the transaction cancellation, :code:`None` will be the value.
205+
"""
146206
if not verbose_properties:
147207
verbose_properties = {}
148208

@@ -152,4 +212,9 @@ def __init__(self, error_response: Any, operation_name: str, verbose_properties:
152212
'operation: {{error_message}}'
153213
).format(request_id=verbose_properties.get('request_id'), table_name=verbose_properties.get('table_name'))
154214

155-
super(VerboseClientError, self).__init__(error_response, operation_name)
215+
self.cancellation_reasons = list(cancellation_reasons)
216+
217+
super(VerboseClientError, self).__init__(
218+
error_response, # type:ignore[arg-type] # in stubs: botocore.exceptions._ClientErrorResponseTypeDef
219+
operation_name,
220+
)

tests/integration/test_transaction_integration.py

+27-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import uuid
22
from datetime import datetime
33

4+
import botocore
45
import pytest
56

67
from pynamodb.connection import Connection
7-
from pynamodb.exceptions import DoesNotExist, TransactWriteError, TransactGetError, InvalidStateError
8+
from pynamodb.exceptions import CancellationReason
9+
from pynamodb.exceptions import DoesNotExist, TransactWriteError, InvalidStateError
810

911

1012
from pynamodb.attributes import (
@@ -160,12 +162,34 @@ def test_transact_write__error__transaction_cancelled__condition_check_failure(c
160162
with TransactWrite(connection=connection) as transaction:
161163
transaction.save(User(1), condition=(User.user_id.does_not_exist()))
162164
transaction.save(BankStatement(1), condition=(BankStatement.user_id.does_not_exist()))
163-
assert get_error_code(exc_info.value) == TRANSACTION_CANCELLED
164-
assert 'ConditionalCheckFailed' in get_error_message(exc_info.value)
165+
assert exc_info.value.cause_response_code == TRANSACTION_CANCELLED
166+
assert 'ConditionalCheckFailed' in exc_info.value.cause_response_message
167+
assert exc_info.value.cancellation_reasons == [
168+
CancellationReason(code='ConditionalCheckFailed', message='The conditional request failed'),
169+
CancellationReason(code='ConditionalCheckFailed', message='The conditional request failed'),
170+
]
171+
assert isinstance(exc_info.value.cause, botocore.exceptions.ClientError)
165172
assert User.Meta.table_name in exc_info.value.cause.MSG_TEMPLATE
166173
assert BankStatement.Meta.table_name in exc_info.value.cause.MSG_TEMPLATE
167174

168175

176+
@pytest.mark.ddblocal
177+
def test_transact_write__error__transaction_cancelled__partial_failure(connection):
178+
User(2).delete()
179+
BankStatement(2).save()
180+
181+
# attempt to do this as a transaction with the condition that they don't already exist
182+
with pytest.raises(TransactWriteError) as exc_info:
183+
with TransactWrite(connection=connection) as transaction:
184+
transaction.save(User(2), condition=(User.user_id.does_not_exist()))
185+
transaction.save(BankStatement(2), condition=(BankStatement.user_id.does_not_exist()))
186+
assert exc_info.value.cause_response_code == TRANSACTION_CANCELLED
187+
assert exc_info.value.cancellation_reasons == [
188+
None,
189+
CancellationReason(code='ConditionalCheckFailed', message='The conditional request failed'),
190+
]
191+
192+
169193
@pytest.mark.ddblocal
170194
def test_transact_write__error__multiple_operations_on_same_record(connection):
171195
BankStatement(1).save()

0 commit comments

Comments
 (0)