Repository: abhinavsingh/proxy.py
Pull request #425 (2020-08-18 13:43:48)

[CacheResponsesPlugin] Serving out of cache
Here is a basic implementation of a cache plugin which supports serving responses out of cache. This should be the base of the solution to #319. Tests are attached for caching and serving HTTP and HTTPS requests.

The implementation is done as follows. A list file (`list.txt`) has been added which list all completed requests (method, host, path and SHA512 hash of body). When a request arrives, the plugin searches the list for this request. If it is not stored, create the entry and fetch response from server, otherwise queue the cached response to the client (and do not connect to the server).

It does not support interception of SSL handshake, which means it needs to connect to the server even though the request is cached. I think this requires a modification of the `proxy.http.proxy.server` class, so that it can perform an SSL handshake with the client without performing one with an upstream server.


abhinavsingh: Thanks for the PR.  Working on a release this week at my end.  I'll review the PR later this week or over the weekend.  Cheers!!!
pasccom: OK, I am working on a mode where the plugin does not contact upstream (even when intercepting TLS or if request is not cached). This will be very useful for automatic tests using proxy.py. I am mostly done. I only need to write tests for response when request is not cached. I will send another PR for this feature.
 
codecov[bot]: # [Codecov](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425?src=pr&el=h1) Report
> Merging [#425](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425?src=pr&el=desc) into [develop](https://codecov.io/gh/abhinavsingh/proxy.py/commit/98a2e5f8f5379f7148d12566669d8224f13eb306?el=desc) will **increase** coverage by `0.21%`.
> The diff coverage is `83.48%`.

[![Impacted file tree graph](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425/graphs/tree.svg?width=650&height=150&src=pr&token=Zh9J7b4la2)](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425?src=pr&el=tree)

```diff
@@             Coverage Diff             @@
##           develop     #425      +/-   ##
===========================================
+ Coverage    81.08%   81.29%   +0.21%     
===========================================
  Files           79       79              
  Lines         2955     3047      +92     
===========================================
+ Hits          2396     2477      +81     
- Misses         559      570      +11     
```


| [Impacted Files](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [proxy/plugin/cache/cache\_responses.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425/diff?src=pr&el=tree#diff-cHJveHkvcGx1Z2luL2NhY2hlL2NhY2hlX3Jlc3BvbnNlcy5weQ==) | `100.00% <ø> (ø)` | |
| [proxy/plugin/cache/store/base.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425/diff?src=pr&el=tree#diff-cHJveHkvcGx1Z2luL2NhY2hlL3N0b3JlL2Jhc2UucHk=) | `77.27% <75.00%> (-2.73%)` | :arrow_down: |
| [proxy/plugin/cache/base.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425/diff?src=pr&el=tree#diff-cHJveHkvcGx1Z2luL2NhY2hlL2Jhc2UucHk=) | `84.81% <80.00%> (-8.30%)` | :arrow_down: |
| [proxy/plugin/cache/store/disk.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425/diff?src=pr&el=tree#diff-cHJveHkvcGx1Z2luL2NhY2hlL3N0b3JlL2Rpc2sucHk=) | `91.30% <86.95%> (-8.70%)` | :arrow_down: |
| [proxy/testing/test\_case.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425/diff?src=pr&el=tree#diff-cHJveHkvdGVzdGluZy90ZXN0X2Nhc2UucHk=) | `100.00% <100.00%> (ø)` | |
| [proxy/http/proxy/server.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425/diff?src=pr&el=tree#diff-cHJveHkvaHR0cC9wcm94eS9zZXJ2ZXIucHk=) | `78.00% <0.00%> (+1.03%)` | :arrow_up: |
| [proxy/http/parser.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425/diff?src=pr&el=tree#diff-cHJveHkvaHR0cC9wYXJzZXIucHk=) | `96.25% <0.00%> (+1.87%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425?src=pr&el=footer). Last update [98a2e5f...b607040](https://codecov.io/gh/abhinavsingh/proxy.py/pull/425?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

abhinavsingh: @pasccom apologies for no review this week, unfortunately I am still working towards the release at my end.  It's unlikely I will be able to review it this week.  But will surely jump onto it over the weekend sometime.  Thank you!!!
abhinavsingh: Will go ahead and merge this under `cache-server` branch.  Want to iterate over your work this weekend.  Thank you again!!!

Patch:
diff --git a/proxy/plugin/cache/base.py b/proxy/plugin/cache/base.py
index 81a2ef6..0605d4e 100644
--- a/proxy/plugin/cache/base.py
+++ b/proxy/plugin/cache/base.py
@@ -9,11 +9,16 @@
     :license: BSD, see LICENSE for more details.
 """
 import logging
+import multiprocessing
 from typing import Optional, Any
 
-from ...http.parser import HttpParser
-from ...http.proxy import HttpProxyBasePlugin
 from .store.base import CacheStore
+from ...http.parser import HttpParser, httpParserTypes
+from ...http.proxy import HttpProxyBasePlugin
+from ...http.codes import httpStatusCodes
+from ...common.constants import PROXY_AGENT_HEADER_VALUE
+from ...common.utils import text_
+from ...common.utils import build_http_response
 
 logger = logging.getLogger(__name__)
 
@@ -27,34 +32,109 @@ class BaseCacheResponsesPlugin(HttpProxyBasePlugin):
     Different storage backends can be used per request if required.
     """
 
+    class EnabledDescriptor:
+        def __init__(self, enabled: bool = False) -> None:
+            self.enabled = multiprocessing.Event()
+            if enabled:
+                self.enabled.set()
+
+        def __get__(self, obj: Optional[object], owner: type) -> Any:
+            if obj is None:
+                return self.enabled
+            return None
+
+    # Dynamically enable / disable cache
+    enabled = EnabledDescriptor(True)
+
     def __init__(
             self,
             *args: Any,
             **kwargs: Any) -> None:
         super().__init__(*args, **kwargs)
         self.store: Optional[CacheStore] = None
+        self.__enabled: Optional[bool] = None
 
     def set_store(self, store: CacheStore) -> None:
         self.store = store
 
     def before_upstream_connection(
             self, request: HttpParser) -> Optional[HttpParser]:
+        self.__enabled = self.__class__.enabled.is_set()
+        if not self.__enabled:
+            return request
+
         assert self.store
+        logger.info("Upstream connexion %s:%d %s" %
+                    (text_(request.host), request.port if request.port else 0, text_(request.path)))
+
+        if request.port == 443:
+            return request
+
         try:
-            self.store.open(request)
+            if self.store.is_cached(request):
+                return None
         except Exception as e:
-            logger.info('Caching disabled due to exception message %s', str(e))
+            logger.info('Caching disabled due to exception message: %s', str(e))
+
         return request
 
     def handle_client_request(
             self, request: HttpParser) -> Optional[HttpParser]:
+        assert self.__enabled is not None
+        if not self.__enabled:
+            return request
+
         assert self.store
-        return self.store.cache_request(request)
+        logger.info("Client request %s:%d %s" %
+                    (text_(request.host), request.port if request.port else 0, text_(request.path)))
+
+        if request.port == 443:
+            return request
+
+        try:
+            msg = self.store.cache_request(request)
+            if (msg.type == httpParserTypes.REQUEST_PARSER):
+                return msg
+            elif (msg.type == httpParserTypes.RESPONSE_PARSER):
+                self.client.queue(memoryview(build_http_response(
+                    int(msg.code) if msg.code is not None else 0,
+                    reason=msg.reason,
+                    headers={k: v for k, v in msg.headers.values()},
+                    body=msg.body
+                )))
+                return None
+            else:
+                raise ValueError('Bad HTTPParser type: %s' % msg.type)
+        except Exception as e:
+            logger.info('Caching disabled due to exception message: %s', str(e))
+
+        try:
+            if self.store.is_cached(request):
+                self.client.queue(memoryview(build_http_response(
+                    httpStatusCodes.INTERNAL_SERVER_ERROR,
+                    reason=b'Internal server error',
+                    headers={
+                        b'Server': PROXY_AGENT_HEADER_VALUE,
+                        b'Connection': b'close',
+                    }
+                )))
+        except Exception as e:
+            logger.info('Caching disabled due to exception message: %s', str(e))
+
+        return request
 
     def handle_upstream_chunk(self, chunk: memoryview) -> memoryview:
+        assert self.__enabled is not None
+        if not self.__enabled:
+            return chunk
+
         assert self.store
         return self.store.cache_response_chunk(chunk)
 
     def on_upstream_connection_close(self) -> None:
+        assert self.__enabled is not None
+        if not self.__enabled:
+            return
+
         assert self.store
         self.store.close()
diff --git a/proxy/plugin/cache/cache_responses.py b/proxy/plugin/cache/cache_responses.py
index 91f2907..0005195 100644
--- a/proxy/plugin/cache/cache_responses.py
+++ b/proxy/plugin/cache/cache_responses.py
@@ -8,7 +8,6 @@
     :copyright: (c) 2013-present by Abhinav Singh and contributors.
     :license: BSD, see LICENSE for more details.
 """
-import multiprocessing
 import tempfile
 from typing import Any
 
@@ -19,9 +18,6 @@ from .base import BaseCacheResponsesPlugin
 class CacheResponsesPlugin(BaseCacheResponsesPlugin):
     """Caches response using OnDiskCacheStore."""
 
-    # Dynamically enable / disable cache
-    ENABLED = multiprocessing.Event()
-
     def __init__(self, *args: Any, **kwargs: Any) -> None:
         super().__init__(*args, **kwargs)
         self.disk_store = OnDiskCacheStore(
diff --git a/proxy/plugin/cache/store/base.py b/proxy/plugin/cache/store/base.py
index eafeaa3..c1f3362 100644
--- a/proxy/plugin/cache/store/base.py
+++ b/proxy/plugin/cache/store/base.py
@@ -9,7 +9,6 @@
     :license: BSD, see LICENSE for more details.
 """
 from abc import ABC, abstractmethod
-from typing import Optional
 from uuid import UUID
 from ....http.parser import HttpParser
 
@@ -24,7 +23,11 @@ class CacheStore(ABC):
         pass
 
     @abstractmethod
-    def cache_request(self, request: HttpParser) -> Optional[HttpParser]:
+    def is_cached(self, request: HttpParser) -> bool:
+        pass
+
+    @abstractmethod
+    def cache_request(self, request: HttpParser) -> HttpParser:
         return request
 
     @abstractmethod
diff --git a/proxy/plugin/cache/store/disk.py b/proxy/plugin/cache/store/disk.py
index 91eb4f2..9323f92 100644
--- a/proxy/plugin/cache/store/disk.py
+++ b/proxy/plugin/cache/store/disk.py
@@ -10,7 +10,10 @@
 """
 import logging
 import os
+import uuid
+import multiprocessing
 from typing import Optional, BinaryIO
+from hashlib import sha512
 from uuid import UUID
 
 from ....common.utils import text_
@@ -22,20 +25,70 @@ logger = logging.getLogger(__name__)
 
 
 class OnDiskCacheStore(CacheStore):
+    lock = multiprocessing.Lock()
 
     def __init__(self, uid: UUID, cache_dir: str) -> None:
         super().__init__(uid)
         self.cache_dir = cache_dir
-        self.cache_file_path: Optional[str] = None
+        if not os.path.isdir(self.cache_dir):
+            os.mkdir(self.cache_dir)
+        logger.debug("Opening cache list")
+        self.cache_list = open(os.path.join(self.cache_dir, 'list.txt'), 'at')
         self.cache_file: Optional[BinaryIO] = None
 
+    def __del__(self) -> None:
+        logger.debug("Closing cache list")
+        self.cache_list.close()
+
     def open(self, request: HttpParser) -> None:
-        self.cache_file_path = os.path.join(
-            self.cache_dir,
-            '%s-%s.txt' % (text_(request.host), self.uid.hex))
-        self.cache_file = open(self.cache_file_path, "wb")
+        pass
+
+    def get_cache_file_path(self, request: HttpParser, create: bool = False) -> str:
+        request_method = text_(request.method)
+        request_host = text_(request.host if request.host else request.headers[b'host'][1])
+        request_path = text_(request.path)
+        request_body = sha512(request.body).hexdigest() if request.body else 'None'
+
+        with self.lock, open(os.path.join(self.cache_dir, 'list.txt'), 'rt') as cache_list:
+            for cache_line in cache_list:
+                method, host, path, body, cache_file_name = cache_line.strip().split(' ')
+                if ((method == request_method) and (host == request_host) and
+                        (path == request_path) and (body == request_body)):
+                    return os.path.join(self.cache_dir, 'proxy-cache-' + cache_file_name)
+
+        if not create:
+            return ''
+
+        cache_file_name = uuid.uuid4().hex
+        while os.path.isfile(os.path.join(self.cache_dir, 'proxy-cache-' + cache_file_name)):
+            cache_file_name = uuid.uuid4().hex
 
-    def cache_request(self, request: HttpParser) -> Optional[HttpParser]:
+        with self.lock:
+            self.cache_list.write('%s %s %s %s %s\n' % (
+                request_method,
+                request_host,
+                request_path,
+                request_body,
+                cache_file_name
+            ))
+        return os.path.join(self.cache_dir, 'proxy-cache-' + cache_file_name)
+
+    def is_cached(self, request: HttpParser) -> bool:
+        return bool(self.get_cache_file_path(request))
+
+    def cache_request(self, request: HttpParser) -> HttpParser:
+        cache_file_path = self.get_cache_file_path(request, True)
+        if os.path.isfile(cache_file_path):
+            logger.info('Found in cache file: %s' % cache_file_path)
+            with open(cache_file_path, 'rb') as cache_file:
+                return HttpParser.response(cache_file.read())
+
+        if self.cache_file:
+            logger.debug("Closing cache file")
+            self.cache_file.close()
+            self.cache_list.flush()
+        logger.info('Caching in file: %s' % cache_file_path)
+        self.cache_file = open(cache_file_path, "ab")
         return request
 
     def cache_response_chunk(self, chunk: memoryview) -> memoryview:
@@ -45,5 +98,6 @@ class OnDiskCacheStore(CacheStore):
 
     def close(self) -> None:
         if self.cache_file:
+            logger.debug("Closing cache file")
             self.cache_file.close()
-            logger.info('Cached response at %s', self.cache_file_path)
+            self.cache_list.flush()


Test patch:
diff --git a/proxy/testing/test_case.py b/proxy/testing/test_case.py
index 55f4b95..3acf850 100644
--- a/proxy/testing/test_case.py
+++ b/proxy/testing/test_case.py
@@ -44,8 +44,8 @@ class TestCase(unittest.TestCase):
         cls.INPUT_ARGS.append(str(cls.PROXY_PORT))
 
         cls.PROXY = Proxy(input_args=cls.INPUT_ARGS)
-        cls.PROXY.flags.plugins[b'HttpProxyBasePlugin'].append(
-            CacheResponsesPlugin)
+        cls.PROXY.flags.plugins[b'HttpProxyBasePlugin'].append(CacheResponsesPlugin)
+        CacheResponsesPlugin.enabled.clear()
 
         cls.PROXY.__enter__()
         cls.wait_for_server(cls.PROXY_PORT)
@@ -79,10 +79,10 @@ class TestCase(unittest.TestCase):
     @contextlib.contextmanager
     def vcr(self) -> Generator[None, None, None]:
         try:
-            CacheResponsesPlugin.ENABLED.set()
+            CacheResponsesPlugin.enabled.set()
             yield
         finally:
-            CacheResponsesPlugin.ENABLED.clear()
+            CacheResponsesPlugin.enabled.clear()
 
     def run(self, result: Optional[unittest.TestResult] = None) -> Any:
         super().run(result)
diff --git a/tests/plugin/test_http_proxy_plugins.py b/tests/plugin/test_http_proxy_plugins.py
index 51b3cf6..0464bcf 100644
--- a/tests/plugin/test_http_proxy_plugins.py
+++ b/tests/plugin/test_http_proxy_plugins.py
@@ -10,7 +10,10 @@
 """
 import unittest
 import selectors
+import tempfile
 import json
+import os
+from pathlib import Path
 
 from urllib import parse as urlparse
 from unittest import mock
@@ -56,6 +59,14 @@ class TestHttpProxyPluginExamples(unittest.TestCase):
             flags=self.flags)
         self.protocol_handler.initialize()
 
+    def tearDown(self) -> None:
+        tmpDir = Path(tempfile.gettempdir())
+        for f in tmpDir.glob('proxy-cache-*'):
+            if f.is_file():
+                os.remove(f)
+        if tmpDir.joinpath('list.txt').is_file():
+            os.remove(tmpDir / 'list.txt')
+
     @mock.patch('proxy.http.proxy.server.TcpServerConnection')
     def test_modify_post_data_plugin(
             self, mock_server_conn: mock.Mock) -> None:
@@ -281,3 +292,171 @@ class TestHttpProxyPluginExamples(unittest.TestCase):
                 headers={b'Connection': b'close'},
             )
         )
+
+    @mock.patch('proxy.http.proxy.server.TcpServerConnection')
+    def test_cache_responses_plugin_cache(self, mock_server_conn: mock.Mock) -> None:
+        request = build_http_request(
+            b'GET', b'http://example.org/get',
+            headers={
+                b'Host': b'example.org',
+            }
+        )
+        server_response = build_http_response(
+            httpStatusCodes.OK,
+            reason=b'OK',
+            body=b'Original Response From Upstream'
+        )
+
+        # Setup server:
+        server = mock_server_conn.return_value
+        server.addr = ('example.org', 80)
+        server.connect.return_value = True
+
+        def has_buffer() -> bool:
+            return cast(bool, server.queue.called)
+
+        def closed() -> bool:
+            return not server.connect.called
+
+        server.has_buffer.side_effect = has_buffer
+        type(server).closed = mock.PropertyMock(side_effect=closed)
+
+        # Setup selector:
+        self.mock_selector.return_value.select.side_effect = [
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ)],
+            [(selectors.SelectorKey(
+                fileobj=server.connection,
+                fd=server.connection.fileno,
+                events=selectors.EVENT_WRITE,
+                data=None), selectors.EVENT_WRITE)],
+            [(selectors.SelectorKey(
+                fileobj=server.connection,
+                fd=server.connection.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ)],
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_WRITE,
+                data=None), selectors.EVENT_WRITE)],
+            [(selectors.SelectorKey(
+                fileobj=server.connection,
+                fd=server.connection.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ)],
+        ]
+
+        # Client read:
+        self._conn.recv.return_value = request
+        self.protocol_handler.run_once()
+        mock_server_conn.assert_called_with('example.org', DEFAULT_HTTP_PORT)
+        server.connect.assert_called_once()
+        server.queue.assert_called_once_with(build_http_request(
+            b'GET', b'/get',
+            headers={
+                b'Host': b'example.org',
+                b'Via': b'1.1 %s' % PROXY_AGENT_HEADER_VALUE,
+            }
+        ))
+
+        # Server write:
+        self.protocol_handler.run_once()
+        server.flush.assert_called_once()
+
+        # Server read:
+        server.recv.return_value = memoryview(server_response)
+        self.protocol_handler.run_once()
+
+        # Client write:
+        self._conn.send.return_value = len(server_response)
+        self.protocol_handler.run_once()
+        self._conn.send.assert_called_once_with(server_response)
+
+        # Server close connection:
+        server.recv.return_value = None
+        self.protocol_handler.run_once()
+        self.protocol_handler.shutdown()
+
+        with open(Path(tempfile.gettempdir()) / 'list.txt', 'rt') as cache_list:
+            cache_lines = list(cache_list)
+            self.assertEqual(len(cache_lines), 1)
+            method, host, path, body, cache_file_name = cache_lines[0].strip().split(' ')
+            self.assertEqual(method, 'GET')
+            self.assertEqual(host, 'example.org')
+            self.assertEqual(path, '/get')
+            self.assertEqual(body, 'None')
+        with open(Path(tempfile.gettempdir()) / ('proxy-cache-' + cache_file_name), 'rb') as cache_file:
+            self.assertEqual(cache_file.read(), server_response)
+
+    @mock.patch('proxy.http.proxy.server.TcpServerConnection')
+    def test_cache_responses_plugin_load(self, mock_server_conn: mock.Mock) -> None:
+        request = build_http_request(
+            b'GET', b'http://example.org/get',
+            headers={
+                b'Host': b'example.org',
+            }
+        )
+        cache_response = build_http_response(
+            httpStatusCodes.OK,
+            reason=b'OK',
+            body=b'Response From Cache'
+        )
+
+        # Setup cache:
+        cache_file_name = 'test'
+        with open(Path(tempfile.gettempdir()) / 'list.txt', 'wt') as cache_list:
+            cache_list.write('GET example.org /get None %s\n' % cache_file_name)
+        with open(Path(tempfile.gettempdir()) / ('proxy-cache-' + cache_file_name), 'wb') as cache_file:
+            cache_file.write(cache_response)
+
+        # Setup server:
+        server = mock_server_conn.return_value
+        server.addr = ('example.org', 80)
+        server.connect.return_value = True
+
+        def has_buffer() -> bool:
+            return cast(bool, server.queue.called)
+
+        def closed() -> bool:
+            return not server.connect.called
+
+        server.has_buffer.side_effect = has_buffer
+        type(server).closed = mock.PropertyMock(side_effect=closed)
+
+        # Setup selector:
+        self.mock_selector.return_value.select.side_effect = [
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ)],
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_WRITE,
+                data=None), selectors.EVENT_WRITE)],
+            [(selectors.SelectorKey(
+                fileobj=self._conn,
+                fd=self._conn.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ)],
+        ]
+
+        # Client read:
+        self._conn.recv.return_value = request
+        self.protocol_handler.run_once()
+        mock_server_conn.assert_not_called()
+
+        # Client write:
+        self._conn.send.return_value = len(cache_response)
+        self.protocol_handler.run_once()
+        self._conn.send.assert_called_once_with(cache_response)
+
+        # Client close connection:
+        self._conn.recv.return_value = b''
+        self.protocol_handler.run_once()
+        self.protocol_handler.shutdown()
diff --git a/tests/plugin/test_http_proxy_plugins_with_tls_interception.py b/tests/plugin/test_http_proxy_plugins_with_tls_interception.py
index f3289e0..127ad49 100644
--- a/tests/plugin/test_http_proxy_plugins_with_tls_interception.py
+++ b/tests/plugin/test_http_proxy_plugins_with_tls_interception.py
@@ -12,6 +12,9 @@ import unittest
 import socket
 import selectors
 import ssl
