Repository: getsentry/responses (python)

Issue:
Flaky test in multithreading environment
### Environment

python 3.10
responses 0.19.0
requests 2.27.1
pytest 7.1.0

### Steps to Reproduce

```python
import threading
import requests
import responses
def test_example():
    n_threads = 4
    n_requests = 100
    with responses.RequestsMock() as m:
        for i in range(n_requests):
            for j in range(n_threads):
                m.add(url=f"http://example.com/example{i%5}", method="GET")
        def fun():
            for i in range(n_requests):
                requests.get(f"http://example.com/example{i%5}")
        threads = [threading.Thread(name=f"example{i}", target=fun) for i in range(n_threads)]
        for thread in threads:
            thread.start()
        for thread in threads:
            thread.join()
```

Run this test with pytest.

### Expected Result

This test should always pass.

### Actual Result

This test sometimes doesn't pass, to increase frequency add more threads or run pytest with option --cov

This looks like a race condition in matching requests, adding a lock in the method `_find_match` seems to fix the issue.

Pull request description:
Add  `threading.Lock()` 
closes #515 

based on the example in #515:
the issue why the error is raised, because `assert_all_requests_are_fired` checks for `call_count` on `Response` object.

in threading we may access `Response` in parallel, thus, `call_count > 1`. While users expects it to be `call_count == 1`.
Since we cannot ensure this, we need to Lock the thread

Patch:
diff --git a/CHANGES b/CHANGES
index fc3be1c..a7d667b 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,6 +1,7 @@
 0.21.0
 ------
 
+* Add `threading.Lock()` to allow `responses` working with `threading` module.
 * Removed internal `_cookies_from_headers` function
 
 0.20.0
diff --git a/README.rst b/README.rst
index 445ab4a..51c029e 100644
--- a/README.rst
+++ b/README.rst
@@ -517,6 +517,11 @@ you can see, that status code will depend on the invocation order.
 
 .. code-block:: python
 
+    import requests
+
+    import responses
+    from responses.registries import OrderedRegistry
+
     @responses.activate(registry=OrderedRegistry)
     def test_invocation_index():
         responses.add(
@@ -1010,6 +1015,32 @@ matched responses from the registered list.
 
 Finally, ``reset`` will reset all registered responses.
 
+Coroutines and Multithreading
+-----------------------------
+
+``responses`` supports both Coroutines and Multithreading out of the box.
+Note, ``responses`` locks threading on ``RequestMock`` object allowing only
+single thread to access it.
+
+.. code-block:: python
+
+    async def test_async_calls():
+        @responses.activate
+        async def run():
+            responses.add(
+                responses.GET,
+                "http://twitter.com/api/1/foobar",
+                json={"error": "not found"},
+                status=404,
+            )
+
+            resp = requests.get("http://twitter.com/api/1/foobar")
+            assert resp.json() == {"error": "not found"}
+            assert responses.calls[0].request.url == "http://twitter.com/api/1/foobar"
+
+        await run()
+
+
 Contributing
 ------------
 
diff --git a/responses/__init__.py b/responses/__init__.py
index e72703c..f7aef8f 100644
--- a/responses/__init__.py
+++ b/responses/__init__.py
@@ -8,6 +8,7 @@ from functools import wraps
 from http import client
 from itertools import groupby
 from re import Pattern
+from threading import Lock as _ThreadingLock
 from warnings import warn
 
 from requests.adapters import HTTPAdapter
@@ -569,6 +570,7 @@ class RequestsMock(object):
         self.passthru_prefixes = tuple(passthru_prefixes)
         self.target = target
         self._patcher = None
+        self._thread_lock = _ThreadingLock()
 
     def get_registry(self):
         return self._registry
@@ -786,7 +788,8 @@ class RequestsMock(object):
             (Response) found match. If multiple found, then remove & return the first match.
             (list) list with reasons why other matches don't match
         """
-        return self._registry.find(request)
+        with self._thread_lock:
+            return self._registry.find(request)
 
     def _parse_request_params(self, url):
         params = {}


Test patch:
diff --git a/responses/tests/test_multithreading.py b/responses/tests/test_multithreading.py
new file mode 100644
index 0000000..39f9490
--- /dev/null
+++ b/responses/tests/test_multithreading.py
@@ -0,0 +1,35 @@
+"""
+Separate file for multithreading since it takes time to run
+"""
+import threading
+
+import pytest
+import requests
+
+import responses
+
+
+@pytest.mark.parametrize("execution_number", range(10))
+def test_multithreading_lock(execution_number):
+    """Reruns test multiple times since error is random and
+    depends on CPU and can lead to false positive result.
+
+    """
+    n_threads = 10
+    n_requests = 30
+    with responses.RequestsMock() as m:
+        for j in range(n_threads):
+            for i in range(n_requests):
+                m.add(url=f"http://example.com/example{i}", method="GET")
+
+        def fun():
+            for req in range(n_requests):
+                requests.get(f"http://example.com/example{req}")
+
+        threads = [
+            threading.Thread(name=f"example{i}", target=fun) for i in range(n_threads)
+        ]
+        for thread in threads:
+            thread.start()
+        for thread in threads:
+            thread.join()