Skip to content

Commit 26efc0f

Browse files
authored
PYTHON-3388 Propagate Original Error for Write Errors Labeled NoWritesPerformed (#1117)
1 parent ee2badf commit 26efc0f

File tree

3 files changed

+157
-2
lines changed

3 files changed

+157
-2
lines changed

pymongo/mongo_client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,12 +1408,18 @@ def is_retrying():
14081408
if retryable_error:
14091409
session._unpin()
14101410
if not retryable_error or (is_retrying() and not multiple_retries):
1411-
raise
1411+
if exc.has_error_label("NoWritesPerformed") and last_error:
1412+
raise last_error from exc
1413+
else:
1414+
raise
14121415
if bulk:
14131416
bulk.retrying = True
14141417
else:
14151418
retrying = True
1416-
last_error = exc
1419+
if not exc.has_error_label("NoWritesPerformed"):
1420+
last_error = exc
1421+
if last_error is None:
1422+
last_error = exc
14171423

14181424
@_csot.apply
14191425
def _retryable_read(self, func, read_pref, session, address=None, retryable=True):
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
{
2+
"description": "retryable-writes insertOne noWritesPerformedErrors",
3+
"schemaVersion": "1.0",
4+
"runOnRequirements": [
5+
{
6+
"minServerVersion": "6.0",
7+
"topologies": [
8+
"replicaset"
9+
]
10+
}
11+
],
12+
"createEntities": [
13+
{
14+
"client": {
15+
"id": "client0",
16+
"useMultipleMongoses": false,
17+
"observeEvents": [
18+
"commandFailedEvent"
19+
]
20+
}
21+
},
22+
{
23+
"database": {
24+
"id": "database0",
25+
"client": "client0",
26+
"databaseName": "retryable-writes-tests"
27+
}
28+
},
29+
{
30+
"collection": {
31+
"id": "collection0",
32+
"database": "database0",
33+
"collectionName": "no-writes-performed-collection"
34+
}
35+
}
36+
],
37+
"tests": [
38+
{
39+
"description": "InsertOne fails after NoWritesPerformed error",
40+
"operations": [
41+
{
42+
"name": "failPoint",
43+
"object": "testRunner",
44+
"arguments": {
45+
"client": "client0",
46+
"failPoint": {
47+
"configureFailPoint": "failCommand",
48+
"mode": {
49+
"times": 2
50+
},
51+
"data": {
52+
"failCommands": [
53+
"insert"
54+
],
55+
"errorCode": 64,
56+
"errorLabels": [
57+
"NoWritesPerformed",
58+
"RetryableWriteError"
59+
]
60+
}
61+
}
62+
}
63+
},
64+
{
65+
"name": "insertOne",
66+
"object": "collection0",
67+
"arguments": {
68+
"document": {
69+
"x": 1
70+
}
71+
},
72+
"expectError": {
73+
"errorCode": 64,
74+
"errorLabelsContain": [
75+
"NoWritesPerformed",
76+
"RetryableWriteError"
77+
]
78+
}
79+
}
80+
],
81+
"outcome": [
82+
{
83+
"collectionName": "no-writes-performed-collection",
84+
"databaseName": "retryable-writes-tests",
85+
"documents": []
86+
}
87+
]
88+
}
89+
]
90+
}

test/test_retryable_writes.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from test.utils import (
2727
CMAPListener,
2828
DeprecationFilter,
29+
EventListener,
2930
OvertCommandListener,
3031
TestCreator,
3132
rs_or_single_client,
@@ -45,6 +46,7 @@
4546
)
4647
from pymongo.mongo_client import MongoClient
4748
from pymongo.monitoring import (
49+
CommandSucceededEvent,
4850
ConnectionCheckedOutEvent,
4951
ConnectionCheckOutFailedEvent,
5052
ConnectionCheckOutFailedReason,
@@ -64,6 +66,26 @@
6466
_TEST_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "retryable_writes", "legacy")
6567

6668

69+
class InsertEventListener(EventListener):
70+
def succeeded(self, event: CommandSucceededEvent) -> None:
71+
super(InsertEventListener, self).succeeded(event)
72+
if (
73+
event.command_name == "insert"
74+
and event.reply.get("writeConcernError", {}).get("code", None) == 91
75+
):
76+
client_context.client.admin.command(
77+
{
78+
"configureFailPoint": "failCommand",
79+
"mode": {"times": 1},
80+
"data": {
81+
"errorCode": 10107,
82+
"errorLabels": ["RetryableWriteError", "NoWritesPerformed"],
83+
"failCommands": ["insert"],
84+
},
85+
}
86+
)
87+
88+
6789
class TestAllScenarios(SpecRunner):
6890
RUN_ON_LOAD_BALANCER = True
6991
RUN_ON_SERVERLESS = True
@@ -581,6 +603,43 @@ def test_pool_paused_error_is_retryable(self):
581603
failed = cmd_listener.failed_events
582604
self.assertEqual(1, len(failed), msg)
583605

606+
@client_context.require_failCommand_fail_point
607+
@client_context.require_replica_set
608+
@client_context.require_version_min(
609+
6, 0, 0
610+
) # the spec requires that this prose test only be run on 6.0+
611+
@client_knobs(heartbeat_frequency=0.05, min_heartbeat_interval=0.05)
612+
def test_returns_original_error_code(
613+
self,
614+
):
615+
cmd_listener = InsertEventListener()
616+
client = rs_or_single_client(retryWrites=True, event_listeners=[cmd_listener])
617+
client.test.test.drop()
618+
self.addCleanup(client.close)
619+
cmd_listener.reset()
620+
client.admin.command(
621+
{
622+
"configureFailPoint": "failCommand",
623+
"mode": {"times": 1},
624+
"data": {
625+
"writeConcernError": {
626+
"code": 91,
627+
"errorLabels": ["RetryableWriteError"],
628+
},
629+
"failCommands": ["insert"],
630+
},
631+
}
632+
)
633+
with self.assertRaises(WriteConcernError) as exc:
634+
client.test.test.insert_one({"_id": 1})
635+
self.assertEqual(exc.exception.code, 91)
636+
client.admin.command(
637+
{
638+
"configureFailPoint": "failCommand",
639+
"mode": "off",
640+
}
641+
)
642+
584643

585644
# TODO: Make this a real integration test where we stepdown the primary.
586645
class TestRetryableWritesTxnNumber(IgnoreDeprecationsTest):

0 commit comments

Comments
 (0)