Repository: kevin1024/vcrpy (python)

Issue:
Newline at the end of JSON files
Cassettes created as JSON do not have a newline at the end. Right now, I manually add it before commiting.

Pull request:
fix #429: include a trailing newline in the JSON cassette format
It is a common convention for text files (esp. in Linux) to end with a newline.

Patch:
diff --git a/.travis.yml b/.travis.yml
index f1e02ef..0e685fb 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -12,6 +12,7 @@ env:
     - TOX_SUFFIX="urllib3"
     - TOX_SUFFIX="tornado4"
     - TOX_SUFFIX="aiohttp"
+    - TOX_SUFFIX="httpx"
 matrix:
   include:
     # Only run lint on a single 3.x
diff --git a/README.rst b/README.rst
index f4c3b14..d8944da 100644
--- a/README.rst
+++ b/README.rst
@@ -47,6 +47,11 @@ VCR.py will detect the absence of a cassette file and once again record
 all HTTP interactions, which will update them to correspond to the new
 API.
 
+Usage with Pytest
+-----------------
+
+There is a library to provide some pytest fixtures called pytest-recording https://github.com/kiwicom/pytest-recording
+
 License
 -------
 
diff --git a/docs/advanced.rst b/docs/advanced.rst
index 5536055..b16710b 100644
--- a/docs/advanced.rst
+++ b/docs/advanced.rst
@@ -33,6 +33,8 @@ consider part of the API. The fields are as follows:
    been played back.
 -  ``responses_of(request)``: Access the responses that match a given
    request
+-  ``allow_playback_repeats``: A boolean indicating whether responses
+   can be played back more than once.
 
 The ``Request`` object has the following properties:
 
@@ -386,3 +388,19 @@ VCR.py allows to rewind a cassette in order to replay it inside the same functio
         assert cass.all_played
         cass.rewind()
         assert not cass.all_played
+
+Playback Repeats
+----------------
+
+By default, each response in a cassette can only be matched and played back
+once while the cassette is in use, unless the cassette is rewound.
+
+If you want to allow playback repeats without rewinding the cassette, use
+the Cassette ``allow_playback_repeats`` option.
+
+.. code:: python
+
+    with vcr.use_cassette('fixtures/vcr_cassettes/synopsis.yaml', allow_playback_repeats=True) as cass:
+        for x in range(10):
+            response = urllib2.urlopen('http://www.zombo.com/').read()
+        assert cass.all_played
diff --git a/docs/changelog.rst b/docs/changelog.rst
index f149199..2d14fa9 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -7,8 +7,13 @@ For a full list of triaged issues, bugs and PRs and what release they are target
 
 All help in providing PRs to close out bug issues is appreciated. Even if that is providing a repo that fully replicates issues. We have very generous contributors that have added these to bug issues which meant another contributor picked up the bug and closed it out.
 
--  UNRELEASED
-    - ...
+-  4.1.0
+    - Add support for httpx!! (thanks @herdigiorgi)
+    - Add the new `allow_playback_repeats` option (thanks @tysonholub)
+    - Several aiohttp improvements (cookie support, multiple headers with same key) (Thanks @pauloromeira)
+    - Use enums for record modes (thanks @aaronbannin)
+    - Bugfix: Do not redirect on 304 in aiohttp (Thanks @royjs)
+    - Bugfix: Fix test suite by switching to mockbin (thanks @jairhenrique)
 -  4.0.2
     - Fix mock imports as reported in #504 by @llybin. Thank you.
 -  4.0.1 
diff --git a/docs/installation.rst b/docs/installation.rst
index abc417a..113d8e0 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -22,6 +22,7 @@ The following HTTP libraries are supported:
 -  ``tornado.httpclient``
 -  ``urllib2``
 -  ``urllib3``
+-  ``httpx``
 
 Speed
 -----
diff --git a/tox.ini b/tox.ini
index 20c4b73..f30a050 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,9 +1,10 @@
 [tox]
 skip_missing_interpreters=true
-envlist = 
+envlist =
   cov-clean,
   lint,
   {py35,py36,py37,py38}-{requests,httplib2,urllib3,tornado4,boto3,aiohttp},
+  {py36,py37,py38}-{httpx}
   {pypy3}-{requests,httplib2,urllib3,tornado4,boto3},
   cov-report
 
@@ -18,7 +19,7 @@ commands = coverage erase
 [testenv:cov-report]
 deps = coverage
 skip_install=true
-commands = 
+commands =
   coverage html
   coverage report --fail-under=90
 
@@ -30,8 +31,8 @@ commands =
     flake8 --version
     flake8 --exclude=./docs/conf.py,./.tox/
     pyflakes ./docs/conf.py
-deps = 
-  flake8 
+deps =
+  flake8
   black
 
 [testenv:docs]
@@ -79,8 +80,10 @@ deps =
     aiohttp: aiohttp
     aiohttp: pytest-asyncio
     aiohttp: pytest-aiohttp
-depends = 
-  lint,{py35,py36,py37,py38,pypy3}-{requests,httplib2,urllib3,tornado4,boto3},{py35,py36,py37,py38}-{aiohttp}: cov-clean
+    {py36,py37,py38}-{httpx}: httpx
+    {py36,py37,py38}-{httpx}: pytest-asyncio
+depends =
+  lint,{py35,py36,py37,py38,pypy3}-{requests,httplib2,urllib3,tornado4,boto3},{py35,py36,py37,py38}-{aiohttp},{py36,py37,py38}-{httpx}: cov-clean
   cov-report: lint,{py35,py36,py37,py38,pypy3}-{requests,httplib2,urllib3,tornado4,boto3},{py35,py36,py37,py38}-{aiohttp}
 passenv =
     AWS_ACCESS_KEY_ID
diff --git a/vcr/__init__.py b/vcr/__init__.py
index 070d3e9..9676b84 100644
--- a/vcr/__init__.py
+++ b/vcr/__init__.py
@@ -1,8 +1,9 @@
 import logging
 from .config import VCR
 from logging import NullHandler
+from .record_mode import RecordMode as mode  # noqa import is not used in this file
 
-__version__ = "4.0.2"
+__version__ = "4.1.0"
 
 logging.getLogger(__name__).addHandler(NullHandler())
 
diff --git a/vcr/cassette.py b/vcr/cassette.py
index 662c2d2..9122b4a 100644
--- a/vcr/cassette.py
+++ b/vcr/cassette.py
@@ -14,6 +14,7 @@ from .serializers import yamlserializer
 from .persisters.filesystem import FilesystemPersister
 from .util import partition_dict
 from ._handle_coroutine import handle_coroutine
+from .record_mode import RecordMode
 
 try:
     from asyncio import iscoroutinefunction
@@ -175,12 +176,13 @@ class Cassette:
         path,
         serializer=None,
         persister=None,
-        record_mode="once",
+        record_mode=RecordMode.ONCE,
         match_on=(uri, method),
         before_record_request=None,
         before_record_response=None,
         custom_patches=(),
         inject=False,
+        allow_playback_repeats=False,
     ):
         self._persister = persister or FilesystemPersister
         self._path = path
