Repository: zulip/zulip

Issue:
Race conditions in muting topics and users
Our pattern in [muting topics](https://github.com/zulip/zulip/blob/b4075b78eb6e128bce7ef3d36b86d176ef2ecfa5/zerver/views/muting.py#L39-L42) is to check if the topic is muted, and if not then to add a row:
```py3
    if topic_is_muted(user_profile, stream.id, topic_name):
        raise JsonableError(_("Topic already muted"))

    do_mute_topic(user_profile, stream, topic_name, date_muted)
    return json_success()
```

This pattern is inherently prone to race conditions.  Luckily, we catch those due to database constraints, in the form of `UserTopic.objects.create` raising an IntegrityError, but those bubble up as 500's, not 400's.

We should catch those IntegrityError's and re-raise them as `JsonableError(_("Topic already muted"))`.  That applies to the mute-topic codepath, as well as the mute-user codepath.

Though it doesn't affect correctness in this case, since the duplicate row is the first database change operation, these actions should be done inside of transactions.

Un-muting is technically also subject to this race, though it doesn't matter -- both processes calling `.delete()` on the same object is a mostly-silent no-op for the second process.


Discussion:
@zulipbot claim
Hello @jai2201, it looks like you've currently claimed 1 issue in this repository. We encourage new contributors to focus their efforts on at most 1 issue at a time, so please complete your work on your other claimed issues before trying to claim this issue again.

We look forward to your valuable contributions!

@jai2201 are you working on this? If not I want to work on this.

@madrix01 , I tried fixing this, but not able to produce the race condition in tests with mocking.
i would be greatful if you can solve this and i'll learn to produce race condition with mocking from your PR.
@alexmv  Can we test by sending multiple api request using a script? 
@madrix01: Not unless you mock something like `topic_is_muted` to always return `False`, so that the test is reliable.  See discussion in https://chat.zulip.org/#narrow/stream/49-development-help/topic/Race.20condition.20with.20mute.20users.20and.20topic.20.2321011
I changed the `topic_is_muted` to return `False`, and making api request always returns
```
{'result': 'error', 'msg': 'Internal server error'}
```
Error in console
```
django.db.utils.IntegrityError: duplicate key value violates unique constraint "zerver_mutedtopic_user_profile_id_stream_i_2cb30f72_uniq"
```
@jai2201 fyi
Correct, that's the symptom that you'll need to make the code catch and return as `JsonableError(_("Topic already muted"))`.
@madrix01 , I too made that change to make ```topic_is_muted``` to always return false, and then added the catch for returning Json error, but it didn't pass the test, maybe i might be doing some other mistake at somewhere else, will have a look at your PR, once it's up.
@alexmv I have implemented a `try~except` block for catching the error returns the desired results. Is the implementation correct? 
@jai2201 can which test failed for you?
@madrix01: That's fine (and presumably comparable to what @jai2201 wrote) but it needs to come with a test, which is what the discussion on chat.zulip.org is about.  If you don't write a new test, you'll find that it fails tests because it doesn't have complete test coverage.

Resolving patch:
diff --git a/zerver/views/muting.py b/zerver/views/muting.py
--- a/zerver/views/muting.py
+++ b/zerver/views/muting.py
@@ -1,6 +1,7 @@
 import datetime
 from typing import Optional
 
+from django.db import IntegrityError
 from django.http import HttpRequest, HttpResponse
 from django.utils.timezone import now as timezone_now
 from django.utils.translation import gettext as _
@@ -39,7 +40,10 @@ def mute_topic(
     if topic_is_muted(user_profile, stream.id, topic_name):
         raise JsonableError(_("Topic already muted"))
 
-    do_mute_topic(user_profile, stream, topic_name, date_muted)
+    try:
+        do_mute_topic(user_profile, stream, topic_name, date_muted)
+    except IntegrityError:
+        raise JsonableError(_("Topic already muted"))
 
 
 def unmute_topic(


Test patch:
diff --git a/zerver/tests/test_muting_topics.py b/zerver/tests/test_muting_topics.py
--- a/zerver/tests/test_muting_topics.py
+++ b/zerver/tests/test_muting_topics.py
@@ -105,7 +105,6 @@ def test_add_muted_topic(self) -> None:
                 self.assert_json_success(result)
 
             self.assertIn((stream.name, "Verona3", mock_date_muted), get_topic_mutes(user))
-            self.assertTrue(topic_is_muted(user, stream.id, "Verona3"))
             self.assertTrue(topic_is_muted(user, stream.id, "verona3"))
 
             remove_topic_mute(
@@ -114,6 +113,23 @@ def test_add_muted_topic(self) -> None:
                 topic_name="Verona3",
             )
 
+        # Verify the error handling for the database level
+        # IntegrityError we'll get with a race between two processes
+        # trying to mute the topic.  To do this, we patch the
+        # topic_is_muted function to always return False when trying
+        # to mute a topic that is already muted.
+        add_topic_mute(
+            user_profile=user,
+            stream_id=stream.id,
+            recipient_id=stream.recipient.id,
+            topic_name="Verona3",
+            date_muted=datetime(2020, 1, 1, tzinfo=timezone.utc),
+        )
+
+        with mock.patch("zerver.views.muting.topic_is_muted", return_value=False):
+            result = self.api_patch(user, url, data)
+            self.assert_json_error(result, "Topic already muted")
+
     def test_remove_muted_topic(self) -> None:
         user = self.example_user("hamlet")
         realm = user.realm