Record and replay HTTP interactions for httpx — an async-native VCR for fast, deterministic tests.
Project description
replayx
Record and replay HTTP and HTTPS interactions for httpx. Run your tests fast and offline.
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.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file replayx-0.4.2.tar.gz.
File metadata
- Download URL: replayx-0.4.2.tar.gz
- Upload date:
- Size: 22.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1d5187c1a5516273629f797f9469ad510d801137875e48d14b0a34b402080859
|
|
| MD5 |
c34e8330a7ae2641887fd033c0d7f95f
|
|
| BLAKE2b-256 |
87b86875a52be9fd78c2f251ba7b2e04e2ca98d74712c36d58afab914acd27a2
|
Provenance
The following attestation bundles were made for replayx-0.4.2.tar.gz:
Publisher:
publish.yml on mkusiappiah/replayx
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
replayx-0.4.2.tar.gz -
Subject digest:
1d5187c1a5516273629f797f9469ad510d801137875e48d14b0a34b402080859 - Sigstore transparency entry: 1918116329
- Sigstore integration time:
-
Permalink:
mkusiappiah/replayx@b12c4851adc999a9a0da3358065d728d3a0c62d5 -
Branch / Tag:
refs/tags/v0.4.2 - Owner: https://github.com/mkusiappiah
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@b12c4851adc999a9a0da3358065d728d3a0c62d5 -
Trigger Event:
release
-
Statement type:
File details
Details for the file replayx-0.4.2-py3-none-any.whl.
File metadata
- Download URL: replayx-0.4.2-py3-none-any.whl
- Upload date:
- Size: 21.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
86ce542682b2ac954446976cb6f3675e9d110ed875556237011003b0f82f3f29
|
|
| MD5 |
e7910b286e20936bc2b75af2063299a1
|
|
| BLAKE2b-256 |
fc6438b8e63be121545d245e2c4bea87f5c3637cb1958e2218de622af0209b8d
|
Provenance
The following attestation bundles were made for replayx-0.4.2-py3-none-any.whl:
Publisher:
publish.yml on mkusiappiah/replayx
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
replayx-0.4.2-py3-none-any.whl -
Subject digest:
86ce542682b2ac954446976cb6f3675e9d110ed875556237011003b0f82f3f29 - Sigstore transparency entry: 1918116453
- Sigstore integration time:
-
Permalink:
mkusiappiah/replayx@b12c4851adc999a9a0da3358065d728d3a0c62d5 -
Branch / Tag:
refs/tags/v0.4.2 - Owner: https://github.com/mkusiappiah
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@b12c4851adc999a9a0da3358065d728d3a0c62d5 -
Trigger Event:
release
-
Statement type: