Repository: abhinavsingh/proxy.py
Pull request #441 (2020-10-04 17:35:29)

Add request fingerprint as cached filename
Introduce a mechanism within `HttpParser` class that maintains a running hash of request being processed.  Fingerprinting feature is currently being used by cache plugin and might be an overkill to introduce within `HttpParser` class.  As request hashes will be calculated even without cache plugin enabled.

**Fingerprinting** removes the need of maintaining an index structure.  Here is how it will work:
1) Cache plugin uses a file named after `fingerprint` of the request to store cached data.
2) For a new incoming request, cache file that matches incoming request fingerprint is looked up on disk.  This operation will be O(1), either the cache file exists or not.  If it exists, request is served out of the cache.

**Note** that, serving out of cache only works for:
1) `http` requests
2) `https` requests with `TLS Interception`

For the 2nd case i.e. with `TLS Interception`, `https` request fingerprint is a calculated based upon both the `CONNECT` request and the actual cached request.

**Additionally**:
1) Fix some tests due to change in default cache directory
2) Fix event propagation using multiprocessing manager
3) Adds `ReplayTestCase` which decouples replay functionality from normal test case.  This also removes the need of dynamically enabling cache plugin.

**TODO**:
1) Revisit placement of hash calculation in case it reflects into higher CPU/RAM consumption for high traffic load.
2) Fix cache plugin for TLS interception mode.  Is currently **broken**.

codecov[bot]: # [Codecov](https://codecov.io/gh/abhinavsingh/proxy.py/pull/441?src=pr&el=h1) Report
> Merging [#441](https://codecov.io/gh/abhinavsingh/proxy.py/pull/441?src=pr&el=desc) into [cache-server](https://codecov.io/gh/abhinavsingh/proxy.py/commit/4bef06dc38459d0e33039103f5c7a39cd6c13145?el=desc) will **increase** coverage by `0.12%`.
> The diff coverage is `93.33%`.

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

```diff
@@               Coverage Diff                @@
##           cache-server     #441      +/-   ##
================================================
+ Coverage         81.29%   81.41%   +0.12%     
================================================
  Files                79       79              
  Lines              3047     3056       +9     
================================================
+ Hits               2477     2488      +11     
+ Misses              570      568       -2     
```


| [Impacted Files](https://codecov.io/gh/abhinavsingh/proxy.py/pull/441?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [proxy/plugin/cache/store/base.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/441/diff?src=pr&el=tree#diff-cHJveHkvcGx1Z2luL2NhY2hlL3N0b3JlL2Jhc2UucHk=) | `77.27% <ø> (ø)` | |
| [proxy/plugin/cache/base.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/441/diff?src=pr&el=tree#diff-cHJveHkvcGx1Z2luL2NhY2hlL2Jhc2UucHk=) | `85.89% <86.36%> (+1.08%)` | :arrow_up: |
| [proxy/plugin/cache/store/disk.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/441/diff?src=pr&el=tree#diff-cHJveHkvcGx1Z2luL2NhY2hlL3N0b3JlL2Rpc2sucHk=) | `92.85% <95.23%> (+1.55%)` | :arrow_up: |
| [proxy/common/flags.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/441/diff?src=pr&el=tree#diff-cHJveHkvY29tbW9uL2ZsYWdzLnB5) | `97.86% <100.00%> (ø)` | |
| [proxy/http/parser.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/441/diff?src=pr&el=tree#diff-cHJveHkvaHR0cC9wYXJzZXIucHk=) | `96.36% <100.00%> (+0.11%)` | :arrow_up: |
| [proxy/plugin/cache/cache\_responses.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/441/diff?src=pr&el=tree#diff-cHJveHkvcGx1Z2luL2NhY2hlL2NhY2hlX3Jlc3BvbnNlcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [proxy/testing/test\_case.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/441/diff?src=pr&el=tree#diff-cHJveHkvdGVzdGluZy90ZXN0X2Nhc2UucHk=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/abhinavsingh/proxy.py/pull/441?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/441?src=pr&el=footer). Last update [4bef06d...799c1c3](https://codecov.io/gh/abhinavsingh/proxy.py/pull/441?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

abhinavsingh: Following warning is produced during tests:

```
--- Logging error ---
Traceback (most recent call last):
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.7/lib/python3.7/logging/__init__.py", line 1037, in emit
    stream.write(msg + self.terminator)
  File "/Users/abhinavsingh/Dev/proxy.py/venv373/lib/python3.7/site-packages/_pytest/capture.py", line 424, in write
    return self.buffer.write(s.encode(self.encoding, "replace"))
ValueError: I/O operation on closed file
Call stack:
  File "/Users/abhinavsingh/Dev/proxy.py/proxy/plugin/cache/store/disk.py", line 43, in __del__
    logger.debug("Closing cache list")
Message: 'Closing cache list'
Arguments: ()
--- Logging error ---
Traceback (most recent call last):
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.7/lib/python3.7/logging/__init__.py", line 1037, in emit
    stream.write(msg + self.terminator)
  File "/Users/abhinavsingh/Dev/proxy.py/venv373/lib/python3.7/site-packages/_pytest/capture.py", line 424, in write
    return self.buffer.write(s.encode(self.encoding, "replace"))
ValueError: I/O operation on closed file
Call stack:
  File "/Users/abhinavsingh/Dev/proxy.py/proxy/plugin/cache/store/disk.py", line 43, in __del__
    logger.debug("Closing cache list")
Message: 'Closing cache list'
Arguments: ()
```
pasccom: Hello, I think these warnings come from the fact that we output the message in the cache `__del__()` method https://github.com/abhinavsingh/proxy.py/blob/4d4a2b1f11ccf9faa3e9b6ac689914a1f21cc01e/proxy/plugin/cache/store/disk.py#L72-L74 which is executed after test ends (when the garbage collector releases the cache).
When running isolated tests (using `python -m unitest`), it is not a problem, but it seems `pytest` does not like this.
I did not bother to fix this because it gives useful information when using `python -m unitest`.
abhinavsingh: > When running isolated tests (using `python -m unitest`), it is not a problem, but it seems `pytest` does not like this.
> I did not bother to fix this because it gives useful information when using `python -m unitest`.

Thank you for the context.  Yes, I wasn't sure why these warnings are being thrown.  IIRC, these warning come from http proxy plugin testing with TLS interception on.  Don't see these warnings otherwise.  Will dig further into it this week.
abhinavsingh: Need a cleaner way to dynamically enable / disable a plugin.  Current approach used by cache response plugin doesn't work universally.  Problems being:

1. `multiprocessing.Event` which is declared as a global variable gets forked instead of being shared between parent and child.  This only impacts `Python 3.8` on `MacOS`.  Likely will fail on `Windows` too.

2. By replacing `multiprocessing.Event` with `multiprocessing.Manager().Event` fixes the issue but then we start to run into following runtime errors:

```
> python -m proxy --num-workers 1
2020-10-05 10:18:55,090 - pid:1685 [I] load_plugins:318 - Loaded plugin proxy.http.proxy.HttpProxyPlugin
2020-10-05 10:18:55,091 - pid:1685 [I] listen:116 - Listening on ::1:8899
2020-10-05 10:18:55,093 - pid:1685 [I] start_workers:139 - Started 1 workers
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/usr/local/Cellar/python@3.8/3.8.3/Frameworks/Python.framework/Versions/3.8/lib/python3.8/multiprocessing/spawn.py", line 116, in spawn_main
    exitcode = _main(fd, parent_sentinel)
  File "/usr/local/Cellar/python@3.8/3.8.3/Frameworks/Python.framework/Versions/3.8/lib/python3.8/multiprocessing/spawn.py", line 126, in _main
    self = reduction.pickle.load(from_parent)
  File "/Users/abhinavsingh/Dev/proxy.py/proxy/__init__.py", line 14, in <module>
    from .testing.test_case import TestCase
  File "/Users/abhinavsingh/Dev/proxy.py/proxy/testing/test_case.py", line 19, in <module>
    from ..plugin import CacheResponsesPlugin
  File "/Users/abhinavsingh/Dev/proxy.py/proxy/plugin/__init__.py", line 11, in <module>
    from .cache import CacheResponsesPlugin, BaseCacheResponsesPlugin
  File "/Users/abhinavsingh/Dev/proxy.py/proxy/plugin/cache/__init__.py", line 11, in <module>
    from .base import BaseCacheResponsesPlugin
  File "/Users/abhinavsingh/Dev/proxy.py/proxy/plugin/cache/base.py", line 44, in <module>
    class BaseCacheResponsesPlugin(HttpProxyBasePlugin):
  File "/Users/abhinavsingh/Dev/proxy.py/proxy/plugin/cache/base.py", line 52, in BaseCacheResponsesPlugin
    enabled = Event()
  File "/Users/abhinavsingh/Dev/proxy.py/proxy/plugin/cache/base.py", line 29, in __init__
    self.manager = multiprocessing.Manager()
  File "/usr/local/Cellar/python@3.8/3.8.3/Frameworks/Python.framework/Versions/3.8/lib/python3.8/multiprocessing/context.py", line 57, in Manager
    m.start()
  File "/usr/local/Cellar/python@3.8/3.8.3/Frameworks/Python.framework/Versions/3.8/lib/python3.8/multiprocessing/managers.py", line 579, in start
    self._process.start()
  File "/usr/local/Cellar/python@3.8/3.8.3/Frameworks/Python.framework/Versions/3.8/lib/python3.8/multiprocessing/process.py", line 121, in start
    self._popen = self._Popen(self)
  File "/usr/local/Cellar/python@3.8/3.8.3/Frameworks/Python.framework/Versions/3.8/lib/python3.8/multiprocessing/context.py", line 283, in _Popen
    return Popen(process_obj)
  File "/usr/local/Cellar/python@3.8/3.8.3/Frameworks/Python.framework/Versions/3.8/lib/python3.8/multiprocessing/popen_spawn_posix.py", line 32, in __init__
    super().__init__(process_obj)
  File "/usr/local/Cellar/python@3.8/3.8.3/Frameworks/Python.framework/Versions/3.8/lib/python3.8/multiprocessing/popen_fork.py", line 19, in __init__
    self._launch(process_obj)
  File "/usr/local/Cellar/python@3.8/3.8.3/Frameworks/Python.framework/Versions/3.8/lib/python3.8/multiprocessing/popen_spawn_posix.py", line 42, in _launch
    prep_data = spawn.get_preparation_data(process_obj._name)
  File "/usr/local/Cellar/python@3.8/3.8.3/Frameworks/Python.framework/Versions/3.8/lib/python3.8/multiprocessing/spawn.py", line 154, in get_preparation_data
    _check_not_importing_main()
  File "/usr/local/Cellar/python@3.8/3.8.3/Frameworks/Python.framework/Versions/3.8/lib/python3.8/multiprocessing/spawn.py", line 134, in _check_not_importing_main
    raise RuntimeError('''
RuntimeError: 
        An attempt has been made to start a new process before the
        current process has finished its bootstrapping phase.

        This probably means that you are not using fork to start your
        child processes and you have forgotten to use the proper idiom
        in the main module:

            if __name__ == '__main__':
                freeze_support()
                ...

        The "freeze_support()" line can be omitted if the program
        is not going to be frozen to produce an executable.
```

Because being a global variable, `event` tries to initialize itself as soon as it is imported.
abhinavsingh: @pasccom Some notable changes:

1. Removed need of a dynamic plugin enable / disable functionality which was flaky in some environment
2. Using fingerprint we can remove need of maintaining an index file.  See PR description.
pasccom: Hello @abhinavsingh, I introduced `list.txt` because I needed to have a means to know which requests were cached and which cache file corresponds to which request (sometimes upstream sends random server errors, and I need to remove them manually from the test cache). 
On second thought, I think it is not a bad idea to get rid of it. I think I can make do with the log file. Maybe we will have to improve the logging messages, I do not know how accurate they are.
abhinavsingh: > Hello @abhinavsingh, I introduced `list.txt` because I needed to have a means to know which requests were cached and which cache file corresponds to which request (sometimes upstream sends random server errors, and I need to remove them manually from the test cache).

Agree about various server response codes.  We'll definitely disable caching for certain http codes.  For replay testing, caching can be enabled for all response codes.

> On second thought, I think it is not a bad idea to get rid of it. I think I can make do with the log file. Maybe we will have to improve the logging messages, I do not know how accurate they are.

Yes, log file can work very well here.  We'll have to add fingerprint as one of the logging format parameters.

Currently we are only caching responses.  But we can also cache requests _(most like add this feature behind a flag)_.  Example, `fingerprint.request` and `fingerprint.response`.  We can add some utility code to build index out out of cached files either offline or in-realtime using eventing.

abhinavsingh: @pasccom  Created #443 to track it separately.  I don't think we can cleanly proceed forward for `https` + `TLS interception` mode.  Problem being, as you mentioned before, core expects an active upstream connection.  I have discussed other problem arising with no upstream connection _(see issue description)_.

Overall, it's unclear how to approach this for now.  Ideally caching should work w/o upstream connection.  Kind of defeats the purpose of cache if we have to re-connect with upstream even for serving out of cache.  At the same time, we have some edge cases to handle if upstream connection is not made _(see issue description)_.

Merging this one for now.

Patch:
diff --git a/proxy/__init__.py b/proxy/__init__.py
index ca7c526..d10c515 100755
--- a/proxy/__init__.py
+++ b/proxy/__init__.py
@@ -11,7 +11,7 @@
 from .proxy import entry_point
 from .proxy import main, start
 from .proxy import Proxy
-from .testing.test_case import TestCase
+from .testing import TestCase, ReplayTestCase
 
 __all__ = [
     # PyPi package entry_point. See
@@ -23,5 +23,6 @@ __all__ = [
     # Unit testing with proxy.py. See
     # https://github.com/abhinavsingh/proxy.py#unit-testing-with-proxypy
     'TestCase',
+    'ReplayTestCase',
     'Proxy',
 ]
diff --git a/proxy/common/flags.py b/proxy/common/flags.py
index 93acc9a..58b9d8e 100644
--- a/proxy/common/flags.py
+++ b/proxy/common/flags.py
@@ -94,3 +94,9 @@ class Flags:
             self.ca_cert_dir = os.path.join(
                 self.proxy_py_data_dir, 'certificates')
             os.makedirs(self.ca_cert_dir, exist_ok=True)
+
+    def tls_interception_enabled(self) -> bool:
+        return self.ca_key_file is not None and \
+            self.ca_cert_dir is not None and \
+            self.ca_signing_key_file is not None and \
+            self.ca_cert_file is not None
diff --git a/proxy/dashboard/plugin.py b/proxy/dashboard/plugin.py
index 7c5b9a3..9fa48e2 100644
--- a/proxy/dashboard/plugin.py
+++ b/proxy/dashboard/plugin.py
@@ -34,7 +34,7 @@ class ProxyDashboardWebsocketPlugin(ABC):
     @abstractmethod
     def methods(self) -> List[str]:
         """Return list of methods that this plugin will handle."""
-        pass
+        pass    # pragma: no cover
 
     def connected(self) -> None:
         """Invoked when client websocket handshake finishes."""
@@ -43,7 +43,7 @@ class ProxyDashboardWebsocketPlugin(ABC):
     @abstractmethod
     def handle_message(self, message: Dict[str, Any]) -> None:
         """Handle messages for registered methods."""
-        pass
+        pass    # pragma: no cover
 
     def disconnected(self) -> None:
         """Invoked when client websocket connection gets closed."""
diff --git a/proxy/http/parser.py b/proxy/http/parser.py
index d40885c..a5de414 100644
--- a/proxy/http/parser.py
+++ b/proxy/http/parser.py
@@ -8,6 +8,7 @@
     :copyright: (c) 2013-present by Abhinav Singh and contributors.
     :license: BSD, see LICENSE for more details.
 """
+import hashlib
 from urllib import parse as urlparse
 from typing import TypeVar, NamedTuple, Optional, Dict, Type, Tuple, List
 
@@ -45,11 +46,12 @@ class HttpParser:
         self.type: int = parser_type
         self.state: int = httpParserStates.INITIALIZED
 
-        # Total size of raw bytes passed for parsing
-        self.total_size: int = 0
-
-        # Buffer to hold unprocessed bytes
-        self.buffer: bytes = b''
+        # These properties cleans up developer APIs.  Python urlparse.urlsplit behaves
+        # differently for proxy request and web request.  Web request is the one
+        # which is broken.
+        self.host: Optional[bytes] = None
+        self.port: Optional[int] = None
+        self.path: Optional[bytes] = None
 
         self.headers: Dict[bytes, Tuple[bytes, bytes]] = dict()
         self.body: Optional[bytes] = None
@@ -62,12 +64,18 @@ class HttpParser:
 
         self.chunk_parser: Optional[ChunkParser] = None
 
-        # This cleans up developer APIs as Python urlparse.urlsplit behaves differently
-        # for incoming proxy request and incoming web request.  Web request is the one
-        # which is broken.
-        self.host: Optional[bytes] = None
-        self.port: Optional[int] = None
-        self.path: Optional[bytes] = None
+        # Total size of raw bytes passed for parsing
+        self.total_size: int = 0
+
+        # Buffer to hold unprocessed bytes
+        self.buffer: bytes = b''
+
+        # Hash which is updated as request gets parsed
+        #
+        # TODO(abhinavsingh): This currently is a requirement
+        # only when cache plugin is in use, otherwise, this
+        # will only increase CPU & RAM usage.
+        self.hash: hashlib._Hash = hashlib.sha512()
 
     @classmethod
     def request(cls: Type[T], raw: bytes) -> T:
@@ -81,6 +89,15 @@ class HttpParser:
         parser.parse(raw)
         return parser
 
+    def fingerprint(self) -> str:
+        """Returns a fingerprint unique for the contents in this request.
+
+        Ideally must only be used once request has finished processing.
+        Otherwise, returned fingerprint will be unique for the partial
+        request being processed.
+        """
+        return self.hash.hexdigest()
+
     def header(self, key: bytes) -> bytes:
         if key.lower() not in self.headers:
             raise KeyError('%s not found in headers', text_(key))
@@ -142,6 +159,8 @@ class HttpParser:
         """Parses Http request out of raw bytes.
 
         Check HttpParser state after parse has successfully returned."""
+        self.hash.update(raw)
+
         self.total_size += len(raw)
         raw = self.buffer + raw
         self.buffer = b''
diff --git a/proxy/http/proxy/server.py b/proxy/http/proxy/server.py
index ef8f5ab..26353f6 100644
--- a/proxy/http/proxy/server.py
+++ b/proxy/http/proxy/server.py
@@ -129,12 +129,6 @@ class HttpProxyPlugin(HttpProtocolHandlerPlugin):
                     self.event_queue)
                 self.plugins[instance.name()] = instance
 
-    def tls_interception_enabled(self) -> bool:
-        return self.flags.ca_key_file is not None and \
-            self.flags.ca_cert_dir is not None and \
-            self.flags.ca_signing_key_file is not None and \
-            self.flags.ca_cert_file is not None
-
     def get_descriptors(
             self) -> Tuple[List[socket.socket], List[socket.socket]]:
         if not self.request.has_upstream_server():
@@ -216,7 +210,7 @@ class HttpProxyPlugin(HttpProtocolHandlerPlugin):
                 # See https://github.com/abhinavsingh/proxy.py/issues/127 for why
                 # currently response parsing is disabled when TLS interception is enabled.
                 #
-                # or self.tls_interception_enabled():
+                # or self.flags.tls_interception_enabled():
                 if self.response.state == httpParserStates.COMPLETE:
                     self.handle_pipeline_response(raw)
                 else:
@@ -279,12 +273,14 @@ class HttpProxyPlugin(HttpProtocolHandlerPlugin):
         if self.server and not self.server.closed:
             if self.request.state == httpParserStates.COMPLETE and (
                     self.request.method != httpMethods.CONNECT or
-                    self.tls_interception_enabled()):
+                    self.flags.tls_interception_enabled()):
                 if self.pipeline_request is not None and \
                         self.pipeline_request.is_connection_upgrade():
                     # Previous pipelined request was a WebSocket
                     # upgrade request. Incoming client data now
                     # must be treated as WebSocket protocol packets.
+                    #
+                    # TODO(abhinavsingh): Parse websocket frames here.
                     self.server.queue(raw)
                     return None
 
@@ -349,7 +345,7 @@ class HttpProxyPlugin(HttpProtocolHandlerPlugin):
             self.client.queue(
                 HttpProxyPlugin.PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT)
             # If interception is enabled
-            if self.tls_interception_enabled():
+            if self.flags.tls_interception_enabled():
                 # Perform SSL/TLS handshake with upstream
                 self.wrap_server()
                 # Generate certificate and perform handshake with client
diff --git a/proxy/plugin/cache/base.py b/proxy/plugin/cache/base.py
index 0605d4e..b018ac2 100644
--- a/proxy/plugin/cache/base.py
+++ b/proxy/plugin/cache/base.py
@@ -9,16 +9,12 @@
     :license: BSD, see LICENSE for more details.
 """
 import logging
-import multiprocessing
+from proxy.common.constants import CRLF
 from typing import Optional, Any
 
 from .store.base import CacheStore
-from ...http.parser import HttpParser, httpParserTypes
+from ...http.parser import HttpParser
 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__)
 
@@ -26,114 +22,93 @@ logger = logging.getLogger(__name__)
 class BaseCacheResponsesPlugin(HttpProxyBasePlugin):
     """Base cache plugin.
 
-    It requires a storage backend to work with. Storage class
-    must implement CacheStore interface.
-
-    Different storage backends can be used per request if required.
+    Cache plugin requires a storage backend to work with.
+    Storage class must implement this interface.
     """
 
-    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
+
+        # Multiple requests can flow over a single connection.
+        # Example, CONNECT followed by one or many HTTP requests.
+        #
+        # Scheme is stored as attribute as request
+        # object is not available across all lifecycle
+        # callbacks.
+        self.scheme: bytes = b'http'
 
     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
+        """Avoid connection with upstream server if cached response exists.
 
-        assert self.store
-        logger.info("Upstream connexion %s:%d %s" %
-                    (text_(request.host), request.port if request.port else 0, text_(request.path)))
+        Disabled for https request when running without TLS interception.
+        """
+        assert request.url is not None
+
+        # Store request scheme
+        self.scheme = request.url.scheme
 
-        if request.port == 443:
+        # Cache plugin is enabled for HTTPS request only when
+        # TLS interception is also enabled.
+        if self.scheme == b'https' and not self.flags.tls_interception_enabled():
             return request
 
-        try:
-            if self.store.is_cached(request):
-                return None
-        except Exception as e:
-            logger.info('Caching disabled due to exception message: %s', str(e))
+        # Cache plugin is a no-op for CONNECT requests
+        # i.e. we don't cache CONNECT responses.  However,
+        # we do skip upstream connection
+        #
+        # See https://github.com/abhinavsingh/proxy.py/issues/443
+        # if request.method == b'CONNECT':
+        #     return None
 
+        assert self.store
+        if self.store.is_cached(request):
+            return None
         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
-        logger.info("Client request %s:%d %s" %
-                    (text_(request.host), request.port if request.port else 0, text_(request.path)))
+        """If cached response exists, return response from cache."""
+        assert request.url is not None
 
-        if request.port == 443:
+        # Cache plugin is enabled for HTTPS request only when
+        # TLS interception is also enabled.
+        if request.url.scheme == b'https' and not self.flags.tls_interception_enabled():
             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))
-
+        assert self.store
+        if self.store.is_cached(request):
+            logger.info("Serving out of cache")
+            try:
+                self.store.open(request)
+                response = self.store.read_response(request)
+                self.client.queue(memoryview(response.build_response()))
+            finally:
+                self.store.close()
+            return None
+        # Request not cached, open store for writes
+        self.store.open(request)
         return request
 
     def handle_upstream_chunk(self, chunk: memoryview) -> memoryview:
-        assert self.__enabled is not None
-        if not self.__enabled:
+        if self.scheme == b'https' and not self.flags.tls_interception_enabled():
             return chunk
 
         assert self.store
-        return self.store.cache_response_chunk(chunk)
+        chunk = self.store.cache_response_chunk(chunk)
+        if chunk.tobytes().endswith(CRLF * 2):
+            self.store.close()
+        return chunk
 
     def on_upstream_connection_close(self) -> None:
-        assert self.__enabled is not None
-        if not self.__enabled:
+        if self.scheme == b'https' and not self.flags.tls_interception_enabled():
             return
 
         assert self.store
diff --git a/proxy/plugin/cache/cache_responses.py b/proxy/plugin/cache/cache_responses.py
index 0005195..f701246 100644
--- a/proxy/plugin/cache/cache_responses.py
+++ b/proxy/plugin/cache/cache_responses.py
@@ -8,7 +8,7 @@
     :copyright: (c) 2013-present by Abhinav Singh and contributors.
     :license: BSD, see LICENSE for more details.
 """
-import tempfile
+import os
 from typing import Any
 
 from .store.disk import OnDiskCacheStore
@@ -16,10 +16,19 @@ from .base import BaseCacheResponsesPlugin
 
 
 class CacheResponsesPlugin(BaseCacheResponsesPlugin):
-    """Caches response using OnDiskCacheStore."""
+    """Pluggable caches response plugin.
+
+    Defaults to OnDiskCacheStore.
+
+    Different storage backends may be used per request if required.
+    """
 
     def __init__(self, *args: Any, **kwargs: Any) -> None:
         super().__init__(*args, **kwargs)
         self.disk_store = OnDiskCacheStore(
-            uid=self.uid, cache_dir=tempfile.gettempdir())
+            uid=self.uid, cache_dir=self.cache_directory())
         self.set_store(self.disk_store)
+
+    def cache_directory(self) -> str:
+        """TODO(abhinavsingh): Turn this into a flag."""
+        return os.path.join(self.flags.proxy_py_data_dir, 'cache')
diff --git a/proxy/plugin/cache/store/base.py b/proxy/plugin/cache/store/base.py
index c1f3362..ab2140e 100644
--- a/proxy/plugin/cache/store/base.py
+++ b/proxy/plugin/cache/store/base.py
@@ -14,26 +14,37 @@ from ....http.parser import HttpParser
 
 
 class CacheStore(ABC):
+    """Cache storage backends must implement this interface."""
 
     def __init__(self, uid: UUID) -> None:
         self.uid = uid
 
     @abstractmethod
     def open(self, request: HttpParser) -> None:
-        pass
+        """Initialize resources to handle this request."""
+        pass    # pragma: no cover
 
     @abstractmethod
     def is_cached(self, request: HttpParser) -> bool:
-        pass
+        """Returns whether the request is already cached."""
+        pass    # pragma: no cover
 
     @abstractmethod
     def cache_request(self, request: HttpParser) -> HttpParser:
-        return request
+        """Cache the request."""
+        return request  # pragma: no cover
 
     @abstractmethod
     def cache_response_chunk(self, chunk: memoryview) -> memoryview:
-        return chunk
+        """Cache response chunks as they arrive."""
+        return chunk    # pragma: no cover
+
+    @abstractmethod
+    def read_response(self, request: HttpParser) -> HttpParser:
+        """Reads and return cached response from store."""
+        pass    # pragma: no cover
 
     @abstractmethod
     def close(self) -> None:
-        pass
+        """Close any open resources."""
+        pass    # pragma: no cover
diff --git a/proxy/plugin/cache/store/disk.py b/proxy/plugin/cache/store/disk.py
index 9323f92..9c6ac1c 100644
--- a/proxy/plugin/cache/store/disk.py
+++ b/proxy/plugin/cache/store/disk.py
@@ -10,13 +10,9 @@
 """
 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_
 from ....http.parser import HttpParser
 
 from .base import CacheStore
@@ -25,79 +21,43 @@ 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
         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:
-        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
-
-        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)
+        if not self.cache_file:
+            cache_file_path = self.get_cache_file_path(request)
+            logger.info('Opening cache file ' + cache_file_path)
+            self.cache_file = open(cache_file_path, 'ab+')
 
     def is_cached(self, request: HttpParser) -> bool:
-        return bool(self.get_cache_file_path(request))
+        return os.path.isfile(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:
-        if self.cache_file:
-            self.cache_file.write(chunk.tobytes())
+        assert self.cache_file is not None
+        self.cache_file.write(chunk.tobytes())
         return chunk
 
+    def read_response(self, request: HttpParser) -> HttpParser:
+        assert self.cache_file is not None
+        self.cache_file.seek(0)
+        return HttpParser.response(self.cache_file.read())
+
     def close(self) -> None:
         if self.cache_file:
-            logger.debug("Closing cache file")
+            logger.info('Closing cache file')
+            self.cache_file.flush()
             self.cache_file.close()
-            self.cache_list.flush()
+            self.cache_file = None
+
+    def get_cache_file_path(self, request: HttpParser) -> str:
+        return os.path.join(self.cache_dir, '.'.join(
+            [request.fingerprint(), 'cache']))


Test patch:
diff --git a/proxy/testing/__init__.py b/proxy/testing/__init__.py
index 232621f..61fd879 100644
--- a/proxy/testing/__init__.py
+++ b/proxy/testing/__init__.py
@@ -8,3 +8,12 @@
     :copyright: (c) 2013-present by Abhinav Singh and contributors.
     :license: BSD, see LICENSE for more details.
 """
+from .test_case import TestCase
+from .replay_test_case import ReplayTestCase
+
+__all__ = [
+    # Unit testing with proxy.py. See
+    # https://github.com/abhinavsingh/proxy.py#unit-testing-with-proxypy
+    'TestCase',
+    'ReplayTestCase',
+]
diff --git a/proxy/testing/base.py b/proxy/testing/base.py
new file mode 100644
index 0000000..a824cef
--- /dev/null
+++ b/proxy/testing/base.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+"""
+    proxy.py
+    ~~~~~~~~
+    ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on
+    Network monitoring, controls & Application development, testing, debugging.
+
+    :copyright: (c) 2013-present by Abhinav Singh and contributors.
+    :license: BSD, see LICENSE for more details.
+"""
+import time
+import unittest
+from typing import Optional, List, Any
+
+from ..proxy import Proxy
+from ..common.constants import DEFAULT_TIMEOUT
+from ..common.utils import new_socket_connection
+
+
+class BaseTestCase(unittest.TestCase):
+    """Base TestCase class that automatically setup and teardown proxy.py.
+
+    It doesn't define a setUpClass which MUST be provided by implementations.
+    """
+
+    DEFAULT_PROXY_PY_STARTUP_FLAGS = [
+        '--num-workers', '1',
+        '--threadless',
+    ]
+
+    PROXY_PORT: int = 8899
+    PROXY: Optional[Proxy] = None
+    INPUT_ARGS: Optional[List[str]] = None
+
+    @staticmethod
+    def wait_for_server(proxy_port: int,
+                        wait_for_seconds: int = DEFAULT_TIMEOUT) -> None:
+        """Wait for proxy.py server to come up."""
+        start_time = time.time()
+        while True:
+            try:
+                conn = new_socket_connection(
+                    ('localhost', proxy_port))
+                conn.close()
+                break
+            except ConnectionRefusedError:
+                time.sleep(0.1)
+
+            if time.time() - start_time > wait_for_seconds:
+                raise TimeoutError(
+                    'Timed out while waiting for proxy.py to start...')
+
+    @classmethod
+    def tearDownClass(cls) -> None:
+        assert cls.PROXY
+        cls.PROXY.__exit__(None, None, None)
+        cls.PROXY_PORT = 8899
+        cls.PROXY = None
+        cls.INPUT_ARGS = None
+
+    def run(self, result: Optional[unittest.TestResult] = None) -> Any:
+        super().run(result)
diff --git a/proxy/testing/replay_test_case.py b/proxy/testing/replay_test_case.py
new file mode 100644
index 0000000..1e24997
--- /dev/null
+++ b/proxy/testing/replay_test_case.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+"""
+    proxy.py
+    ~~~~~~~~
+    ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on
+    Network monitoring, controls & Application development, testing, debugging.
+
+    :copyright: (c) 2013-present by Abhinav Singh and contributors.
+    :license: BSD, see LICENSE for more details.
+"""
+from .base import BaseTestCase
+from ..proxy import Proxy
+from ..common.utils import get_available_port
+from ..plugin import CacheResponsesPlugin
+
+
+class ReplayTestCase(BaseTestCase):
+    """Base TestCase class that automatically setup and teardown proxy.py."""
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        cls.PROXY_PORT = get_available_port()
+
+        cls.INPUT_ARGS = getattr(cls, 'PROXY_PY_STARTUP_FLAGS') \
+            if hasattr(cls, 'PROXY_PY_STARTUP_FLAGS') \
+            else cls.DEFAULT_PROXY_PY_STARTUP_FLAGS
+        cls.INPUT_ARGS.append('--hostname')
+        cls.INPUT_ARGS.append('0.0.0.0')
+        cls.INPUT_ARGS.append('--port')
+        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.__enter__()
+        cls.wait_for_server(cls.PROXY_PORT)
diff --git a/proxy/testing/test_case.py b/proxy/testing/test_case.py
index 3acf850..5868c69 100644
--- a/proxy/testing/test_case.py
+++ b/proxy/testing/test_case.py
@@ -8,29 +8,14 @@
     :copyright: (c) 2013-present by Abhinav Singh and contributors.
     :license: BSD, see LICENSE for more details.
 """
-import contextlib
-import time
-import unittest
-from typing import Optional, List, Generator, Any
-
+from .base import BaseTestCase
 from ..proxy import Proxy
-from ..common.constants import DEFAULT_TIMEOUT
-from ..common.utils import get_available_port, new_socket_connection
-from ..plugin import CacheResponsesPlugin
+from ..common.utils import get_available_port
 
 
-class TestCase(unittest.TestCase):
+class TestCase(BaseTestCase):
     """Base TestCase class that automatically setup and teardown proxy.py."""
 
-    DEFAULT_PROXY_PY_STARTUP_FLAGS = [
-        '--num-workers', '1',
-        '--threadless',
-    ]
-
-    PROXY_PORT: int = 8899
-    PROXY: Optional[Proxy] = None
-    INPUT_ARGS: Optional[List[str]] = None
-
     @classmethod
     def setUpClass(cls) -> None:
         cls.PROXY_PORT = get_available_port()
@@ -44,45 +29,7 @@ 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)
-        CacheResponsesPlugin.enabled.clear()
+        cls.PROXY.flags.plugins[b'HttpProxyBasePlugin']
 
         cls.PROXY.__enter__()
         cls.wait_for_server(cls.PROXY_PORT)
-
-    @staticmethod
-    def wait_for_server(proxy_port: int,
-                        wait_for_seconds: int = DEFAULT_TIMEOUT) -> None:
-        """Wait for proxy.py server to come up."""
-        start_time = time.time()
-        while True:
-            try:
-                conn = new_socket_connection(
-                    ('localhost', proxy_port))
-                conn.close()
-                break
-            except ConnectionRefusedError:
-                time.sleep(0.1)
-
-            if time.time() - start_time > wait_for_seconds:
-                raise TimeoutError(
-                    'Timed out while waiting for proxy.py to start...')
-
-    @classmethod
-    def tearDownClass(cls) -> None:
-        assert cls.PROXY
-        cls.PROXY.__exit__(None, None, None)
-        cls.PROXY_PORT = 8899
-        cls.PROXY = None
-        cls.INPUT_ARGS = None
-
-    @contextlib.contextmanager
-    def vcr(self) -> Generator[None, None, None]:
-        try:
-            CacheResponsesPlugin.enabled.set()
-            yield
-        finally:
-            CacheResponsesPlugin.enabled.clear()
-
-    def run(self, result: Optional[unittest.TestResult] = None) -> Any:
-        super().run(result)
diff --git a/tests/http/test_http_parser.py b/tests/http/test_http_parser.py
index e8d78cb..ac09cf0 100644
--- a/tests/http/test_http_parser.py
+++ b/tests/http/test_http_parser.py
@@ -22,6 +22,13 @@ class TestHttpParser(unittest.TestCase):
     def setUp(self) -> None:
         self.parser = HttpParser(httpParserTypes.REQUEST_PARSER)
 
+    def test_finerprint(self) -> None:
+        self.parser.parse(b'CONNECT httpbin.org:443 HTTP/1.1\r\n')
+        self.assertEqual(self.parser.fingerprint(),
+                         'ec442a897c0657288580b4554859c282fadf4d1a78d03' +
+                         'ddce96468d23269d96060b74c714f5ceec6bfb01ea2c2' +
+                         'bc90f48b979842fc87f0675da98192ec73a0a0')
+
     def test_urlparse(self) -> None:
         self.parser.parse(b'CONNECT httpbin.org:443 HTTP/1.1\r\n')
         self.assertEqual(self.parser.host, b'httpbin.org')
diff --git a/tests/plugin/test_http_proxy_plugins.py b/tests/plugin/test_http_proxy_plugins.py
index 0464bcf..af6b7f7 100644
--- a/tests/plugin/test_http_proxy_plugins.py
+++ b/tests/plugin/test_http_proxy_plugins.py
@@ -8,9 +8,9 @@
     :copyright: (c) 2013-present by Abhinav Singh and contributors.
     :license: BSD, see LICENSE for more details.
 """
+from proxy.http.parser import HttpParser
 import unittest
 import selectors
-import tempfile
 import json
 import os
 from pathlib import Path
@@ -60,12 +60,11 @@ class TestHttpProxyPluginExamples(unittest.TestCase):
         self.protocol_handler.initialize()
 
     def tearDown(self) -> None:
-        tmpDir = Path(tempfile.gettempdir())
-        for f in tmpDir.glob('proxy-cache-*'):
+        # Delete cache plugin data
+        cacheDir = Path(os.path.join(self.flags.proxy_py_data_dir, 'cache'))
+        for f in cacheDir.glob('*'):
             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(
@@ -294,13 +293,15 @@ class TestHttpProxyPluginExamples(unittest.TestCase):
         )
 
     @mock.patch('proxy.http.proxy.server.TcpServerConnection')
-    def test_cache_responses_plugin_cache(self, mock_server_conn: mock.Mock) -> None:
-        request = build_http_request(
+    def test_cache_responses_plugin_cache(
+            self, mock_server_conn: mock.Mock) -> None:
+        request_bytes = build_http_request(
             b'GET', b'http://example.org/get',
             headers={
                 b'Host': b'example.org',
             }
         )
+        request = HttpParser.request(request_bytes)
         server_response = build_http_response(
             httpStatusCodes.OK,
             reason=b'OK',
@@ -351,7 +352,7 @@ class TestHttpProxyPluginExamples(unittest.TestCase):
         ]
 
         # Client read:
-        self._conn.recv.return_value = request
+        self._conn.recv.return_value = request_bytes
         self.protocol_handler.run_once()
         mock_server_conn.assert_called_with('example.org', DEFAULT_HTTP_PORT)
         server.connect.assert_called_once()
@@ -381,25 +382,22 @@ class TestHttpProxyPluginExamples(unittest.TestCase):
         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:
+        with open(os.path.join(
+                self.flags.proxy_py_data_dir,
+                'cache',
+                '.'.join([request.fingerprint(), 'cache'])), '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(
+    def test_cache_responses_plugin_load(
+            self, mock_server_conn: mock.Mock) -> None:
+        request_bytes = build_http_request(
             b'GET', b'http://example.org/get',
             headers={
                 b'Host': b'example.org',
             }
         )
+        request = HttpParser.request(request_bytes)
         cache_response = build_http_response(
             httpStatusCodes.OK,
             reason=b'OK',
@@ -407,10 +405,10 @@ class TestHttpProxyPluginExamples(unittest.TestCase):
         )
 
         # 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:
+        with open(os.path.join(
+                self.flags.proxy_py_data_dir,
+                'cache',
+                '.'.join([request.fingerprint(), 'cache'])), 'wb') as cache_file:
             cache_file.write(cache_response)
 
         # Setup server:
@@ -447,7 +445,7 @@ class TestHttpProxyPluginExamples(unittest.TestCase):
         ]
 
         # Client read:
-        self._conn.recv.return_value = request
+        self._conn.recv.return_value = request_bytes
         self.protocol_handler.run_once()
         mock_server_conn.assert_not_called()
 
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 127ad49..c9727cc 100644
--- a/tests/plugin/test_http_proxy_plugins_with_tls_interception.py
+++ b/tests/plugin/test_http_proxy_plugins_with_tls_interception.py
@@ -8,12 +8,11 @@
     :copyright: (c) 2013-present by Abhinav Singh and contributors.
     :license: BSD, see LICENSE for more details.
 """
+import os
 import unittest
 import socket
 import selectors
 import ssl
-import os
-import tempfile
 from pathlib import Path
 
 from unittest import mock
@@ -23,6 +22,7 @@ from proxy.common.utils import bytes_
 from proxy.common.flags import Flags
 from proxy.common.utils import build_http_request, build_http_response
 from proxy.core.connection import TcpClientConnection, TcpServerConnection
+# from proxy.http.parser import HttpParser
 from proxy.http.codes import httpStatusCodes
 from proxy.http.methods import httpMethods
 from proxy.http.handler import HttpProtocolHandler
@@ -171,12 +171,11 @@ 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-*'):
+        # Delete cache plugin data
+        cacheDir = Path(os.path.join(self.flags.proxy_py_data_dir, 'cache'))
+        for f in cacheDir.glob('*'):
             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"}'
@@ -233,57 +232,52 @@ class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase):
                 reason=b'OK', body=b'Hello from man in the middle')
         )
 
+
+"""
     def test_cache_responses_plugin_cache(self) -> None:
-        request = build_http_request(
+        request_bytes = build_http_request(
             b'GET', b'/get',
             headers={
                 b'Host': b'uni.corn',
             }
         )
-        server_response = build_http_response(
+        request = HttpParser.request(request_bytes)
+        server_response_bytes = 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.client_ssl_connection.recv.return_value = request_bytes
         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',
-            }
-        ))
+        self.server.queue.assert_called_once_with(request_bytes)
 
         # Server write:
         self.protocol_handler.run_once()
         self.server.flush.assert_called_once()
 
         # Server read:
-        self.server.recv.return_value = memoryview(server_response)
+        self.server.recv.return_value = memoryview(server_response_bytes)
         self.protocol_handler.run_once()
 
         # Client write:
-        self.client_ssl_connection.send.return_value = len(server_response)
+        self.client_ssl_connection.send.return_value = len(
+            server_response_bytes)
         self.protocol_handler.run_once()
-        self.client_ssl_connection.send.assert_called_once_with(server_response)
+        self.client_ssl_connection.send.assert_called_once_with(
+            server_response_bytes)
 
         # 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)
+        with open(os.path.join(
+                self.flags.proxy_py_data_dir,
+                'cache',
+                '.'.join([request.fingerprint(), 'cache'])), 'rb') as cache_file:
+            self.assertEqual(cache_file.read(), server_response_bytes)
 
     def test_cache_responses_plugin_load(self) -> None:
         request = build_http_request(
@@ -300,9 +294,9 @@ class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase):
 
         # Setup cache:
         cache_file_name = 'test'
-        with open(Path(tempfile.gettempdir()) / 'list.txt', 'wt') as cache_list:
+        with open(os.path.join(self.flags.proxy_py_data_dir, 'cache', '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:
+        with open(os.path.join(self.flags.proxy_py_data_dir, 'cache', 'proxy-cache-' + cache_file_name), 'wb') as cache_file:
             cache_file.write(cache_response)
 
         # Setup selector:
@@ -338,3 +332,4 @@ class TestHttpProxyPluginExamplesWithTlsInterception(unittest.TestCase):
         self.client_ssl_connection.recv.return_value = b''
         self.protocol_handler.run_once()
         self.protocol_handler.shutdown()
+"""
diff --git a/tests/testing/test_embed.py b/tests/testing/test_embed.py
index 9e89c28..82c67ab 100644
--- a/tests/testing/test_embed.py
+++ b/tests/testing/test_embed.py
@@ -9,17 +9,15 @@
     :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_TIMEOUT, DEFAULT_CLIENT_RECVBUF_SIZE, PROXY_AGENT_HEADER_VALUE
+from proxy.common.constants import DEFAULT_DATA_DIRECTORY_PATH
+from proxy.common.constants import 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
@@ -35,31 +33,25 @@ 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-*'):
+        # Delete cache plugin data
+        cacheDir = Path(os.path.join(DEFAULT_DATA_DIRECTORY_PATH, 'cache'))
+        for f in cacheDir.glob('*'):
             if f.is_file():
                 os.remove(f)
-        os.remove(tmpDir / 'list.txt')
+
+    def make_http_request_using_proxy(self) -> None:
+        proxy_handler = urllib.request.ProxyHandler({
+            'http': 'http://localhost:%d' % self.PROXY_PORT,
+        })
+        opener = urllib.request.build_opener(proxy_handler)
+        with self.assertRaises(urllib.error.HTTPError):
+            r: http.client.HTTPResponse = opener.open(
+                'http://localhost:%d/' %
+                self.PROXY_PORT, timeout=10)
+            self.assertEqual(r.status, 404)
+            self.assertEqual(r.headers.get('server'), PROXY_AGENT_HEADER_VALUE)
+            self.assertEqual(r.headers.get('connection'), b'close')
 
     def test_with_proxy(self) -> None:
         """Makes a HTTP request to in-build web server via proxy server."""
@@ -82,50 +74,3 @@ class TestProxyPyEmbedded(TestCase):
                 }
             )
         )
