Skip to content

Commit b03838c

Browse files
authored
Expose transaction cancellation reasons (pynamodb#1144)
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 9618fe2 commit b03838c

File tree

5 files changed

+114
-6
lines changed

5 files changed

+114
-6
lines changed

docs/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,4 @@ Exceptions
4444
.. autoexception:: pynamodb.exceptions.InvalidStateError
4545
.. autoexception:: pynamodb.exceptions.AttributeDeserializationError
4646
.. autoexception:: pynamodb.exceptions.AttributeNullError
47+
.. autoclass:: pynamodb.exceptions.CancellationReason

docs/transaction.rst

Lines changed: 4 additions & 0 deletions
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/connection/base.py

Lines changed: 16 additions & 2 deletions
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

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
"""
22
PynamoDB exceptions
33
"""
4-
5-
from typing import Any, Optional
4+
from dataclasses import dataclass
5+
from typing import Any
6+
from typing import Dict
7+
from typing import Iterable
8+
from typing import List
9+
from typing import Optional
10+
from typing_extensions import Literal
611

712
import botocore.exceptions
813

@@ -112,16 +117,63 @@ def __init__(self, table_name: str) -> None:
112117
super(TableDoesNotExist, self).__init__(msg)
113118

114119

120+
@dataclass
121+
class CancellationReason:
122+
"""
123+
A reason for a transaction cancellation.
124+
"""
125+
code: Literal[
126+
'ConditionalCheckFailed',
127+
'ItemCollectionSizeLimitExceeded',
128+
'TransactionConflict',
129+
'ProvisionedThroughputExceeded',
130+
'ThrottlingError',
131+
'ValidationError',
132+
]
133+
message: Optional[str] = None
134+
135+
115136
class TransactWriteError(PynamoDBException):
116137
"""
117138
Raised when a :class:`~pynamodb.transactions.TransactWrite` operation fails.
118139
"""
119140

141+
@property
142+
def cancellation_reasons(self) -> List[Optional[CancellationReason]]:
143+
"""
144+
When :attr:`.cause_response_code` is ``TransactionCanceledException``, this property lists
145+
cancellation reasons in the same order as the transaction items (one-to-one).
146+
Items which were not part of the reason for cancellation would have :code:`None` as the value.
147+
148+
For a list of possible cancellation reasons and their semantics,
149+
see `TransactWriteItems`_ in the AWS documentation.
150+
151+
.. _TransactWriteItems: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html
152+
"""
153+
if not isinstance(self.cause, VerboseClientError):
154+
return []
155+
return self.cause.cancellation_reasons
156+
120157

121158
class TransactGetError(PynamoDBException):
122159
"""
123160
Raised when a :class:`~pynamodb.transactions.TransactGet` operation fails.
124161
"""
162+
@property
163+
def cancellation_reasons(self) -> List[Optional[CancellationReason]]:
164+
"""
165+
When :attr:`.cause_response_code` is ``TransactionCanceledException``, this property lists
166+
cancellation reasons in the same order as the transaction items (one-to-one).
167+
Items which were not part of the reason for cancellation would have :code:`None` as the value.
168+
169+
For a list of possible cancellation reasons and their semantics,
170+
see `TransactGetItems`_ in the AWS documentation.
171+
172+
.. _TransactGetItems: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactGetItems.html
173+
"""
174+
if not isinstance(self.cause, VerboseClientError):
175+
return []
176+
return self.cause.cancellation_reasons
125177

126178

127179
class InvalidStateError(PynamoDBException):
@@ -156,13 +208,23 @@ def prepend_path(self, attr_name: str) -> None:
156208

157209

158210
class VerboseClientError(botocore.exceptions.ClientError):
159-
def __init__(self, error_response: Any, operation_name: str, verbose_properties: Optional[Any] = None) -> None:
211+
def __init__(
212+
self,
213+
error_response: Dict[str, Any],
214+
operation_name: str,
215+
verbose_properties: Optional[Any] = None,
216+
*,
217+
cancellation_reasons: Iterable[Optional[CancellationReason]] = (),
218+
) -> None:
160219
"""
161220
Like ClientError, but with a verbose message.
162221
163222
:param error_response: Error response in shape expected by ClientError.
164223
:param operation_name: The name of the operation that failed.
165224
:param verbose_properties: A dict of properties to include in the verbose message.
225+
:param cancellation_reasons: For `TransactionCanceledException` error code,
226+
a list of cancellation reasons in the same order as the transaction's items (one to one).
227+
For items which were not a reason for the transaction cancellation, :code:`None` will be the value.
166228
"""
167229
if not verbose_properties:
168230
verbose_properties = {}
@@ -173,4 +235,9 @@ def __init__(self, error_response: Any, operation_name: str, verbose_properties:
173235
'operation: {{error_message}}'
174236
).format(request_id=verbose_properties.get('request_id'), table_name=verbose_properties.get('table_name'))
175237

176-
super(VerboseClientError, self).__init__(error_response, operation_name)
238+
self.cancellation_reasons = list(cancellation_reasons)
239+
240+
super(VerboseClientError, self).__init__(
241+
error_response, # type:ignore[arg-type] # in stubs: botocore.exceptions._ClientErrorResponseTypeDef
242+
operation_name,
243+
)

tests/integration/test_transaction_integration.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66

77
from pynamodb.connection import Connection
8+
from pynamodb.exceptions import CancellationReason
89
from pynamodb.exceptions import DoesNotExist, TransactWriteError, InvalidStateError
910

1011

@@ -158,11 +159,32 @@ def test_transact_write__error__transaction_cancelled__condition_check_failure(c
158159
transaction.save(BankStatement(1), condition=(BankStatement.user_id.does_not_exist()))
159160
assert exc_info.value.cause_response_code == TRANSACTION_CANCELLED
160161
assert 'ConditionalCheckFailed' in exc_info.value.cause_response_message
162+
assert exc_info.value.cancellation_reasons == [
163+
CancellationReason(code='ConditionalCheckFailed', message='The conditional request failed'),
164+
CancellationReason(code='ConditionalCheckFailed', message='The conditional request failed'),
165+
]
161166
assert isinstance(exc_info.value.cause, botocore.exceptions.ClientError)
162167
assert User.Meta.table_name in exc_info.value.cause.MSG_TEMPLATE
163168
assert BankStatement.Meta.table_name in exc_info.value.cause.MSG_TEMPLATE
164169

165170

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

0 commit comments

Comments
 (0)