diff --git a/pymongo/mongo_client.py b/pymongo/mongo_client.py index 7d16e58777..dccd4bb6b1 100644 --- a/pymongo/mongo_client.py +++ b/pymongo/mongo_client.py @@ -1408,12 +1408,18 @@ def is_retrying(): if retryable_error: session._unpin() if not retryable_error or (is_retrying() and not multiple_retries): - raise + if exc.has_error_label("NoWritesPerformed") and last_error: + raise last_error from exc + else: + raise if bulk: bulk.retrying = True else: retrying = True - last_error = exc + if not exc.has_error_label("NoWritesPerformed"): + last_error = exc + if last_error is None: + last_error = exc @_csot.apply def _retryable_read(self, func, read_pref, session, address=None, retryable=True): diff --git a/test/retryable_writes/unified/handshakeError.json b/test/retryable_writes/unified/handshakeError.json index e07e5412b2..df37bd7232 100644 --- a/test/retryable_writes/unified/handshakeError.json +++ b/test/retryable_writes/unified/handshakeError.json @@ -54,7 +54,7 @@ ], "tests": [ { - "description": "insertOne succeeds after retryable handshake network error", + "description": "collection.insertOne succeeds after retryable handshake network error", "operations": [ { "name": "failPoint", @@ -150,7 +150,7 @@ ] }, { - "description": "insertOne succeeds after retryable handshake server error (ShutdownInProgress)", + "description": "collection.insertOne succeeds after retryable handshake server error (ShutdownInProgress)", "operations": [ { "name": "failPoint", @@ -246,7 +246,7 @@ ] }, { - "description": "insertMany succeeds after retryable handshake network error", + "description": "collection.insertMany succeeds after retryable handshake network error", "operations": [ { "name": "failPoint", @@ -344,7 +344,7 @@ ] }, { - "description": "insertMany succeeds after retryable handshake server error (ShutdownInProgress)", + "description": "collection.insertMany succeeds after retryable handshake server error (ShutdownInProgress)", "operations": [ { "name": "failPoint", @@ -442,7 +442,7 @@ ] }, { - "description": "deleteOne succeeds after retryable handshake network error", + "description": "collection.deleteOne succeeds after retryable handshake network error", "operations": [ { "name": "failPoint", @@ -535,7 +535,7 @@ ] }, { - "description": "deleteOne succeeds after retryable handshake server error (ShutdownInProgress)", + "description": "collection.deleteOne succeeds after retryable handshake server error (ShutdownInProgress)", "operations": [ { "name": "failPoint", @@ -628,7 +628,7 @@ ] }, { - "description": "replaceOne succeeds after retryable handshake network error", + "description": "collection.replaceOne succeeds after retryable handshake network error", "operations": [ { "name": "failPoint", @@ -724,7 +724,7 @@ ] }, { - "description": "replaceOne succeeds after retryable handshake server error (ShutdownInProgress)", + "description": "collection.replaceOne succeeds after retryable handshake server error (ShutdownInProgress)", "operations": [ { "name": "failPoint", @@ -820,7 +820,7 @@ ] }, { - "description": "updateOne succeeds after retryable handshake network error", + "description": "collection.updateOne succeeds after retryable handshake network error", "operations": [ { "name": "failPoint", @@ -918,7 +918,7 @@ ] }, { - "description": "updateOne succeeds after retryable handshake server error (ShutdownInProgress)", + "description": "collection.updateOne succeeds after retryable handshake server error (ShutdownInProgress)", "operations": [ { "name": "failPoint", @@ -1016,7 +1016,7 @@ ] }, { - "description": "findOneAndDelete succeeds after retryable handshake network error", + "description": "collection.findOneAndDelete succeeds after retryable handshake network error", "operations": [ { "name": "failPoint", @@ -1109,7 +1109,7 @@ ] }, { - "description": "findOneAndDelete succeeds after retryable handshake server error (ShutdownInProgress)", + "description": "collection.findOneAndDelete succeeds after retryable handshake server error (ShutdownInProgress)", "operations": [ { "name": "failPoint", @@ -1202,7 +1202,7 @@ ] }, { - "description": "findOneAndReplace succeeds after retryable handshake network error", + "description": "collection.findOneAndReplace succeeds after retryable handshake network error", "operations": [ { "name": "failPoint", @@ -1298,7 +1298,7 @@ ] }, { - "description": "findOneAndReplace succeeds after retryable handshake server error (ShutdownInProgress)", + "description": "collection.findOneAndReplace succeeds after retryable handshake server error (ShutdownInProgress)", "operations": [ { "name": "failPoint", @@ -1394,7 +1394,7 @@ ] }, { - "description": "findOneAndUpdate succeeds after retryable handshake network error", + "description": "collection.findOneAndUpdate succeeds after retryable handshake network error", "operations": [ { "name": "failPoint", @@ -1492,7 +1492,7 @@ ] }, { - "description": "findOneAndUpdate succeeds after retryable handshake server error (ShutdownInProgress)", + "description": "collection.findOneAndUpdate succeeds after retryable handshake server error (ShutdownInProgress)", "operations": [ { "name": "failPoint", @@ -1590,7 +1590,7 @@ ] }, { - "description": "bulkWrite succeeds after retryable handshake network error", + "description": "collection.bulkWrite succeeds after retryable handshake network error", "operations": [ { "name": "failPoint", @@ -1692,7 +1692,7 @@ ] }, { - "description": "bulkWrite succeeds after retryable handshake server error (ShutdownInProgress)", + "description": "collection.bulkWrite succeeds after retryable handshake server error (ShutdownInProgress)", "operations": [ { "name": "failPoint", diff --git a/test/retryable_writes/unified/insertOne-noWritesPerformedError.json b/test/retryable_writes/unified/insertOne-noWritesPerformedError.json new file mode 100644 index 0000000000..3194e91c5c --- /dev/null +++ b/test/retryable_writes/unified/insertOne-noWritesPerformedError.json @@ -0,0 +1,90 @@ +{ + "description": "retryable-writes insertOne noWritesPerformedErrors", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "6.0", + "topologies": [ + "replicaset" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandFailedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "retryable-writes-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "no-writes-performed-collection" + } + } + ], + "tests": [ + { + "description": "InsertOne fails after NoWritesPerformed error", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 2 + }, + "data": { + "failCommands": [ + "insert" + ], + "errorCode": 64, + "errorLabels": [ + "NoWritesPerformed", + "RetryableWriteError" + ] + } + } + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "x": 1 + } + }, + "expectError": { + "errorCode": 64, + "errorLabelsContain": [ + "NoWritesPerformed", + "RetryableWriteError" + ] + } + } + ], + "outcome": [ + { + "collectionName": "no-writes-performed-collection", + "databaseName": "retryable-writes-tests", + "documents": [] + } + ] + } + ] +} diff --git a/test/test_retryable_writes.py b/test/test_retryable_writes.py index 7ca1c9c1ef..a22c776534 100644 --- a/test/test_retryable_writes.py +++ b/test/test_retryable_writes.py @@ -26,6 +26,7 @@ from test.utils import ( CMAPListener, DeprecationFilter, + EventListener, OvertCommandListener, TestCreator, rs_or_single_client, @@ -45,6 +46,7 @@ ) from pymongo.mongo_client import MongoClient from pymongo.monitoring import ( + CommandSucceededEvent, ConnectionCheckedOutEvent, ConnectionCheckOutFailedEvent, ConnectionCheckOutFailedReason, @@ -64,6 +66,26 @@ _TEST_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "retryable_writes", "legacy") +class InsertEventListener(EventListener): + def succeeded(self, event: CommandSucceededEvent) -> None: + super(InsertEventListener, self).succeeded(event) + if ( + event.command_name == "insert" + and event.reply.get("writeConcernError", {}).get("code", None) == 91 + ): + client_context.client.admin.command( + { + "configureFailPoint": "failCommand", + "mode": {"times": 1}, + "data": { + "errorCode": 10107, + "errorLabels": ["RetryableWriteError", "NoWritesPerformed"], + "failCommands": ["insert"], + }, + } + ) + + class TestAllScenarios(SpecRunner): RUN_ON_LOAD_BALANCER = True RUN_ON_SERVERLESS = True @@ -581,6 +603,43 @@ def test_pool_paused_error_is_retryable(self): failed = cmd_listener.failed_events self.assertEqual(1, len(failed), msg) + @client_context.require_failCommand_fail_point + @client_context.require_replica_set + @client_context.require_version_min( + 6, 0, 0 + ) # the spec requires that this prose test only be run on 6.0+ + @client_knobs(heartbeat_frequency=0.05, min_heartbeat_interval=0.05) + def test_returns_original_error_code( + self, + ): + cmd_listener = InsertEventListener() + client = rs_or_single_client(retryWrites=True, event_listeners=[cmd_listener]) + client.test.test.drop() + self.addCleanup(client.close) + cmd_listener.reset() + client.admin.command( + { + "configureFailPoint": "failCommand", + "mode": {"times": 1}, + "data": { + "writeConcernError": { + "code": 91, + "errorLabels": ["RetryableWriteError"], + }, + "failCommands": ["insert"], + }, + } + ) + with self.assertRaises(WriteConcernError) as exc: + client.test.test.insert_one({"_id": 1}) + self.assertEqual(exc.exception.code, 91) + client.admin.command( + { + "configureFailPoint": "failCommand", + "mode": "off", + } + ) + # TODO: Make this a real integration test where we stepdown the primary. class TestRetryableWritesTxnNumber(IgnoreDeprecationsTest):