-
-    def test_proxy_vcr(self) -> None:
-        """With VCR enabled, proxy.py will cache responses for all HTTP(s)
-        requests made during the test.  When test is re-run, until explicitly
-        disabled, proxy.py will replay responses from cache avoiding calls to
-        upstream servers.
-
-        This feature only works iff proxy.py is used as a proxy server
-        for all HTTP(s) requests made during the test.
-
-        Below we make a HTTP GET request using Python's urllib library."""
-        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,
-        })
-        opener = urllib.request.build_opener(proxy_handler)
-        with self.assertRaises(urllib.error.HTTPError):
-            r: http.client.HTTPResponse = opener.open(
-                'http://localhost:%d/' %
-                self.PROXY_PORT, timeout=10)
-            self.assertEqual(r.status, 404)
-            self.assertEqual(r.headers.get('server'), PROXY_AGENT_HEADER_VALUE)
-            self.assertEqual(r.headers.get('connection'), b'close')
diff --git a/tests/testing/test_replay.py b/tests/testing/test_replay.py
new file mode 100644
index 0000000..81a306b
--- /dev/null
+++ b/tests/testing/test_replay.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+"""
+    proxy.py
+    ~~~~~~~~
+    ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on
+    Network monitoring, controls & Application development, testing, debugging.
+
+    :copyright: (c) 2013-present by Abhinav Singh and contributors.
+    :license: BSD, see LICENSE for more details.
+"""
+import os
+import sys
+import unittest
+import http.client
+import urllib.request
+import urllib.error
+
+from proxy import ReplayTestCase
+from proxy.common.constants import DEFAULT_DATA_DIRECTORY_PATH, PROXY_AGENT_HEADER_VALUE
+from proxy.common.utils import build_http_response
+from proxy.http.parser import HttpParser
+from proxy.http.codes import httpStatusCodes
+
+
+@unittest.skipIf(
+    os.name == 'nt', 'Disabled for Windows due to weird permission issues.')
+class TestReplayTestCase(ReplayTestCase):
+
+    PROXY_PY_STARTUP_FLAGS = ReplayTestCase.DEFAULT_PROXY_PY_STARTUP_FLAGS + [
+        '--enable-web-server',
+    ]
+
+    def tearDown(self) -> None:
+        # Delete cache plugin data
+        """cacheDir = Path(os.path.join(DEFAULT_DATA_DIRECTORY_PATH, 'cache'))
+        for f in cacheDir.glob('*'):
+            if f.is_file():
+                os.remove(f)"""
+
+    def make_http_request_using_proxy(self) -> None:
+        proxy_handler = urllib.request.ProxyHandler({
+            'http': 'http://localhost:%d' % self.PROXY_PORT,
+        })
+        opener = urllib.request.build_opener(proxy_handler)
+        with self.assertRaises(urllib.error.HTTPError):
+            r: http.client.HTTPResponse = opener.open(
+                'http://localhost:%d/' % self.PROXY_PORT,
+                timeout=10)
+            self.assertEqual(r.status, 404)
+            self.assertEqual(r.headers.get('server'), PROXY_AGENT_HEADER_VALUE)
+            self.assertEqual(r.headers.get('connection'), b'close')
+
+    def test_proxy_replay(self) -> None:
+        """With replay enabled, proxy.py will cache responses for all HTTP(s)
+        requests made during the test.  When test is re-run, until explicitly
+        disabled, proxy.py will replay responses from cache avoiding calls to
+        upstream servers.
+
+        This feature only works iff proxy.py is used as a proxy server
+        for all HTTP(s) requests made during the test.
+
+        Below we make a HTTP GET request using Python's urllib library."""
+        self.make_http_request_using_proxy()
+
+        # This is roughly the request made by urllib
+        request = HttpParser.request(
+            b'GET http://localhost:%d/ HTTP/1.1\r\n' % self.PROXY_PORT +
+            b'Accept-Encoding: identity\r\n' +
+            b'Host: localhost:%d\r\n' % self.PROXY_PORT +
+            b'User-Agent: Python-urllib/%d.%d\r\n' % (sys.version_info[0], sys.version_info[1]) +
+            b'Connection: close\r\n\r\n')
+
+        cache_file_path = os.path.join(
+            DEFAULT_DATA_DIRECTORY_PATH,
+            'cache',
+            '.'.join([request.fingerprint(), 'cache']))
+        self.assertTrue(os.path.isfile(cache_file_path))
+        with open(cache_file_path, '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'
+                }
+            ))