@@ -192,6 +194,7 @@ class Cassette:
         self.inject = inject
         self.record_mode = record_mode
         self.custom_patches = custom_patches
+        self.allow_playback_repeats = allow_playback_repeats
 
         # self.data is the list of (req, resp) tuples
         self.data = []
@@ -206,7 +209,7 @@ class Cassette:
     @property
     def all_played(self):
         """Returns True if all responses have been played, False otherwise."""
-        return self.play_count == len(self)
+        return len(self.play_counts.values()) == len(self)
 
     @property
     def requests(self):
@@ -218,7 +221,7 @@ class Cassette:
 
     @property
     def write_protected(self):
-        return self.rewound and self.record_mode == "once" or self.record_mode == "none"
+        return self.rewound and self.record_mode == RecordMode.ONCE or self.record_mode == RecordMode.NONE
 
     def append(self, request, response):
         """Add a request, response pair to this cassette"""
@@ -250,7 +253,7 @@ class Cassette:
 
     def can_play_response_for(self, request):
         request = self._before_record_request(request)
-        return request and request in self and self.record_mode != "all" and self.rewound
+        return request and request in self and self.record_mode != RecordMode.ALL and self.rewound
 
     def play_response(self, request):
         """
@@ -258,7 +261,7 @@ class Cassette:
         hasn't been played back before, and mark it as played
         """
         for index, response in self._responses(request):
-            if self.play_counts[index] == 0:
+            if self.play_counts[index] == 0 or self.allow_playback_repeats:
                 self.play_counts[index] += 1
                 return response
         # The cassette doesn't contain the request asked for.
@@ -348,6 +351,6 @@ class Cassette:
     def __contains__(self, request):
         """Return whether or not a request has been stored"""
         for index, response in self._responses(request):
-            if self.play_counts[index] == 0:
+            if self.play_counts[index] == 0 or self.allow_playback_repeats:
                 return True
         return False
diff --git a/vcr/config.py b/vcr/config.py
index ce41978..e95908c 100644
--- a/vcr/config.py
+++ b/vcr/config.py
@@ -12,6 +12,7 @@ from .cassette import Cassette
 from .serializers import yamlserializer, jsonserializer
 from .persisters.filesystem import FilesystemPersister
 from .util import compose, auto_decorate
+from .record_mode import RecordMode
 from . import matchers
 from . import filters
 
@@ -37,7 +38,7 @@ class VCR:
         custom_patches=(),
         filter_query_parameters=(),
         ignore_hosts=(),
-        record_mode="once",
+        record_mode=RecordMode.ONCE,
         ignore_localhost=False,
         filter_headers=(),
         before_record_response=None,
@@ -148,6 +149,7 @@ class VCR:
             "inject": kwargs.get("inject_cassette", self.inject_cassette),
             "path_transformer": path_transformer,
             "func_path_generator": func_path_generator,
+            "allow_playback_repeats": kwargs.get("allow_playback_repeats", False),
         }
         path = kwargs.get("path")
         if path:
diff --git a/vcr/patch.py b/vcr/patch.py
index 3b30407..a3683fb 100644
--- a/vcr/patch.py
+++ b/vcr/patch.py
@@ -94,6 +94,15 @@ else:
     _AiohttpClientSessionRequest = aiohttp.client.ClientSession._request
 
 
+try:
+    import httpx
+except ImportError:  # pragma: no cover
+    pass
+else:
+    _HttpxSyncClient_send = httpx.Client.send
+    _HttpxAsyncClient_send = httpx.AsyncClient.send
+
+
 class CassettePatcherBuilder:
     def _build_patchers_from_mock_triples_decorator(function):
         @functools.wraps(function)
@@ -116,6 +125,7 @@ class CassettePatcherBuilder:
             self._boto(),
             self._tornado(),
             self._aiohttp(),
+            self._httpx(),
             self._build_patchers_from_mock_triples(self._cassette.custom_patches),
         )
 
@@ -313,6 +323,21 @@ class CassettePatcherBuilder:
             new_request = vcr_request(self._cassette, _AiohttpClientSessionRequest)
             yield client.ClientSession, "_request", new_request
 
+    @_build_patchers_from_mock_triples_decorator
+    def _httpx(self):
+        try:
+            import httpx
+        except ImportError:  # pragma: no cover
+            return
+        else:
+            from .stubs.httpx_stubs import async_vcr_send, sync_vcr_send
+
+            new_async_client_send = async_vcr_send(self._cassette, _HttpxAsyncClient_send)
+            yield httpx.AsyncClient, "send", new_async_client_send
+
+            new_sync_client_send = sync_vcr_send(self._cassette, _HttpxSyncClient_send)
+            yield httpx.Client, "send", new_sync_client_send
+
     def _urllib3_patchers(self, cpool, stubs):
         http_connection_remover = ConnectionRemover(
             self._get_cassette_subclass(stubs.VCRRequestsHTTPConnection)
diff --git a/vcr/record_mode.py b/vcr/record_mode.py
new file mode 100644
index 0000000..c25cf14
--- /dev/null
+++ b/vcr/record_mode.py
@@ -0,0 +1,23 @@
+from enum import Enum
+
+
+class RecordMode(str, Enum):
+    """
+    Configues when VCR will record to the cassette.
+
+    Can be declared by either using the enumerated value (`vcr.mode.ONCE`)
+    or by simply using the defined string (`once`).
+
+    `ALL`: Every request is recorded.
+    `ANY`: ?
+    `NEW_EPISODES`: Any request not found in the cassette is recorded.
+    `NONE`: No requests are recorded.
+    `ONCE`:  First set of requests is recorded, all others are replayed.
+    Attempting to add a new episode fails.
+    """
+
+    ALL = "all"
+    ANY = "any"
+    NEW_EPISODES = "new_episodes"
+    NONE = "none"
+    ONCE = "once"
diff --git a/vcr/serializers/jsonserializer.py b/vcr/serializers/jsonserializer.py
index e5ff85a..5ffef3e 100644
--- a/vcr/serializers/jsonserializer.py
+++ b/vcr/serializers/jsonserializer.py
@@ -16,7 +16,7 @@ def serialize(cassette_dict):
     )
 
     try:
