HTTP Cassettes
Cassettes record HTTP request/response interactions to disk and replay them later without hitting the network. This is useful for:
Quickstart
Record an interaction once, then replay it without network access:
import asyncio
from zapros import (
AsyncClient,
AsyncStdNetworkHandler,
)
from zapros import (
Cassette,
CassetteHandler,
)
async def main():
cassette = Cassette()
handler = CassetteHandler(
cassette,
AsyncStdNetworkHandler(),
mode="once",
cassette_dir="cassettes",
cassette_name="github_api",
)
async with AsyncClient(handler=handler) as client:
response = await client.request(
"GET",
"https://api.github.com/users/octocat",
)
print(await response.ajson())
asyncio.run(main())from zapros import (
Client,
StdNetworkHandler,
)
from zapros import (
Cassette,
CassetteHandler,
)
cassette = Cassette()
handler = CassetteHandler(
cassette,
StdNetworkHandler(),
mode="once",
cassette_dir="cassettes",
cassette_name="github_api",
)
with Client(handler=handler) as client:
response = client.request(
"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="once"
Records only if the cassette file doesn't exist yet. Useful for initial recording:
handler = CassetteHandler(
cassette,
network_handler,
mode="once",
cassette_dir="cassettes",
cassette_name="api",
)- First run: records to
cassettes/api.json - Later runs: replays from cassette, raises error for unmatched requests
mode="new_episodes"
Replays existing interactions, records new ones:
handler = CassetteHandler(
cassette,
network_handler,
mode="new_episodes",
cassette_dir="cassettes",
cassette_name="api",
)- Matched requests: served from cassette
- Unmatched requests: hit network, get appended to cassette
mode="all"
Always hits the network, always records (even duplicates):
handler = CassetteHandler(
cassette,
network_handler,
mode="all",
cassette_dir="cassettes",
cassette_name="api",
)Use for regenerating cassettes or debugging.
mode="none"
Replay-only mode. Raises error if no match found:
handler = CassetteHandler(
cassette,
None, # no network handler needed
mode="none",
cassette_dir="cassettes",
cassette_name="api",
)Use in CI to ensure tests never hit the network.
Playback Repeats
By default, each cassette interaction can be played back once. Requesting the same URL again raises an error:
cassette = Cassette()
handler = CassetteHandler(
cassette,
None,
mode="none",
cassette_dir=".",
cassette_name="test",
)
async with AsyncClient(handler=handler) as client:
await client.request(
"GET",
"https://api.example.com/data",
) # OK
await client.request(
"GET",
"https://api.example.com/data",
) # UnhandledRequestErrorTo allow repeated playback:
cassette = Cassette(allow_playback_repeats=True)
handler = CassetteHandler(
cassette,
None,
mode="none",
cassette_dir=".",
cassette_name="test",
)
async with AsyncClient(handler=handler) as client:
await client.request(
"GET",
"https://api.example.com/data",
) # OK
await client.request(
"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.request(
"GET",
"https://api.example.com/search?a=1&b=2",
)
await client.request(
"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. 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 (
Cassette,
CassetteHandler,
)
from zapros.mock import (
Mock,
MockHandler,
MockRouter,
)
from zapros.matchers import path
async def main():
router = MockRouter()
Mock.given(path("/api")).respond(
status=200, text="ok"
).mount(router)
cassette = Cassette()
def strip_query(
req: Request,
) -> Request:
return Request(
req.url.without_query(),
req.method,
)
cassette.modifier(path("/api")).map_network_request(
strip_query
)
handler = CassetteHandler(
cassette,
MockHandler(router),
mode="all",
cassette_dir="cassettes",
cassette_name="test",
)
async with AsyncClient(handler=handler) as client:
await client.request(
"GET",
"https://api.example.com/api?token=secret123",
)
asyncio.run(main())from zapros import Client, Request
from zapros import (
Cassette,
CassetteHandler,
)
from zapros.mock import (
Mock,
MockHandler,
MockRouter,
)
from zapros.matchers import path
router = MockRouter()
Mock.given(path("/api")).respond(
status=200, text="ok"
).mount(router)
cassette = Cassette()
def strip_query(
req: Request,
) -> Request:
return Request(
req.url.without_query(),
req.method,
)
cassette.modifier(path("/api")).map_network_request(
strip_query
)
handler = CassetteHandler(
cassette,
MockHandler(router),
mode="all",
cassette_dir="cassettes",
cassette_name="test",
)
with Client(handler=handler) as client:
client.request(
"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,
stream=resp.stream,
)
cassette.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\"}]"
},
}
]request: Normalized method + URIresponse: Status, headers, and body (UTF-8 encoded)