Repository: lundberg/respx

Issue:
Pass-through requests
It would sometimes be useful to mark a pattern to pass-through the request to the original url.

**Ideas:**
```py
with respx.mock():
    respx.get(...., pass_through=True)
    # -- and/or --
    respx.get(...., pass_through=pass_through_or_not)

def pass_through_or_not(request, **kwargs):
    if some_awesome_logic:
        return True
```

Relates to #16 

Discussion:

Patch:
diff --git a/respx/mock.py b/respx/mock.py
index 39d4176..c3f45e5 100644
--- a/respx/mock.py
+++ b/respx/mock.py
@@ -108,6 +108,7 @@ class HTTPXMock:
         content: typing.Optional[ContentDataTypes] = None,
         content_type: typing.Optional[str] = None,
         headers: typing.Optional[HeaderTypes] = None,
+        pass_through: bool = False,
         alias: typing.Optional[str] = None,
     ) -> RequestPattern:
         """
@@ -118,7 +119,9 @@ class HTTPXMock:
             headers["Content-Type"] = content_type
 
         response = ResponseTemplate(status_code, headers, content)
-        pattern = RequestPattern(method, url, response, alias=alias)
+        pattern = RequestPattern(
+            method, url, response, pass_through=pass_through, alias=alias
+        )
 
         self.add(pattern, alias=alias)
 
@@ -145,8 +148,8 @@ class HTTPXMock:
         matched_response: typing.Optional[ResponseTemplate] = None
 
         for i, pattern in enumerate(self._patterns):
-            response = pattern.match(request)
-            if not response:
+            match = pattern.match(request)
+            if not match:
                 continue
 
             if found_index is not None:
@@ -156,38 +159,60 @@ class HTTPXMock:
 
             found_index = i
             matched_pattern = pattern
-            matched_response = response
+
+            if isinstance(match, ResponseTemplate):
+                # Mock response
+                matched_response = match
+            elif isinstance(match, AsyncRequest):
+                # Pass-through request
+                matched_response = None
+            else:
+                raise ValueError(
+                    (
+                        "Matched request pattern must return either a "
+                        'ResponseTemplate or an AsyncResponse, got "{}"'
+                    ).format(type(match))
+                )
 
         return matched_pattern, matched_response
 
     @contextmanager
-    def _patch_backend(self, backend: ConcurrencyBackend) -> typing.Iterator[None]:
+    def _patch_backend(
+        self,
+        backend: ConcurrencyBackend,
+        request: AsyncRequest,
+        response: typing.Optional[ResponseTemplate],
+    ) -> typing.Iterator[None]:
         patchers = []
 
-        # Mock open_tcp_stream()
-        patchers.append(
-            asynctest.mock.patch.object(
-                backend, "open_tcp_stream", self._open_tcp_stream_mock
-            )
-        )
+        if response is not None:
+            # 1. Patch request url with response for later pickup in patched backend
+            request.url = URLResponse(request.url, response)
 
-        # Mock open_uds_stream()
-        # TODO: Remove if-statement once httpx uds support is released
-        if hasattr(backend, "open_uds_stream"):  # pragma: nocover
+            # Mock open_tcp_stream()
             patchers.append(
                 asynctest.mock.patch.object(
-                    backend, "open_uds_stream", self._open_uds_stream_mock
+                    backend, "open_tcp_stream", self._open_tcp_stream_mock
                 )
             )
 
-        # Start patchers
+            # Mock open_uds_stream()
+            # TODO: Remove if-statement once httpx uds support is released
+            if hasattr(backend, "open_uds_stream"):  # pragma: nocover
+                patchers.append(
+                    asynctest.mock.patch.object(
+                        backend, "open_uds_stream", self._open_uds_stream_mock
+                    )
+                )
+
+        # 2. Start patchers
         for patcher in patchers:
             patcher.start()
 
         try:
             yield
         finally:
-            # Stop patchers
+            # 3. Stop patchers
             for patcher in patchers:
                 patcher.start()
 
@@ -203,17 +228,17 @@ class HTTPXMock:
         # 1. Match request against added patterns
         pattern, template = self._match(request)
 
-        # 2. Patch request url with response for later pickup in mocked backend methods
-        request.url = URLResponse(request.url, template or ResponseTemplate())
+        if pattern is None:
+            template = ResponseTemplate()
 
-        # 3. Patch client's backend and pass-through to _get_response
+        # 2. Patch client's backend and continue to original _get_response
         try:
             global _get_response
-            with self._patch_backend(client.concurrency_backend):
+            with self._patch_backend(client.concurrency_backend, request, template):
                 response = None
                 response = await _get_response(client, request, **kwargs)
         finally:
-            # 4. Update stats
+            # 3. Update stats
             if pattern:
                 pattern(request, response)
             self.calls.append((request, response))
diff --git a/respx/models.py b/respx/models.py
index dc32211..5131247 100644
--- a/respx/models.py
+++ b/respx/models.py
@@ -74,6 +74,7 @@ class RequestPattern:
         method: typing.Union[str, typing.Callable],
         url: typing.Optional[typing.Union[str, typing.Pattern]],
         response: ResponseTemplate,
+        pass_through: bool = False,
         alias: typing.Optional[str] = None,
     ) -> None:
         self._match_func: typing.Optional[typing.Callable] = None
@@ -81,10 +82,12 @@ class RequestPattern:
         if callable(method):
             self.method = None
             self.url = None
+            self.pass_through = None
             self._match_func = method
         else:
             self.method = method
             self.url = url
+            self.pass_through = pass_through
 
         self.response = response
         self.alias = alias
@@ -106,10 +109,22 @@ class RequestPattern:
     ) -> None:
         self._stats(request, response)
 
-    def match(self, request: AsyncRequest) -> typing.Optional[ResponseTemplate]:
+    def match(
+        self, request: AsyncRequest
+    ) -> typing.Optional[typing.Union[AsyncRequest, ResponseTemplate]]:
+        """
+        Matches request with configured pattern;
+        custom matcher function or http method + url pattern.
+
+        Returns None for a non-matching pattern, mocked response for a match,
+        or input request for pass-through.
+        """
         matches = False
         url_params: Kwargs = {}
 
+        if self.pass_through:
+            return request
+
         if self._match_func:
             response = self.response.clone(context={"request": request})
             return self._match_func(request, response)


Test patch:
diff --git a/tests/test_mock.py b/tests/test_mock.py
index 6870958..9624c47 100644
--- a/tests/test_mock.py
+++ b/tests/test_mock.py
@@ -309,8 +309,8 @@ class HTTPXMockTestCase(asynctest.TestCase):
                 response.context["id"] = 123
                 return response
 
-        with respx.HTTPXMock() as httpx_mock:
-            httpx_mock.request(matcher, status_code=202, headers={"X-Ham": "Spam"})
+        with respx.HTTPXMock(assert_all_called=False) as httpx_mock:
+            request = httpx_mock.request(matcher, status_code=202, headers={"X-Ham": "Spam"})
             response = httpx.get("https://foo/bar/")
 
             self.assertEqual(response.status_code, 202)
@@ -319,6 +319,12 @@ class HTTPXMockTestCase(asynctest.TestCase):
                 httpx.Headers({"Content-Type": "text/plain", "X-Ham": "Spam"}),
             )
             self.assertEqual(response.text, "foobar #123")
+            self.assertTrue(request.called)
+            self.assertFalse(request.pass_through)
+
+            with self.assertRaises(ValueError):
+                httpx_mock.request(lambda req, res: "invalid")
+                httpx.get("https://ham/spam/")
 
     def test_assert_all_called_fail(self):
         with self.assertRaises(AssertionError):
@@ -355,6 +361,36 @@ class HTTPXMockTestCase(asynctest.TestCase):
             self.assertTrue(request1.called)
             self.assertTrue(request2.called)
 
+    def test_pass_through_with_arg(self):
+        with respx.mock():
+            request = respx.get("https://www.example.org/", pass_through=True)
+
+            with asynctest.mock.patch(
+                "asyncio.open_connection",
+                side_effect=ConnectionRefusedError("test request blocked"),
+            ) as open_connection:
+                with self.assertRaises(ConnectionRefusedError):
+                    httpx.get("https://www.example.org/")
+
+            self.assertTrue(open_connection.called)
+            self.assertTrue(request.called)
+            self.assertTrue(request.pass_through)
+
+    def test_pass_through_with_custom_matcher(self):
+        with respx.mock():
+            request = respx.request(lambda request, response: request)
+
+            with asynctest.mock.patch(
+                "asyncio.open_connection",
+                side_effect=ConnectionRefusedError("test request blocked"),
+            ) as open_connection:
+                with self.assertRaises(ConnectionRefusedError):
+                    httpx.get("https://www.example.org/")
+
+            self.assertTrue(open_connection.called)
+            self.assertTrue(request.called)
+            self.assertIsNone(request.pass_through)
+
     async def test_stats(self, backend=None):
         with respx.mock():
             url = "https://foo/bar/1/"