-        return json.dumps(cassette_dict, indent=4)
+        return json.dumps(cassette_dict, indent=4) + "\n"
     except UnicodeDecodeError as original:  # py2
         raise UnicodeDecodeError(
             original.encoding,
diff --git a/vcr/stubs/aiohttp_stubs/__init__.py b/vcr/stubs/aiohttp_stubs.py
similarity index 64%
rename from vcr/stubs/aiohttp_stubs/__init__.py
rename to vcr/stubs/aiohttp_stubs.py
index 9bf1313..064ac0f 100644
--- a/vcr/stubs/aiohttp_stubs/__init__.py
+++ b/vcr/stubs/aiohttp_stubs.py
@@ -5,9 +5,13 @@ import logging
 import json
 
 from aiohttp import ClientConnectionError, ClientResponse, RequestInfo, streams
+from aiohttp import hdrs, CookieJar
+from http.cookies import CookieError, Morsel, SimpleCookie
+from aiohttp.helpers import strip_auth_from_url
 from multidict import CIMultiDict, CIMultiDictProxy
 from yarl import URL
 
+from vcr.errors import CannotOverwriteExistingCassetteException
 from vcr.request import Request
 
 log = logging.getLogger(__name__)
@@ -59,15 +63,27 @@ def build_response(vcr_request, vcr_response, history):
     request_info = RequestInfo(
         url=URL(vcr_request.url),
         method=vcr_request.method,
-        headers=CIMultiDictProxy(CIMultiDict(vcr_request.headers)),
+        headers=_deserialize_headers(vcr_request.headers),
         real_url=URL(vcr_request.url),
     )
     response = MockClientResponse(vcr_request.method, URL(vcr_response.get("url")), request_info=request_info)
     response.status = vcr_response["status"]["code"]
     response._body = vcr_response["body"].get("string", b"")
     response.reason = vcr_response["status"]["message"]
-    response._headers = CIMultiDictProxy(CIMultiDict(vcr_response["headers"]))
+    response._headers = _deserialize_headers(vcr_response["headers"])
     response._history = tuple(history)
+    # cookies
+    for hdr in response.headers.getall(hdrs.SET_COOKIE, ()):
+        try:
+            cookies = SimpleCookie(hdr)
+            for cookie_name, cookie in cookies.items():
+                expires = cookie.get("expires", "").strip()
+                if expires:
+                    log.debug('Ignoring expiration date: %s="%s"', cookie_name, expires)
+                cookie["expires"] = ""
+                response.cookies.load(cookie.output(header="").strip())
+        except CookieError as exc:
+            log.warning("Can not load response cookies: %s", exc)
 
     response.close()
     return response
@@ -81,7 +97,23 @@ def _serialize_headers(headers):
     """
     # Mark strings as keys so 'istr' types don't show up in
     # the cassettes as comments.
-    return {str(k): v for k, v in headers.items()}
+    serialized_headers = {}
+    for k, v in headers.items():
+        serialized_headers.setdefault(str(k), []).append(v)
+
+    return serialized_headers
+
+
+def _deserialize_headers(headers):
+    deserialized_headers = CIMultiDict()
+    for k, vs in headers.items():
+        if isinstance(vs, list):
+            for v in vs:
+                deserialized_headers.add(k, v)
+        else:
+            deserialized_headers.add(k, vs)
+
+    return CIMultiDictProxy(deserialized_headers)
 
 
 def play_responses(cassette, vcr_request):
@@ -92,14 +124,20 @@ def play_responses(cassette, vcr_request):
     # If we're following redirects, continue playing until we reach
     # our final destination.
     while 300 <= response.status <= 399:
-        next_url = URL(response.url).with_path(response.headers["location"])
+        if "location" not in response.headers:
+            break
+
+        next_url = URL(response.url).join(URL(response.headers["location"]))
 
         # Make a stub VCR request that we can then use to look up the recorded
         # VCR request saved to the cassette. This feels a little hacky and
         # may have edge cases based on the headers we're providing (e.g. if
         # there's a matcher that is used to filter by headers).
         vcr_request = Request("GET", str(next_url), None, _serialize_headers(response.request_info.headers))
-        vcr_request = cassette.find_requests_with_most_matches(vcr_request)[0][0]
+        vcr_requests = cassette.find_requests_with_most_matches(vcr_request)
+        for vcr_request, *_ in vcr_requests:
+            if cassette.can_play_response_for(vcr_request):
+                break
 
         # Tack on the response we saw from the redirect into the history
         # list that is added on to the final response.
@@ -163,6 +201,33 @@ async def record_responses(cassette, vcr_request, response):
     await record_response(cassette, vcr_request, response)
 
 
+def _build_cookie_header(session, cookies, cookie_header, url):
+    url, _ = strip_auth_from_url(url)
+    all_cookies = session._cookie_jar.filter_cookies(url)
+    if cookies is not None:
+        tmp_cookie_jar = CookieJar()
+        tmp_cookie_jar.update_cookies(cookies)
+        req_cookies = tmp_cookie_jar.filter_cookies(url)
+        if req_cookies:
+            all_cookies.load(req_cookies)
+
+    if not all_cookies and not cookie_header:
+        return None
+
+    c = SimpleCookie()
+    if cookie_header:
+        c.load(cookie_header)
+    for name, value in all_cookies.items():
+        if isinstance(value, Morsel):
+            mrsl_val = value.get(value.key, Morsel())
+            mrsl_val.set(value.key, value.value, value.coded_value)
+            c[name] = mrsl_val
+        else:
+            c[name] = value
+
+    return c.output(header="", sep=";").strip()
+
+
 def vcr_request(cassette, real_request):
     @functools.wraps(real_request)
     async def new_request(self, method, url, **kwargs):
@@ -171,6 +236,7 @@ def vcr_request(cassette, real_request):
         headers = self._prepare_headers(headers)
         data = kwargs.get("data", kwargs.get("json"))
         params = kwargs.get("params")
+        cookies = kwargs.get("cookies")
 
         if auth is not None:
             headers["AUTHORIZATION"] = auth.encode()
@@ -181,22 +247,23 @@ def vcr_request(cassette, real_request):
                 params[k] = str(v)
             request_url = URL(url).with_query(params)
 
-        vcr_request = Request(method, str(request_url), data, headers)
+        c_header = headers.pop(hdrs.COOKIE, None)
+        cookie_header = _build_cookie_header(self, cookies, c_header, request_url)
+        if cookie_header:
+            headers[hdrs.COOKIE] = cookie_header
+
+        vcr_request = Request(method, str(request_url), data, _serialize_headers(headers))
 
         if cassette.can_play_response_for(vcr_request):
-            return play_responses(cassette, vcr_request)
+            log.info("Playing response for {} from cassette".format(vcr_request))
+            response = play_responses(cassette, vcr_request)
+            for redirect in response.history:
+                self._cookie_jar.update_cookies(redirect.cookies, redirect.url)
+            self._cookie_jar.update_cookies(response.cookies, response.url)
+            return response
 
         if cassette.write_protected and cassette.filter_request(vcr_request):
-            response = MockClientResponse(method, URL(url))
-            response.status = 599
-            msg = (
-                "No match for the request {!r} was found. Can't overwrite "
-                "existing cassette {!r} in your current record mode {!r}."
-            )
-            msg = msg.format(vcr_request, cassette._path, cassette.record_mode)
-            response._body = msg.encode()
-            response.close()
-            return response
+            raise CannotOverwriteExistingCassetteException(cassette=cassette, failed_request=vcr_request)
 
         log.info("%s not in cassette, sending to real server", vcr_request)
 
diff --git a/vcr/stubs/httpx_stubs.py b/vcr/stubs/httpx_stubs.py
new file mode 100644
index 0000000..a22502c
--- /dev/null
+++ b/vcr/stubs/httpx_stubs.py
@@ -0,0 +1,159 @@
+import functools
+import logging
+from unittest.mock import patch, MagicMock
+
+import httpx
+from vcr.request import Request as VcrRequest
+from vcr.errors import CannotOverwriteExistingCassetteException
+
+
+_logger = logging.getLogger(__name__)
+
+
+def _transform_headers(httpx_reponse):
+    """
+    Some headers can appear multiple times, like "Set-Cookie".
+    Therefore transform to every header key to list of values.
+    """
+
+    out = {}
+    for key, var in httpx_reponse.headers.raw:
+        decoded_key = key.decode("utf-8")
+        out.setdefault(decoded_key, [])
+        out[decoded_key].append(var.decode("utf-8"))
+    return out
+
+
+def _to_serialized_response(httpx_reponse):
+    return {
+        "status_code": httpx_reponse.status_code,
+        "http_version": httpx_reponse.http_version,
+        "headers": _transform_headers(httpx_reponse),
+        "content": httpx_reponse.content.decode("utf-8", "ignore"),
+    }
+
+
+def _from_serialized_headers(headers):
+    """
+    httpx accepts headers as list of tuples of header key and value.
+    """
+
+    header_list = []
+    for key, values in headers.items():
+        for v in values:
+            header_list.append((key, v))
+    return header_list
+
+
+@patch("httpx.Response.close", MagicMock())
+@patch("httpx.Response.read", MagicMock())
+def _from_serialized_response(request, serialized_response, history=None):
+    content = serialized_response.get("content").encode()
+    response = httpx.Response(
+        status_code=serialized_response.get("status_code"),
+        request=request,
+        http_version=serialized_response.get("http_version"),
+        headers=_from_serialized_headers(serialized_response.get("headers")),
+        content=content,
+        history=history or [],
+    )
+    response._content = content
+    return response
+
+
+def _make_vcr_request(httpx_request, **kwargs):
+    body = httpx_request.read().decode("utf-8")
+    uri = str(httpx_request.url)
+    headers = dict(httpx_request.headers)
+    return VcrRequest(httpx_request.method, uri, body, headers)
+
+
+def _shared_vcr_send(cassette, real_send, *args, **kwargs):
+    real_request = args[1]
+
+    vcr_request = _make_vcr_request(real_request, **kwargs)
+
+    if cassette.can_play_response_for(vcr_request):
+        return vcr_request, _play_responses(cassette, real_request, vcr_request, args[0], kwargs)
+
+    if cassette.write_protected and cassette.filter_request(vcr_request):
+        raise CannotOverwriteExistingCassetteException(cassette=cassette, failed_request=vcr_request)
+
+    _logger.info("%s not in cassette, sending to real server", vcr_request)
+    return vcr_request, None
+
+
+def _record_responses(cassette, vcr_request, real_response):
+    for past_real_response in real_response.history:
+        past_vcr_request = _make_vcr_request(past_real_response.request)
+        cassette.append(past_vcr_request, _to_serialized_response(past_real_response))
+
+    if real_response.history:
+        # If there was a redirection keep we want the request which will hold the
+        # final redirect value
+        vcr_request = _make_vcr_request(real_response.request)
+
+    cassette.append(vcr_request, _to_serialized_response(real_response))
+    return real_response
+
+
+def _play_responses(cassette, request, vcr_request, client, kwargs):
+    history = []
+    allow_redirects = kwargs.get("allow_redirects", True)
+    vcr_response = cassette.play_response(vcr_request)
+    response = _from_serialized_response(request, vcr_response)
+
+    while allow_redirects and 300 <= response.status_code <= 399:
+        next_url = response.headers.get("location")
+        if not next_url:
+            break
+
+        vcr_request = VcrRequest("GET", next_url, None, dict(response.headers))
+        vcr_request = cassette.find_requests_with_most_matches(vcr_request)[0][0]
+
+        history.append(response)
+        # add cookies from response to session cookie store
+        client.cookies.extract_cookies(response)
+
+        vcr_response = cassette.play_response(vcr_request)
+        response = _from_serialized_response(vcr_request, vcr_response, history)
+
+    return response
+
+
+async def _async_vcr_send(cassette, real_send, *args, **kwargs):
+    vcr_request, response = _shared_vcr_send(cassette, real_send, *args, **kwargs)
+    if response:
+        # add cookies from response to session cookie store
+        args[0].cookies.extract_cookies(response)
+        return response
+
+    real_response = await real_send(*args, **kwargs)
+    return _record_responses(cassette, vcr_request, real_response)
+
+
+def async_vcr_send(cassette, real_send):
+    @functools.wraps(real_send)
+    def _inner_send(*args, **kwargs):
+        return _async_vcr_send(cassette, real_send, *args, **kwargs)
+
+    return _inner_send
+
+
+def _sync_vcr_send(cassette, real_send, *args, **kwargs):
+    vcr_request, response = _shared_vcr_send(cassette, real_send, *args, **kwargs)
+    if response:
+        # add cookies from response to session cookie store
+        args[0].cookies.extract_cookies(response)
+        return response
+
+    real_response = real_send(*args, **kwargs)
+    return _record_responses(cassette, vcr_request, real_response)
+
+
+def sync_vcr_send(cassette, real_send):
+    @functools.wraps(real_send)
+    def _inner_send(*args, **kwargs):
+        return _sync_vcr_send(cassette, real_send, *args, **kwargs)
+
+    return _inner_send


Test patch:
diff --git a/tests/integration/cassettes/test_httpx_test_test_behind_proxy.yml b/tests/integration/cassettes/test_httpx_test_test_behind_proxy.yml
new file mode 100644
index 0000000..355ebeb
--- /dev/null
+++ b/tests/integration/cassettes/test_httpx_test_test_behind_proxy.yml
@@ -0,0 +1,41 @@
+interactions:
+- request:
+    body: ''
+    headers:
+      accept:
+      - '*/*'
+      accept-encoding:
+      - gzip, deflate, br
+      connection:
+      - keep-alive
+      host:
+      - httpbin.org
+      user-agent:
+      - python-httpx/0.12.1
+    method: GET
+    uri: https://httpbin.org/headers
+  response:
+    content: "{\n  \"headers\": {\n    \"Accept\": \"*/*\", \n    \"Accept-Encoding\"\
+      : \"gzip, deflate, br\", \n    \"Host\": \"httpbin.org\", \n    \"User-Agent\"\
+      : \"python-httpx/0.12.1\", \n    \"X-Amzn-Trace-Id\": \"Root=1-5ea778c9-ea76170da792abdbf7614067\"\
+      \n  }\n}\n"
+    headers:
+      access-control-allow-credentials:
+      - 'true'
+      access-control-allow-origin:
+      - '*'
+      connection:
+      - keep-alive
+      content-length:
+      - '226'
+      content-type:
+      - application/json
+      date:
+      - Tue, 28 Apr 2020 00:28:57 GMT
+      server:
+      - gunicorn/19.9.0
+      via:
+      - my_own_proxy
+    http_version: HTTP/1.1
+    status_code: 200
+version: 1
diff --git a/tests/integration/test_aiohttp.py b/tests/integration/test_aiohttp.py
index 6508c48..d0ce7bb 100644
--- a/tests/integration/test_aiohttp.py
+++ b/tests/integration/test_aiohttp.py
@@ -1,5 +1,6 @@
 import contextlib
 import logging
+import urllib.parse
 
 import pytest
 
@@ -179,9 +180,8 @@ def test_params_same_url_distinct_params(tmpdir, scheme):
 
     other_params = {"other": "params"}
     with vcr.use_cassette(str(tmpdir.join("get.yaml"))) as cassette:
-        response, cassette_response_text = get(url, output="text", params=other_params)
-        assert "No match for the request" in cassette_response_text
-        assert response.status == 599
+        with pytest.raises(vcr.errors.CannotOverwriteExistingCassetteException):
+            get(url, output="text", params=other_params)
 
 
 def test_params_on_url(tmpdir, scheme):
@@ -250,7 +250,7 @@ def test_aiohttp_test_client_json(aiohttp_client, tmpdir):
 
 
 def test_redirect(aiohttp_client, tmpdir):
-    url = "https://httpbin.org/redirect/2"
+    url = "https://mockbin.org/redirect/302/2"
 
     with vcr.use_cassette(str(tmpdir.join("redirect.yaml"))):
         response, _ = get(url)
@@ -273,6 +273,23 @@ def test_redirect(aiohttp_client, tmpdir):
     assert cassette_response.request_info.real_url == response.request_info.real_url
 
 
+def test_not_modified(aiohttp_client, tmpdir):
+    """It doesn't try to redirect on 304"""
+    url = "https://httpbin.org/status/304"
+
+    with vcr.use_cassette(str(tmpdir.join("not_modified.yaml"))):
+        response, _ = get(url)
+
+    with vcr.use_cassette(str(tmpdir.join("not_modified.yaml"))) as cassette:
+        cassette_response, _ = get(url)
+
+        assert cassette_response.status == 304
+        assert response.status == 304
+        assert len(cassette_response.history) == len(response.history)
+        assert len(cassette) == 1
+        assert cassette.play_count == 1
+
+
 def test_double_requests(tmpdir):
     """We should capture, record, and replay all requests and response chains,
         even if there are duplicate ones.
@@ -302,3 +319,83 @@ def test_double_requests(tmpdir):
 
         # Now that we made both requests, we should have played both.
         assert cassette.play_count == 2
+
+
+def test_cookies(scheme, tmpdir):
+    async def run(loop):
+        cookies_url = scheme + (
+            "://httpbin.org/response-headers?"
+            "set-cookie=" + urllib.parse.quote("cookie_1=val_1; Path=/") + "&"
+            "Set-Cookie=" + urllib.parse.quote("Cookie_2=Val_2; Path=/")
+        )
+        home_url = scheme + "://httpbin.org/"
+        tmp = str(tmpdir.join("cookies.yaml"))
+        req_cookies = {"Cookie_3": "Val_3"}
+        req_headers = {"Cookie": "Cookie_4=Val_4"}
+
+        # ------------------------- Record -------------------------- #
+        with vcr.use_cassette(tmp) as cassette:
+            async with aiohttp.ClientSession(loop=loop) as session:
+                cookies_resp = await session.get(cookies_url)
+                home_resp = await session.get(home_url, cookies=req_cookies, headers=req_headers)
+                assert cassette.play_count == 0
+        assert_responses(cookies_resp, home_resp)
+
+        # -------------------------- Play --------------------------- #
+        with vcr.use_cassette(tmp, record_mode=vcr.mode.NONE) as cassette:
+            async with aiohttp.ClientSession(loop=loop) as session:
+                cookies_resp = await session.get(cookies_url)
+                home_resp = await session.get(home_url, cookies=req_cookies, headers=req_headers)
+                assert cassette.play_count == 2
+        assert_responses(cookies_resp, home_resp)
+
+    def assert_responses(cookies_resp, home_resp):
+        assert cookies_resp.cookies.get("cookie_1").value == "val_1"
+        assert cookies_resp.cookies.get("Cookie_2").value == "Val_2"
+        request_cookies = home_resp.request_info.headers["cookie"]
+        assert "cookie_1=val_1" in request_cookies
+        assert "Cookie_2=Val_2" in request_cookies
+        assert "Cookie_3=Val_3" in request_cookies
+        assert "Cookie_4=Val_4" in request_cookies
+
+    run_in_loop(run)
+
+
+def test_cookies_redirect(scheme, tmpdir):
+    async def run(loop):
+        # Sets cookie as provided by the query string and redirects
+        cookies_url = scheme + "://httpbin.org/cookies/set?Cookie_1=Val_1"
+        tmp = str(tmpdir.join("cookies.yaml"))
+
+        # ------------------------- Record -------------------------- #
+        with vcr.use_cassette(tmp) as cassette:
+            async with aiohttp.ClientSession(loop=loop) as session:
+                cookies_resp = await session.get(cookies_url)
+                assert not cookies_resp.cookies
+                cookies = session.cookie_jar.filter_cookies(cookies_url)
+                assert cookies["Cookie_1"].value == "Val_1"
+                assert cassette.play_count == 0
+            cassette.requests[1].headers["Cookie"] == "Cookie_1=Val_1"
+
+        # -------------------------- Play --------------------------- #
+        with vcr.use_cassette(tmp, record_mode=vcr.mode.NONE) as cassette:
+            async with aiohttp.ClientSession(loop=loop) as session:
+                cookies_resp = await session.get(cookies_url)
+                assert not cookies_resp.cookies
+                cookies = session.cookie_jar.filter_cookies(cookies_url)
+                assert cookies["Cookie_1"].value == "Val_1"
+                assert cassette.play_count == 2
+            cassette.requests[1].headers["Cookie"] == "Cookie_1=Val_1"
+
+        # Assert that it's ignoring expiration date
+        with vcr.use_cassette(tmp, record_mode=vcr.mode.NONE) as cassette:
+            cassette.responses[0]["headers"]["set-cookie"] = [
+                "Cookie_1=Val_1; Expires=Wed, 21 Oct 2015 07:28:00 GMT"
+            ]
+            async with aiohttp.ClientSession(loop=loop) as session:
+                cookies_resp = await session.get(cookies_url)
+                assert not cookies_resp.cookies
+                cookies = session.cookie_jar.filter_cookies(cookies_url)
+                assert cookies["Cookie_1"].value == "Val_1"
+
+    run_in_loop(run)
diff --git a/tests/integration/test_config.py b/tests/integration/test_config.py
index 30c9089..4c8c595 100644
--- a/tests/integration/test_config.py
+++ b/tests/integration/test_config.py
@@ -13,7 +13,9 @@ def test_set_serializer_default_config(tmpdir, httpbin):
         urlopen(httpbin.url + "/get")
 
     with open(str(tmpdir.join("test.json"))) as f:
-        assert json.loads(f.read())
+        file_content = f.read()
+        assert file_content.endswith("\n")
+        assert json.loads(file_content)
 
 
 def test_default_set_cassette_library_dir(tmpdir, httpbin):
diff --git a/tests/integration/test_disksaver.py b/tests/integration/test_disksaver.py
index c26e13a..9868555 100644
--- a/tests/integration/test_disksaver.py
+++ b/tests/integration/test_disksaver.py
@@ -45,7 +45,7 @@ def test_disk_saver_write(tmpdir, httpbin):
     # the mtime doesn't change
     time.sleep(1)
 
-    with vcr.use_cassette(fname, record_mode="any") as cass:
+    with vcr.use_cassette(fname, record_mode=vcr.mode.ANY) as cass:
         urlopen(httpbin.url).read()
         urlopen(httpbin.url + "/get").read()
         assert cass.play_count == 1
diff --git a/tests/integration/test_httpx.py b/tests/integration/test_httpx.py
new file mode 100644
index 0000000..f783934
--- /dev/null
+++ b/tests/integration/test_httpx.py
@@ -0,0 +1,255 @@
+import pytest
+import contextlib
+import os
+
+asyncio = pytest.importorskip("asyncio")
+httpx = pytest.importorskip("httpx")
+
+import vcr  # noqa: E402
+
+
+class BaseDoRequest:
+    _client_class = None
+
+    def __init__(self, *args, **kwargs):
+        self._client = self._client_class(*args, **kwargs)
+
+
+class DoSyncRequest(BaseDoRequest):
+    _client_class = httpx.Client
+
+    def __call__(self, *args, **kwargs):
+        return self._client.request(*args, timeout=60, **kwargs)
+
+
+class DoAsyncRequest(BaseDoRequest):
+    _client_class = httpx.AsyncClient
+
+    @staticmethod
+    def run_in_loop(coroutine):
+        with contextlib.closing(asyncio.new_event_loop()) as loop:
+            asyncio.set_event_loop(loop)
+            task = loop.create_task(coroutine)
+            return loop.run_until_complete(task)
+
+    def __call__(self, *args, **kwargs):
+        async def _request():
+            async with self._client as c:
+                return await c.request(*args, **kwargs)
+
+        return DoAsyncRequest.run_in_loop(_request())
+
+
+def pytest_generate_tests(metafunc):
+    if "do_request" in metafunc.fixturenames:
+        metafunc.parametrize("do_request", [DoAsyncRequest, DoSyncRequest])
+    if "scheme" in metafunc.fixturenames:
+        metafunc.parametrize("scheme", ["http", "https"])
+
+
+@pytest.fixture
+def yml(tmpdir, request):
+    return str(tmpdir.join(request.function.__name__ + ".yaml"))
+
+
+def test_status(tmpdir, scheme, do_request):
+    url = scheme + "://mockbin.org/request"
+    with vcr.use_cassette(str(tmpdir.join("status.yaml"))):
+        response = do_request()("GET", url)
+
+    with vcr.use_cassette(str(tmpdir.join("status.yaml"))) as cassette:
+        cassette_response = do_request()("GET", url)
+        assert cassette_response.status_code == response.status_code
+        assert cassette.play_count == 1
+
+
+def test_case_insensitive_headers(tmpdir, scheme, do_request):
+    url = scheme + "://mockbin.org/request"
+    with vcr.use_cassette(str(tmpdir.join("whatever.yaml"))):
+        do_request()("GET", url)
+
+    with vcr.use_cassette(str(tmpdir.join("whatever.yaml"))) as cassette:
+        cassette_response = do_request()("GET", url)
+        assert "Content-Type" in cassette_response.headers
+        assert "content-type" in cassette_response.headers
+        assert cassette.play_count == 1
+
+
+def test_content(tmpdir, scheme, do_request):
+    url = scheme + "://httpbin.org"
+    with vcr.use_cassette(str(tmpdir.join("cointent.yaml"))):
+        response = do_request()("GET", url)
+
+    with vcr.use_cassette(str(tmpdir.join("cointent.yaml"))) as cassette:
+        cassette_response = do_request()("GET", url)
+        assert cassette_response.content == response.content
+        assert cassette.play_count == 1
+
+
+def test_json(tmpdir, scheme, do_request):
+    url = scheme + "://httpbin.org/get"
+    headers = {"Content-Type": "application/json"}
+
+    with vcr.use_cassette(str(tmpdir.join("json.yaml"))):
+        response = do_request(headers=headers)("GET", url)
+
+    with vcr.use_cassette(str(tmpdir.join("json.yaml"))) as cassette:
+        cassette_response = do_request(headers=headers)("GET", url)
+        assert cassette_response.json() == response.json()
+        assert cassette.play_count == 1
+
+
+def test_params_same_url_distinct_params(tmpdir, scheme, do_request):
+    url = scheme + "://httpbin.org/get"
+    headers = {"Content-Type": "application/json"}
+    params = {"a": 1, "b": False, "c": "c"}
+
+    with vcr.use_cassette(str(tmpdir.join("get.yaml"))) as cassette:
+        response = do_request()("GET", url, params=params, headers=headers)
+
+    with vcr.use_cassette(str(tmpdir.join("get.yaml"))) as cassette:
+        cassette_response = do_request()("GET", url, params=params, headers=headers)
+        assert cassette_response.request.url == response.request.url
+        assert cassette_response.json() == response.json()
+        assert cassette.play_count == 1
+
+    params = {"other": "params"}
+    with vcr.use_cassette(str(tmpdir.join("get.yaml"))) as cassette:
+        with pytest.raises(vcr.errors.CannotOverwriteExistingCassetteException):
+            do_request()("GET", url, params=params, headers=headers)
+
+
+def test_redirect(tmpdir, do_request, yml):
+    url = "https://mockbin.org/redirect/303/2"
+
+    response = do_request()("GET", url)
+    with vcr.use_cassette(yml):
+        response = do_request()("GET", url)
+
+    with vcr.use_cassette(yml) as cassette:
+        cassette_response = do_request()("GET", url)
+
+        assert cassette_response.status_code == response.status_code
+        assert len(cassette_response.history) == len(response.history)
+        assert len(cassette) == 3
+        assert cassette.play_count == 3
+
+    # Assert that the real response and the cassette response have a similar
+    # looking request_info.
+    assert cassette_response.request.url == response.request.url
+    assert cassette_response.request.method == response.request.method
+    assert {k: v for k, v in cassette_response.request.headers.items()} == {
+        k: v for k, v in response.request.headers.items()
+    }
+
+
+def test_work_with_gzipped_data(tmpdir, do_request, yml):
+    with vcr.use_cassette(yml):
+        do_request()("GET", "https://httpbin.org/gzip")
+
+    with vcr.use_cassette(yml) as cassette:
+        cassette_response = do_request()("GET", "https://httpbin.org/gzip")
+
+        assert "gzip" in cassette_response.json()["headers"]["Accept-Encoding"]
+        assert cassette_response.read()
+        assert cassette.play_count == 1
+
+
+@pytest.mark.parametrize("url", ["https://github.com/kevin1024/vcrpy/issues/" + str(i) for i in range(3, 6)])
+def test_simple_fetching(tmpdir, do_request, yml, url):
+    with vcr.use_cassette(yml):
+        do_request()("GET", url)
+
+    with vcr.use_cassette(yml) as cassette:
+        cassette_response = do_request()("GET", url)
+        assert str(cassette_response.request.url) == url
+        assert cassette.play_count == 1
+
+
+def test_behind_proxy(do_request):
+    # This is recorded because otherwise we should have a live proxy somewhere.
+    yml = (
+        os.path.dirname(os.path.realpath(__file__)) + "/cassettes/" + "test_httpx_test_test_behind_proxy.yml"
+    )
+    url = "https://httpbin.org/headers"
+    proxy = "http://localhost:8080"
+    proxies = {"http": proxy, "https": proxy}
+
+    with vcr.use_cassette(yml):
+        response = do_request(proxies=proxies, verify=False)("GET", url)
+
+    with vcr.use_cassette(yml) as cassette:
+        cassette_response = do_request(proxies=proxies, verify=False)("GET", url)
+        assert str(cassette_response.request.url) == url
+        assert cassette.play_count == 1
+
+        assert cassette_response.headers["Via"] == "my_own_proxy", str(cassette_response.headers)
+        assert cassette_response.request.url == response.request.url
+
+
+def test_cookies(tmpdir, scheme, do_request):
+    def client_cookies(client):
+        return [c for c in client._client.cookies]
+
+    def response_cookies(response):
+        return [c for c in response.cookies]
+
+    client = do_request()
+    assert client_cookies(client) == []
+
+    url = scheme + "://httpbin.org"
+    testfile = str(tmpdir.join("cookies.yml"))
+    with vcr.use_cassette(testfile):
+        r1 = client("GET", url + "/cookies/set?k1=v1&k2=v2")
+        assert response_cookies(r1.history[0]) == ["k1", "k2"]
+        assert response_cookies(r1) == []
+
+        r2 = client("GET", url + "/cookies")
+        assert len(r2.json()["cookies"]) == 2
+
+        assert client_cookies(client) == ["k1", "k2"]
+
+    new_client = do_request()
+    assert client_cookies(new_client) == []
+
+    with vcr.use_cassette(testfile) as cassette:
+        cassette_response = new_client("GET", url + "/cookies/set?k1=v1&k2=v2")
+        assert response_cookies(cassette_response.history[0]) == ["k1", "k2"]
+        assert response_cookies(cassette_response) == []
+
+        assert cassette.play_count == 2
+        assert client_cookies(new_client) == ["k1", "k2"]
+
+
+def test_relative_redirects(tmpdir, scheme, do_request):
+    url = scheme + "://mockbin.com/redirect/301?to=/redirect/301?to=/request"
+    testfile = str(tmpdir.join("relative_redirects.yml"))
+    with vcr.use_cassette(testfile):
+        response = do_request()("GET", url)
+        assert len(response.history) == 2, response
+        assert response.json()["url"].endswith("request")
+
+    with vcr.use_cassette(testfile) as cassette:
+        response = do_request()("GET", url)
+        assert len(response.history) == 2
+        assert response.json()["url"].endswith("request")
+
+        assert cassette.play_count == 3
+
+
+def test_redirect_wo_allow_redirects(do_request, yml):
+    url = "https://mockbin.org/redirect/308/5"
+
+    with vcr.use_cassette(yml):
+        response = do_request()("GET", url, allow_redirects=False)
+
+        assert str(response.url).endswith("308/5")
+        assert response.status_code == 308
+
+    with vcr.use_cassette(yml) as cassette:
+        response = do_request()("GET", url, allow_redirects=False)
+
+        assert str(response.url).endswith("308/5")
+        assert response.status_code == 308
+
+        assert cassette.play_count == 1
diff --git a/tests/integration/test_matchers.py b/tests/integration/test_matchers.py
index c555e5c..3cdbedc 100644
--- a/tests/integration/test_matchers.py
+++ b/tests/integration/test_matchers.py
@@ -13,13 +13,13 @@ def _replace_httpbin(uri, httpbin, httpbin_secure):
 @pytest.fixture
 def cassette(tmpdir, httpbin, httpbin_secure):
     """
-    Helper fixture used to prepare the cassete
+    Helper fixture used to prepare the cassette
     returns path to the recorded cassette
     """
     default_uri = _replace_httpbin(DEFAULT_URI, httpbin, httpbin_secure)
 
     cassette_path = str(tmpdir.join("test.yml"))
-    with vcr.use_cassette(cassette_path, record_mode="all"):
+    with vcr.use_cassette(cassette_path, record_mode=vcr.mode.ALL):
         urlopen(default_uri)
     return cassette_path
 
diff --git a/tests/integration/test_record_mode.py b/tests/integration/test_record_mode.py
index 38abce5..c5109ad 100644
--- a/tests/integration/test_record_mode.py
+++ b/tests/integration/test_record_mode.py
@@ -5,11 +5,11 @@ from urllib.request import urlopen
 
 def test_once_record_mode(tmpdir, httpbin):
     testfile = str(tmpdir.join("recordmode.yml"))
-    with vcr.use_cassette(testfile, record_mode="once"):
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.ONCE):
         # cassette file doesn't exist, so create.
         urlopen(httpbin.url).read()
 
-    with vcr.use_cassette(testfile, record_mode="once"):
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.ONCE):
         # make the same request again
         urlopen(httpbin.url).read()
 
@@ -22,12 +22,12 @@ def test_once_record_mode(tmpdir, httpbin):
 
 def test_once_record_mode_two_times(tmpdir, httpbin):
     testfile = str(tmpdir.join("recordmode.yml"))
-    with vcr.use_cassette(testfile, record_mode="once"):
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.ONCE):
         # get two of the same file
         urlopen(httpbin.url).read()
         urlopen(httpbin.url).read()
 
-    with vcr.use_cassette(testfile, record_mode="once"):
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.ONCE):
         # do it again
         urlopen(httpbin.url).read()
         urlopen(httpbin.url).read()
@@ -35,7 +35,7 @@ def test_once_record_mode_two_times(tmpdir, httpbin):
 
 def test_once_mode_three_times(tmpdir, httpbin):
     testfile = str(tmpdir.join("recordmode.yml"))
-    with vcr.use_cassette(testfile, record_mode="once"):
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.ONCE):
         # get three of the same file
         urlopen(httpbin.url).read()
         urlopen(httpbin.url).read()
@@ -45,11 +45,11 @@ def test_once_mode_three_times(tmpdir, httpbin):
 def test_new_episodes_record_mode(tmpdir, httpbin):
     testfile = str(tmpdir.join("recordmode.yml"))
 
-    with vcr.use_cassette(testfile, record_mode="new_episodes"):
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.NEW_EPISODES):
         # cassette file doesn't exist, so create.
         urlopen(httpbin.url).read()
 
-    with vcr.use_cassette(testfile, record_mode="new_episodes") as cass:
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.NEW_EPISODES) as cass:
         # make the same request again
         urlopen(httpbin.url).read()
 
@@ -66,7 +66,7 @@ def test_new_episodes_record_mode(tmpdir, httpbin):
         # not all responses have been played
         assert not cass.all_played
 
-    with vcr.use_cassette(testfile, record_mode="new_episodes") as cass:
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.NEW_EPISODES) as cass:
         # the cassette should now have 2 responses
         assert len(cass.responses) == 2
 
@@ -74,11 +74,11 @@ def test_new_episodes_record_mode(tmpdir, httpbin):
 def test_new_episodes_record_mode_two_times(tmpdir, httpbin):
     testfile = str(tmpdir.join("recordmode.yml"))
     url = httpbin.url + "/bytes/1024"
-    with vcr.use_cassette(testfile, record_mode="new_episodes"):
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.NEW_EPISODES):
         # cassette file doesn't exist, so create.
         original_first_response = urlopen(url).read()
 
-    with vcr.use_cassette(testfile, record_mode="new_episodes"):
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.NEW_EPISODES):
         # make the same request again
         assert urlopen(url).read() == original_first_response
 
@@ -86,7 +86,7 @@ def test_new_episodes_record_mode_two_times(tmpdir, httpbin):
         # to the cassette without repercussions
         original_second_response = urlopen(url).read()
 
-    with vcr.use_cassette(testfile, record_mode="once"):
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.ONCE):
         # make the same request again
         assert urlopen(url).read() == original_first_response
         assert urlopen(url).read() == original_second_response
@@ -99,11 +99,11 @@ def test_new_episodes_record_mode_two_times(tmpdir, httpbin):
 def test_all_record_mode(tmpdir, httpbin):
     testfile = str(tmpdir.join("recordmode.yml"))
 
-    with vcr.use_cassette(testfile, record_mode="all"):
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.ALL):
         # cassette file doesn't exist, so create.
         urlopen(httpbin.url).read()
 
-    with vcr.use_cassette(testfile, record_mode="all") as cass:
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.ALL) as cass:
         # make the same request again
         urlopen(httpbin.url).read()
 
@@ -121,7 +121,7 @@ def test_none_record_mode(tmpdir, httpbin):
     # Cassette file doesn't exist, yet we are trying to make a request.
     # raise hell.
     testfile = str(tmpdir.join("recordmode.yml"))
-    with vcr.use_cassette(testfile, record_mode="none"):
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.NONE):
         with pytest.raises(Exception):
             urlopen(httpbin.url).read()
 
@@ -130,11 +130,11 @@ def test_none_record_mode_with_existing_cassette(tmpdir, httpbin):
     # create a cassette file
     testfile = str(tmpdir.join("recordmode.yml"))
 
-    with vcr.use_cassette(testfile, record_mode="all"):
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.ALL):
         urlopen(httpbin.url).read()
 
     # play from cassette file
-    with vcr.use_cassette(testfile, record_mode="none") as cass:
+    with vcr.use_cassette(testfile, record_mode=vcr.mode.NONE) as cass:
         urlopen(httpbin.url).read()
         assert cass.play_count == 1
         # but if I try to hit the net, raise an exception.
diff --git a/tests/unit/test_cassettes.py b/tests/unit/test_cassettes.py
index 90a05c7..adbc77a 100644
--- a/tests/unit/test_cassettes.py
+++ b/tests/unit/test_cassettes.py
@@ -137,6 +137,31 @@ def test_cassette_all_played():
     assert a.all_played
 
 
+@mock.patch("vcr.cassette.requests_match", _mock_requests_match)
+def test_cassette_allow_playback_repeats():
+    a = Cassette("test", allow_playback_repeats=True)
+    a.append("foo", "bar")
+    a.append("other", "resp")
+    for x in range(10):
+        assert a.play_response("foo") == "bar"
+    assert a.play_count == 10
+    assert a.all_played is False
+    assert a.play_response("other") == "resp"
+    assert a.play_count == 11
+    assert a.all_played
+
+    a.allow_playback_repeats = False
+    with pytest.raises(UnhandledHTTPRequestError) as e:
+        a.play_response("foo")
+    assert str(e.value) == "\"The cassette ('test') doesn't contain the request ('foo') asked for\""
+    a.rewind()
+    assert a.all_played is False
+    assert a.play_response("foo") == "bar"
+    assert a.all_played is False
+    assert a.play_response("other") == "resp"
+    assert a.all_played
+
+
 @mock.patch("vcr.cassette.requests_match", _mock_requests_match)
 def test_cassette_rewound():
     a = Cassette("test")
diff --git a/tests/unit/test_stubs.py b/tests/unit/test_stubs.py
index ee05134..c952fad 100644
--- a/tests/unit/test_stubs.py
+++ b/tests/unit/test_stubs.py
@@ -1,5 +1,6 @@
 from unittest import mock
 
+from vcr import mode
 from vcr.stubs import VCRHTTPSConnection
 from vcr.cassette import Cassette
 
@@ -13,6 +14,6 @@ class TestVCRConnection:
     @mock.patch("vcr.cassette.Cassette.can_play_response_for", return_value=False)
     def testing_connect(*args):
         vcr_connection = VCRHTTPSConnection("www.google.com")
-        vcr_connection.cassette = Cassette("test", record_mode="all")
+        vcr_connection.cassette = Cassette("test", record_mode=mode.ALL)
         vcr_connection.real_connection.connect()
         assert vcr_connection.real_connection.sock is not None
diff --git a/tests/unit/test_vcr.py b/tests/unit/test_vcr.py
index c49a4b9..1eb8c69 100644
--- a/tests/unit/test_vcr.py
+++ b/tests/unit/test_vcr.py
@@ -4,7 +4,7 @@ import os
 import pytest
 import http.client as httplib
 
-from vcr import VCR, use_cassette
+from vcr import VCR, mode, use_cassette
 from vcr.request import Request
 from vcr.stubs import VCRHTTPSConnection
 from vcr.patch import _HTTPConnection, force_reset
@@ -188,11 +188,11 @@ def test_custom_patchers():
 def test_inject_cassette():
     vcr = VCR(inject_cassette=True)
 
-    @vcr.use_cassette("test", record_mode="once")
+    @vcr.use_cassette("test", record_mode=mode.ONCE)
     def with_cassette_injected(cassette):
-        assert cassette.record_mode == "once"
+        assert cassette.record_mode == mode.ONCE
 
-    @vcr.use_cassette("test", record_mode="once", inject_cassette=False)
+    @vcr.use_cassette("test", record_mode=mode.ONCE, inject_cassette=False)
     def without_cassette_injected():
         pass
 
@@ -201,7 +201,7 @@ def test_inject_cassette():
 
 
 def test_with_current_defaults():
-    vcr = VCR(inject_cassette=True, record_mode="once")
+    vcr = VCR(inject_cassette=True, record_mode=mode.ONCE)
 
     @vcr.use_cassette("test", with_current_defaults=False)
     def changing_defaults(cassette, checks):
@@ -212,10 +212,10 @@ def test_with_current_defaults():
         checks(cassette)
 
     def assert_record_mode_once(cassette):
-        assert cassette.record_mode == "once"
+        assert cassette.record_mode == mode.ONCE
 
     def assert_record_mode_all(cassette):
-        assert cassette.record_mode == "all"
+        assert cassette.record_mode == mode.ALL
 
     changing_defaults(assert_record_mode_once)
     current_defaults(assert_record_mode_once)