HTTP Cassettes
Cassettes record HTTP request/response interactions to disk and replay them later without hitting the network.
Quickstart
Record an interaction once, then replay it without network access:
import asyncio
from zapros import (
AsyncClient,
AsyncStdNetworkHandler,
)
from zapros import CassetteMiddleware
async def main():
handler = CassetteMiddleware(
AsyncStdNetworkHandler(),
cassette_name="github_api",
)
async with AsyncClient(handler=handler) as client:
response = await client.get(
"https://api.github.com/users/octocat",
)
print(response.json)
asyncio.run(main())from zapros import (
Client,
StdNetworkHandler,
)
from zapros import CassetteMiddleware
handler = CassetteMiddleware(
StdNetworkHandler(),
cassette_name="github_api",
)
with Client(handler=handler) as client:
response = client.get(
"https://api.github.com/users/octocat",
)
print(response.json)The first run hits the network and writes cassettes/github_api.json. Subsequent runs replay from disk.
Cassette Modes
The mode parameter controls recording behavior:
mode=CassetteMode.ONCE
The default mode. Records only if the cassette file doesn't exist yet. Useful for initial recording:
handler = CassetteMiddleware(
network_handler,
mode=CassetteMode.ONCE,
cassette_name="api",
)- First run: records to
cassettes/api.json - Later runs: replays from cassette, raises error for unmatched requests
mode=CassetteMode.NEW_EPISODES
Replays existing interactions, records new ones:
handler = CassetteMiddleware(
network_handler,
mode=CassetteMode.NEW_EPISODES,
cassette_name="api",
)- Matched requests: served from cassette
- Unmatched requests: hit network, get appended to cassette
mode=CassetteMode.ALL
Always hits the network, always records (even duplicates):
handler = CassetteMiddleware(
network_handler,
mode=CassetteMode.ALL,
cassette_name="api",
)Use for regenerating cassettes or debugging.
mode=CassetteMode.NONE
Replay-only mode. Raises error if no match found:
handler = CassetteMiddleware(
None, # no network handler needed
mode=CassetteMode.NONE,
cassette_name="api",
)Use in CI to ensure tests never hit the network.
Overriding the mode via environment
If mode is not passed to CassetteMiddleware, the default is read from the ZAPROS_CASSETTE_MODE environment variable. This lets the same code record or replay depending on how it's run:
handler = CassetteMiddleware(
network_handler,
cassette_name="api",
)# record new interactions on first run, replay after
ZAPROS_CASSETTE_MODE=once pytest
# always hit the network and regenerate cassettes
ZAPROS_CASSETTE_MODE=all pytest
# append new interactions to existing cassettes
ZAPROS_CASSETTE_MODE=new_episodes pytest
# replay-only; fail on any unmatched request
ZAPROS_CASSETTE_MODE=none pytestValid values match the CassetteMode names (case-insensitive): all, new_episodes, once, none. An invalid value raises ValueError at middleware init. If both mode= and the environment variable are set, the explicit mode= argument wins. If neither is set, the default is once.
Playback Repeats
By default, each cassette interaction can be played back once. Requesting the same URL again raises an error:
handler = CassetteMiddleware(
None,
mode=CassetteMode.NONE,
cassette_dir=".",
cassette_name="test",
)
async with AsyncClient(handler=handler) as client:
await client.get(
"https://api.example.com/data",
) # OK
await client.get(
"https://api.example.com/data",
) # UnhandledRequestErrorTo allow repeated playback:
handler = CassetteMiddleware(
None,
mode=CassetteMode.NONE,
cassette_dir=".",
cassette_name="test",
allow_playback_repeats=True,
)
async with AsyncClient(handler=handler) as client:
await client.get(
"https://api.example.com/data",
) # OK
await client.get(
"https://api.example.com/data",
) # OKRequest Matching
Requests are matched by method and normalized URL. Query parameters are sorted before matching:
# These match the same cassette entry:
await client.get(
"https://api.example.com/search?a=1&b=2",
)
await client.get(
"https://api.example.com/search?b=2&a=1",
)Headers and request bodies are not part of the match key by default.
Modifiers
Modifiers transform requests or responses before they're recorded. They use matchers to select which requests to modify. Useful for:
- Stripping authentication tokens from cassettes
- Normalizing dynamic URLs
- Redacting sensitive data
Transform Request Keys
Map the request before it becomes a cassette key:
import asyncio
from zapros import AsyncClient, Request
from zapros import (
CassetteMiddleware,
CassetteMode,
ModifierRouter,
)
from zapros.mock import (
Mock,
MockMiddleware,
MockRouter,
)
from zapros.matchers import path
async def main():
router = MockRouter()
Mock.given(path("/api")).respond(
status=200, text="ok"
).mount(router)
router = ModifierRouter()
def strip_query(
req: Request,
) -> Request:
return Request(
req.url.without_query(),
req.method,
)
router.modifier(path("/api")).map_network_request(
strip_query
)
handler = CassetteMiddleware(
MockMiddleware(router),
router=router,
mode=CassetteMode.ALL,
cassette_name="test",
)
async with AsyncClient(handler=handler) as client:
await client.get(
"https://api.example.com/api?token=secret123",
)
asyncio.run(main())from zapros import Client, Request
from zapros import (
CassetteMiddleware,
CassetteMode,
ModifierRouter,
)
from zapros.mock import (
Mock,
MockMiddleware,
MockRouter,
)
from zapros.matchers import path
router = MockRouter()
Mock.given(path("/api")).respond(
status=200, text="ok"
).mount(router)
router = ModifierRouter()
def strip_query(
req: Request,
) -> Request:
return Request(
req.url.without_query(),
req.method,
)
router.modifier(path("/api")).map_network_request(
strip_query
)
handler = CassetteMiddleware(
MockMiddleware(router),
router=router,
mode=CassetteMode.ALL,
cassette_name="test",
)
with Client(handler=handler) as client:
client.get(
"https://api.example.com/api?token=secret123",
)The cassette stores https://api.example.com/api without the query parameter.
Transform Response Data
Map the response before it's saved to the cassette:
from zapros import Response
def redact_headers(
resp: Response,
) -> Response:
headers = dict(resp.headers)
headers.pop("set-cookie", None)
return Response(
status=resp.status,
headers=headers,
content=resp.read(),
)
router.modifier(path("/login")).map_network_response(
redact_headers
)Recorded responses won't include Set-Cookie headers.
Cassette File Format
Cassettes are stored as JSON:
[
{
"request": {
"method": "GET",
"uri": "https://api.example.com/users"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": [
{ "id": 1, "name": "Alice" }
]
}
}
]Bodies are stored based on the response content-type:
application/json(or*/*+json): inlined as a JSON value (object, array, number, etc.) so diffs stay readable.text/*: inlined as a string, decoded with the charset fromcontent-type(defaultutf-8).- Anything else (binary): base64-encoded string.