replayx documentation
Record and replay HTTP and HTTPS interactions for httpx. Run your tests fast and offline.
Overview
replayx saves real HTTP and HTTPS responses to a cassette file on the first test run. Every later run reads from the cassette. No network calls. No flaky tests. No slow CI.
replayx targets httpx. replayx supports the sync client, the async client, gzip and deflate bodies, secret redaction, GraphQL matching, and inline stubs. Source lives on GitHub. Releases ship to PyPI.
Installation
pip install replayx
YAML cassettes need one extra:
pip install "replayx[yaml]"
replayx needs Python 3.9 or newer and httpx 0.23 or newer. The package ships type hints and a py.typed marker, so strict type checkers see full signatures.
Quickstart
Wrap your code in use_cassette. The first run records over the network. Later runs replay from the file.
import httpx
from replayx import use_cassette
with use_cassette("cassettes/github.json"):
resp = httpx.get("https://api.github.com/users/octocat")
assert resp.json()["login"] == "octocat"
Commit the cassette file with your tests. Your suite then runs offline and stays deterministic.
How replayx works
httpx asks Client._transport_for_url for a transport on every request. replayx swaps this method for the duration of a block, returns a replayx transport, and restores the original on exit. The transport follows the public httpx Transport API: a class subclassing httpx.BaseTransport and implementing handle_request (or httpx.AsyncBaseTransport with handle_async_request).
- Cassette: a file of recorded interactions. Each interaction holds a request and a response.
- Transport: the object httpx calls to send a request. replayx wraps the real transport when recording and replaces the transport when replaying.
- Matcher: the rule pairing an incoming request with a recorded one.
Record modes
Pass record_mode to control recording and replay. The names follow the established VCR conventions.
| Mode | What happens | When to use |
|---|---|---|
once (default) | Replay an existing cassette. Record everything when no cassette file exists. A new request against an existing cassette raises an error. | Most tests. Record one time, replay forever. |
new_episodes | Replay matches and append any new interactions to the cassette. | Adding new calls to a test without re-recording the rest. |
none | Replay only. Never reach the network. Never write. A new request raises an error. | CI, where any unmocked call should fail loudly. |
all | Always reach the real backend and overwrite the cassette. | Re-recording after an API changes. |
with use_cassette("cassettes/api.json", record_mode="none"):
...
Request matching
By default replayx matches on method and url. Query order does not affect matching. Set match_on to change the rules. replayx combines matchers with logical AND, so an interaction matches only when every named matcher agrees.
with use_cassette("cassettes/api.json", match_on=("method", "path", "body")):
...
| Matcher | Compares |
|---|---|
method | HTTP method, case-insensitive |
scheme | http or https |
host | Host name |
port | Port, with the default per scheme (443 or 80) |
path | URL path |
query | Query parameters, order-independent |
url (alias uri) | Scheme, host, port, path, and query together |
headers | Request headers |
body | Raw request body bytes |
graphql | GraphQL operation, see below |
GraphQL requests
GraphQL sends every operation as a POST to one URL, so raw body matching breaks on formatting. The graphql matcher parses the JSON body and compares the operation name, the variables, and the query with whitespace collapsed. A non-GraphQL body falls back to an exact body comparison.
with use_cassette("cassettes/api.json", match_on=("method", "url", "graphql")):
...
So these two bodies match, because only formatting differs:
{"query": "query { user { id } }"}
{"query": "query {\n user {\n id\n }\n}"}
Redacting secrets
Cassettes are meant to be committed, so keep credentials out of them. Redaction runs at record time. The live response your code receives stays intact.
with use_cassette(
"cassettes/api.json",
filter_headers=["authorization", "set-cookie"],
filter_query_params=["api_key", "token"],
):
...
For full control, use hooks. Return a changed recording, or return None to skip recording the interaction.
from dataclasses import replace
def scrub_body(response):
return replace(response, body=b'{"token": "REDACTED"}')
with use_cassette("cassettes/api.json", before_record_response=scrub_body):
...
HTTPS and TLS
replayx works with https:// URLs the same as http://. replayx hooks into httpx below TLS, so the scheme is never a special case.
- On record, httpx makes the real TLS request and replayx captures the result.
- On replay, replayx serves the response from the cassette with no network and no TLS handshake, so certificates and expiry do not matter.
- Matching tracks the scheme and the correct default port, so http and https to the same host stay distinct.
Content encoding
replayx keeps the Content-Encoding header and re-encodes the body on replay, so a replayed response matches a live one. gzip and deflate work with no extra dependency. brotli needs brotli or brotlicffi. zstd needs zstandard. A missing codec degrades gracefully: replayx drops the encoding header and serves the decoded body.
Bodies are stored decoded, so cassettes stay readable in code review even when the live API uses gzip.
The pytest plugin
replayx ships a pytest plugin through the pytest11 entry point, so installing the package registers the fixture. The replayx_cassette fixture builds a per-test cassette path at <test-dir>/cassettes/<test-name>.json.
import httpx
def test_octocat(replayx_cassette):
with replayx_cassette():
resp = httpx.get("https://api.github.com/users/octocat")
assert resp.status_code == 200
Override the record mode for a whole run from the command line. This is handy for re-recording in one step:
pytest --replayx-record=all
Set per-test defaults with the marker. The fixture forwards these to use_cassette:
import pytest
@pytest.mark.replayx(match_on=("method", "url", "body"), filter_headers=["authorization"])
def test_create(replayx_cassette):
with replayx_cassette():
...
Inline stubs
Sometimes you want responses defined in code, with no recording. use_stubs patches httpx and serves responses from routes you declare.
import httpx
from replayx import use_stubs
with use_stubs() as router:
router.get("https://api.example.com/users").respond(json=[{"id": 1}])
router.post("https://api.example.com/users").respond(201, json={"id": 2})
with httpx.Client() as client:
assert client.get("https://api.example.com/users").json() == [{"id": 1}]
Routes match on method plus scheme, host, port, and path. The query string is ignored. A request matching no route raises UnhandledStubError, so unmocked calls surface at once. Each route counts its calls:
with use_stubs() as router:
route = router.get("https://api.example.com/ping").respond(text="pong")
...
assert route.call_count == 1
Explicit transports
If you prefer no patching, build a transport and pass the transport to your client. Nothing global changes.
import httpx
from replayx import Cassette
cassette = Cassette.load("cassettes/data.json", record_mode="once")
with httpx.Client(transport=cassette.sync_transport()) as client:
resp = client.get("https://api.example.com/data")
cassette.save()
Use cassette.async_transport() with httpx.AsyncClient for async code. sync_transport and async_transport take an optional real= transport, which replayx wraps when recording.
Cassette format
Cassettes use plain JSON by default. YAML works with the yaml extra. Both read well in a diff.
{
"version": 1,
"recorded_with": "replayx/0.4.1",
"interactions": [
{
"request": { "method": "GET", "url": "https://api.example.com/data", "headers": [], "body": null },
"response": { "status_code": 200, "headers": [["content-type", "application/json"]], "body": { "text": "{\"ok\":true}" } }
}
]
}
- A body is
nullwhen empty,{"text": "..."}for UTF-8 content, or{"base64": "..."}for binary content. - The file extension picks the serializer:
.jsonby default,.yamlor.ymlfor YAML.
API reference
use_cassette
use_cassette(
path,
*,
record_mode="once", # "once" | "new_episodes" | "none" | "all"
match_on=("method", "url"),
serializer=None,
allow_playback_repeats=False,
filter_headers=(),
filter_query_params=(),
before_record_request=None,
before_record_response=None,
patch=True,
)
A context manager yielding the active Cassette. Saves new interactions on exit.
Cassette
Cassette.load(path, *, record_mode="once", match_on=(...), ...)builds a cassette.cassette.sync_transport(real=None)andcassette.async_transport(real=None)return transports.cassette.save()writes new interactions. ReturnsTruewhen a file is written.
use_stubs and StubRouter
use_stubs(router=None)yields aStubRouter.router.get/post/put/patch/delete/head/options(url)androuter.route(method, url)return aRoute.route.respond(status_code=200, *, json=None, text=None, content=None, headers=None)sets the response.route.call_countreports how many requests matched the route.
RecordMode
An enum with members ONCE, NEW_EPISODES, NONE, ALL. String values work everywhere a mode is accepted.
Exceptions
ReplayxError: base class for all replayx errors.UnhandledRequestError: a request had no matching recording innonemode, or inoncemode with an existing cassette.UnhandledStubError: a stub router had no route for a request.CassetteFormatError: a cassette file could not be parsed.
Troubleshooting
A request is not matching
Check the matchers. The default pairs on method and url. If the request body or a query value changes per run, drop the field from match_on or use a looser matcher such as path. For GraphQL, use the graphql matcher.
UnhandledRequestError on a new request
In once mode with an existing cassette, and in none mode, a new request raises. Re-record with record_mode="all", or append with record_mode="new_episodes".
The call still hits the network
replayx intercepts httpx only. Confirm the request goes through httpx. A call inside use_cassette made with requests or aiohttp is not captured.
The cassette is empty after a run
replayx writes only after recording something new. In none mode replayx never writes. In once mode replayx writes the first time, then replays.
FAQ
Does replayx support HTTPS?
Yes. See the HTTPS and TLS section. Record runs over real TLS. Replay needs no network and no certificates.
Does replayx work with async?
Yes. The same use_cassette and use_stubs calls work with httpx.AsyncClient. replayx provides both sync and async transports.
Should I commit cassettes?
Yes. Commit them with your tests so the suite runs offline. Redact secrets first.
How do I re-record after an API change?
Run with record_mode="all", or with the pytest flag --replayx-record=all.
How is replayx different from respx or vcrpy?
vcrpy targets requests and the sync world. respx focuses on inline mocking. replayx is built for httpx, records and replays, supports async first, redacts secrets, and also offers inline stubs through use_stubs.