+import os
+import tempfile
+from pathlib import Path
 
 from unittest import mock
 from typing import Any, cast
@@ -81,6 +84,7 @@ class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase):
         self.protocol_handler.initialize()
 
         self.server = self.mock_server_conn.return_value
+        self.server.addr = ('uni.corn', 433)
 
         self.server_ssl_connection = mock.MagicMock(spec=ssl.SSLSocket)
         self.mock_ssl_context.return_value.wrap_socket.return_value = self.server_ssl_connection
@@ -128,7 +132,18 @@ class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase):
                 fileobj=self.server_ssl_connection,
                 fd=self.server_ssl_connection.fileno,
                 events=selectors.EVENT_READ,
-                data=None), selectors.EVENT_READ)], ]
+                data=None), selectors.EVENT_READ)],
+            [(selectors.SelectorKey(
+                fileobj=self.client_ssl_connection,
+                fd=self.client_ssl_connection.fileno,
+                events=selectors.EVENT_WRITE,
+                data=None), selectors.EVENT_WRITE)],
+            [(selectors.SelectorKey(
+                fileobj=self.server_ssl_connection,
+                fd=self.server_ssl_connection.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ)],
+        ]
 
         # Connect
         def send(raw: bytes) -> int:
@@ -155,6 +170,14 @@ class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase):
         )
         self.assertFalse(self.protocol_handler.client.has_buffer())
 
