replayx

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).

replayx hooks httpx at the transport layer. Calls made through other clients (requests, urllib, aiohttp) are not intercepted.

Record modes

Pass record_mode to control recording and replay. The names follow the established VCR conventions.

ModeWhat happensWhen 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_episodesReplay matches and append any new interactions to the cassette.Adding new calls to a test without re-recording the rest.
noneReplay only. Never reach the network. Never write. A new request raises an error.CI, where any unmocked call should fail loudly.
allAlways 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")):
    ...
MatcherCompares
methodHTTP method, case-insensitive
schemehttp or https
hostHost name
portPort, with the default per scheme (443 or 80)
pathURL path
queryQuery parameters, order-independent
url (alias uri)Scheme, host, port, path, and query together
headersRequest headers
bodyRaw request body bytes
graphqlGraphQL operation, see below
A matcher you redact and match on at the same time will not line up. For example, do not match on a header you also redact.

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.

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}" } }
    }
  ]
}

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

use_stubs and StubRouter

RecordMode

An enum with members ONCE, NEW_EPISODES, NONE, ALL. String values work everywhere a mode is accepted.

Exceptions

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.