Skip to main content

replayx

Record and replay HTTP and HTTPS interactions for httpx. Run your tests fast and offline.

CI PyPI Python versions License: MIT

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.

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"

The first run records. Later runs replay.

Why I built replayx

vcrpy brought record and replay to Python. vcrpy targets requests and the sync world. I built replayx for modern httpx code.

Feature replayx vcrpy
Async httpx.AsyncClient yes limited
Built for httpx yes through patches
Zero deps beyond httpx yes (JSON) needs PyYAML
Secret redaction for committed cassettes yes partial
Explicit transport API, no patching yes no
Modern typing with py.typed yes no

HTTPS and TLS

replayx works with https:// URLs the same as http://. replayx hooks into httpx at the transport layer, 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 (443 for https, 80 for http), so https and http to the same host stay distinct.

replayx hooks httpx, so requests made through other clients (requests, urllib, aiohttp) are not intercepted.

Install

pip install replayx

Add YAML cassettes:

pip install "replayx[yaml]"

replayx needs Python 3.9 or newer and httpx 0.23 or newer.

Usage

Patch httpx with use_cassette

use_cassette patches httpx for the block. Your existing client code runs without changes. Sync and async both work.

import httpx
from replayx import use_cassette

async def fetch():
    async with httpx.AsyncClient() as client:
        return await client.get("https://api.example.com/data")

with use_cassette("cassettes/data.json"):
    resp = await fetch()

Build a transport yourself

Prefer no patching? Build a transport and pass the transport to your client. Nothing gets monkeypatched.

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.

The pytest plugin

The plugin gives each test an auto-named cassette 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

Re-record a whole run from the command line:

pytest --replayx-record=all

Set per-test defaults with the marker:

import pytest

@pytest.mark.replayx(match_on=("method", "url", "body"), filter_headers=["authorization"])
def test_create(replayx_cassette):
    with replayx_cassette():
        ...

Inline stubs, no recording

Sometimes you want to define responses in code instead of recording them. 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}]

A request that matches no route raises UnhandledStubError, so unmocked calls show up at once. Routes match on method plus scheme, host, port, and path. The query string is ignored. 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

Record modes

Mode What happens
once (default) Replay an existing cassette. Record everything when no cassette exists. A new request against an existing cassette raises an error.
new_episodes Replay matches and append new interactions.
none Replay only. No network. No writes. Good for CI.
all Always reach the real backend and overwrite the cassette. Use to re-record.
with use_cassette("cassettes/api.json", record_mode="none"):
    ...

Match requests

Requests match on method and url by default. Query order does not affect matching. Change the rules with match_on.

with use_cassette("cassettes/api.json", match_on=("method", "path", "body")):
    ...

Available matchers: method, scheme, host, port, path, query, url (alias uri), headers, body, graphql.

GraphQL requests

GraphQL sends every operation as a POST to one URL, so raw body matching breaks on formatting. The graphql matcher compares the operation name, the variables, and the query with whitespace collapsed.

with use_cassette("cassettes/api.json", match_on=("method", "url", "graphql")):
    ...

Redact secrets

Commit cassettes without leaking credentials. 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"],
):
    ...

Use hooks for full control. Return a changed recording, or return None to skip the recording.

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

Cassette format

Cassettes use plain JSON. YAML works with the yaml extra. Both read well in code review.

{
  "version": 1,
  "recorded_with": "replayx/0.1.0",
  "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}" } }
    }
  ]
}

replayx stores binary bodies as base64.

Contribute

I welcome contributions. Set up a dev environment:

git clone https://github.com/mkusiappiah/replayx
cd replayx
pip install -e ".[dev]"
pytest
ruff check .
mypy

Open an issue before large changes.

License

MIT. See LICENSE.

Metadata

Release files for replayx 0.4.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for replayx 0.4.3
File Size Uploaded
replayx-0.4.3.tar.gz 22.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for replayx 0.4.3
File Interpreter ABI Platform
replayx-0.4.3-py3-none-any.whl Python 3 none any Details

Total release size: 43.9 kB

Release files / replayx-0.4.3.tar.gz

Download URL replayx-0.4.3.tar.gz
Size 22.1 kB
Tags Source
SHA-256 checksum
How to use checksums
4af141d89699a498a8004753b74a9ba6e816a9a80692e23d069451d324950981
BLAKE2b-256 checksum
How to use checksums
6d7dd3ea2bf651ea3d47367b25dbb4c9a2d293b0e87e99c1caf51ad35313d958
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 22, 2026.

Transparency log

Release files / replayx-0.4.3-py3-none-any.whl

Download URL replayx-0.4.3-py3-none-any.whl
Size 21.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cccebe5f0a25cca49ba1bbda378699a8e1fbe7ba2c55366d17134156eb477b54
BLAKE2b-256 checksum
How to use checksums
449e62f7e3c2e65953d70a81f99ea18d8e67562afa14b1f77c1db148009254a8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 22, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.4.3 This release

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page