+    def tearDown(self) -> None:
+        tmpDir = Path(tempfile.gettempdir())
+        for f in tmpDir.glob('proxy-cache-*'):
+            if f.is_file():
+                os.remove(f)
+        if tmpDir.joinpath('list.txt').is_file():
+            os.remove(tmpDir / 'list.txt')
+
     def test_modify_post_data_plugin(self) -> None:
         original = b'{"key": "value"}'
         modified = b'{"key": "modified"}'
@@ -209,3 +232,109 @@ class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase):
                 httpStatusCodes.OK,
                 reason=b'OK', body=b'Hello from man in the middle')
         )
+
+    def test_cache_responses_plugin_cache(self) -> None:
+        request = build_http_request(
+            b'GET', b'/get',
+            headers={
+                b'Host': b'uni.corn',
+            }
+        )
+        server_response = build_http_response(
+            httpStatusCodes.OK,
+            reason=b'OK',
+            body=b'Original Response From Upstream'
+        )
+
+        # Client read:
+        self.client_ssl_connection.recv.return_value = request
+        self.protocol_handler.run_once()
+        self.server.queue.assert_called_once_with(build_http_request(
+            b'GET', b'/get',
+            headers={
+                b'Host': b'uni.corn',
+            }
+        ))
+
+        # Server write:
+        self.protocol_handler.run_once()
+        self.server.flush.assert_called_once()
+
+        # Server read:
+        self.server.recv.return_value = memoryview(server_response)
+        self.protocol_handler.run_once()
+
+        # Client write:
+        self.client_ssl_connection.send.return_value = len(server_response)
+        self.protocol_handler.run_once()
+        self.client_ssl_connection.send.assert_called_once_with(server_response)
+
+        # Server close connection:
+        self.server.recv.return_value = None
+        self.protocol_handler.run_once()
+        self.protocol_handler.shutdown()
+
+        with open(Path(tempfile.gettempdir()) / 'list.txt', 'rt') as cache_list:
+            cache_lines = list(cache_list)
+            self.assertEqual(len(cache_lines), 1)
+            method, host, path, body, cache_file_name = cache_lines[0].strip().split(' ')
+            self.assertEqual(method, 'GET')
+            self.assertEqual(host, 'uni.corn')
+            self.assertEqual(path, '/get')
+            self.assertEqual(body, 'None')
+        with open(Path(tempfile.gettempdir()) / ('proxy-cache-' + cache_file_name), 'rb') as cache_file:
+            self.assertEqual(cache_file.read(), server_response)
+
+    def test_cache_responses_plugin_load(self) -> None:
+        request = build_http_request(
+            b'GET', b'/get',
+            headers={
+                b'Host': b'uni.corn',
+            }
+        )
+        cache_response = build_http_response(
+            httpStatusCodes.OK,
+            reason=b'OK',
+            body=b'Response From Cache'
+        )
+
+        # Setup cache:
+        cache_file_name = 'test'
+        with open(Path(tempfile.gettempdir()) / 'list.txt', 'wt') as cache_list:
+            cache_list.write('GET uni.corn /get None %s' % cache_file_name)
+        with open(Path(tempfile.gettempdir()) / ('proxy-cache-' + cache_file_name), 'wb') as cache_file:
+            cache_file.write(cache_response)
+
+        # Setup selector:
+        self.mock_selector.return_value.select.side_effect = [
+            [(selectors.SelectorKey(
+                fileobj=self.client_ssl_connection,
+                fd=self.client_ssl_connection.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ)],
+            [(selectors.SelectorKey(
+                fileobj=self.client_ssl_connection,
+                fd=self.client_ssl_connection.fileno,
+                events=selectors.EVENT_WRITE,
+                data=None), selectors.EVENT_WRITE)],
+            [(selectors.SelectorKey(
+                fileobj=self.client_ssl_connection,
+                fd=self.client_ssl_connection.fileno,
+                events=selectors.EVENT_READ,
+                data=None), selectors.EVENT_READ)],
+        ]
+
+        # Client read:
+        self.client_ssl_connection.recv.return_value = request
+        self.protocol_handler.run_once()
+        self.server_ssl_connection.send.assert_not_called()
+
+        # Client write:
+        self.client_ssl_connection.send.return_value = len(cache_response)
+        self.protocol_handler.run_once()
+        self.client_ssl_connection.send.assert_called_once_with(cache_response)
+
+        # Client close connection:
+        self.client_ssl_connection.recv.return_value = b''
+        self.protocol_handler.run_once()
+        self.protocol_handler.shutdown()
diff --git a/tests/plugin/utils.py b/tests/plugin/utils.py
index 3e58ff1..5ed238c 100644
--- a/tests/plugin/utils.py
+++ b/tests/plugin/utils.py
@@ -25,7 +25,7 @@ def get_plugin_by_test_name(test_name: str) -> Type[HttpProxyBasePlugin]:
         plugin = RedirectToCustomServerPlugin
     elif test_name == 'test_filter_by_upstream_host_plugin':
         plugin = FilterByUpstreamHostPlugin
-    elif test_name == 'test_cache_responses_plugin':
+    elif test_name.startswith('test_cache_responses_plugin'):
         plugin = CacheResponsesPlugin
     elif test_name == 'test_man_in_the_middle_plugin':
         plugin = ManInTheMiddlePlugin
diff --git a/tests/testing/test_embed.py b/tests/testing/test_embed.py
index e69c5ff..9e89c28 100644
--- a/tests/testing/test_embed.py
+++ b/tests/testing/test_embed.py
@@ -9,13 +9,17 @@
     :license: BSD, see LICENSE for more details.
 """
 import os
+import time
+import tempfile
 import unittest
 import http.client
 import urllib.request
 import urllib.error
+from typing import Union, Optional
+from pathlib import Path
 
 from proxy import TestCase
-from proxy.common.constants import DEFAULT_CLIENT_RECVBUF_SIZE, PROXY_AGENT_HEADER_VALUE
+from proxy.common.constants import DEFAULT_TIMEOUT, DEFAULT_CLIENT_RECVBUF_SIZE, PROXY_AGENT_HEADER_VALUE
 from proxy.common.utils import socket_connection, build_http_request, build_http_response
 from proxy.http.codes import httpStatusCodes
 from proxy.http.methods import httpMethods
@@ -31,6 +35,32 @@ class TestProxyPyEmbedded(TestCase):
         '--enable-web-server',
     ]
 
+    def waitCached(self,
+                   cache_list_path: Union[str, Path],
+                   expected_method: str,
+                   expected_host: str,
+                   expected_path: str,
+                   expected_body: str,
+                   timeout: int = DEFAULT_TIMEOUT) -> Optional[str]:
+        while timeout != 0:
+            with open(cache_list_path, 'rt') as cache_list:
+                for cache_line in cache_list:
+                    method, host, path, body, cache_file_name = cache_line.strip().split(' ')
+                    if ((method == expected_method) and (host == expected_host) and
+                            (path == expected_path) and (body == expected_body)):
+                        return 'proxy-cache-' + cache_file_name
+            time.sleep(1)
+            if (timeout > 0):
+                timeout -= 1
+        return None
+
+    def tearDown(self) -> None:
+        tmpDir = Path(tempfile.gettempdir())
+        for f in tmpDir.glob('proxy-cache-*'):
+            if f.is_file():
+                os.remove(f)
+        os.remove(tmpDir / 'list.txt')
+
     def test_with_proxy(self) -> None:
         """Makes a HTTP request to in-build web server via proxy server."""
         with socket_connection(('localhost', self.PROXY_PORT)) as conn:
@@ -66,9 +96,27 @@ class TestProxyPyEmbedded(TestCase):
         with self.vcr():
             self.make_http_request_using_proxy()
 
+        cache_file_name = self.waitCached(Path(tempfile.gettempdir()) / 'list.txt',
+                                          'GET', 'localhost', '/', 'None')
+        if cache_file_name is None:
+            self.fail('Timeout waiting for cached request')
+        self.assertTrue(os.path.isfile(Path(tempfile.gettempdir()) / cache_file_name))
+
+        with open(Path(tempfile.gettempdir()) / cache_file_name, 'rb') as cache_file:
+            self.assertEqual(cache_file.read(), build_http_response(
+                httpStatusCodes.NOT_FOUND, reason=b'NOT FOUND',
+                headers={
+                    b'Server': PROXY_AGENT_HEADER_VALUE,
+                    b'Connection': b'close'
+                }
+            ))
+
     def test_proxy_no_vcr(self) -> None:
         self.make_http_request_using_proxy()
 
+        self.assertIsNone(self.waitCached(Path(tempfile.gettempdir()) / 'list.txt',
+                                          'GET', 'localhost', '/', 'None', 2))
+
     def make_http_request_using_proxy(self) -> None:
         proxy_handler = urllib.request.ProxyHandler({
             'http': 'http://localhost:%d' % self.PROXY_PORT,