Developing Against a Live API
Everything in the two previous chapters serves a finished client library: fixtures recorded once, masked, committed, replayed strictly forever after. Developing an application against a live API with no sandbox is a different problem. The same flow is re-run all day, every run repeats every call it already made, and each of those calls costs quota, time, or a side effect on a real account. Dev mode turns the replay machinery toward that problem: one directory that replays what it already holds and records what it does not.
The trace
ReplayClient::dev(config, flow_name) runs a replay engine and a recording
client side by side over one directory, .capi_dev/<flow_name>/ beside the
application (or under CAPI_DEV_ROOT). Every request is offered to the engine
first. A key match replays; a call the trace has never seen goes out live
through the recorder and is appended. So an empty directory records everything,
a complete one replays everything, and the usual case — a flow whose last few
calls are new — pays the network only for the frontier. There is no
construction-time choice between recording and replaying, and the live
environment is touched once per step rather than once per iteration.
An application is generic over its client, so dev mode is a swap at the
construction site, switched by the ambient CAPI_DEV without recompiling:
let api = if ReplayClient::dev_requested() {
let client = ReplayClient::dev(ParcelDev, "onboard_customer");
let mut api = ParcelApi::new_with_defaults(client.clone());
api.set_base_context(client.context());
api
} else {
ParcelApi::new_with_defaults(ReqwestClient::default())
};
dev needs a live client on the same terms recording does — the bundled one
under reqwest or native-client, the Web Fetch client on browser wasm — and
dev_with_client(client, config, flow_name) takes one of your own on every
lane, which is also how an application hands dev mode its production client,
pipeline and all. dev_with_backend and dev_with_client_and_backend supply
the fixture backend as well.
What follows from any call being able to go live
Three things, each a consequence of not knowing whether a request will hit or miss until it is offered to the trace.
The clock runs live throughout. Requests are signed above the client, before hit or miss is knowable, so a signature computed against mock time would be wrong for the calls that do go out. A replayed response is served with the real clock still running.
sensitive answers with live values, and resolves them eagerly, so a
missing one fails before any traffic. The trace is matched in the form it was
written, so replayed responses come back carrying the developer’s current
live values, and refreshing a credential is a change to the environment rather
than to any fixture.
A request that matches a recorded exchange but disagrees with it is a hard
error, ReplayError::StaleFixture, not a re-recording. The recorded
tail is evidence of effects already applied to the live environment, and
re-recording from an arbitrary midpoint would replay the prefix and then issue
the changed call against state still carrying the original’s effects — the
corruption this mode exists to avoid. The error says what the two ways out are:
delete the stale pair (and, when in doubt, everything recorded after it) to force
those calls live on the next run, or hand-edit the pair, which the textual
fixture format is for.
A trace is not a fixture set
It holds the server’s real values on purpose. A placeholder replayed into the
live tail would be sent straight back to the API, so every artifact is stamped
unmasked, marked capture=dev, kept out of tests/, and refused by the
commit gate as it stands. Add .capi_dev/ to the project’s .gitignore.
Two config knobs shape what the trace keeps. route_always_live("/oauth2/token")
exchanges every request under a path live on every run — never replayed, never
recorded, never consumed from the trace — for the exchanges that must happen
fresh: a session mint, a short-lived credential endpoint whose recorded mint
would otherwise be served, byte-identical request and all, long after the token
expired. route_always_live_method(Method::POST, "/orders") narrows it to one
method, for a path whose reads may be cached but whose writes may not. And
with_record_server_errors(true) keeps a response the server failed on; off by
default, because a recorded 5xx would replay forever and one transient hiccup
would wedge the trace until files were deleted by hand. Turn it on to develop
against the error itself.
client.mock_only() on a dev client drops the recorder mid-flow: what the trace
holds still replays, and anything it does not now fails instead of going live.
client.finish() flushes pending record-time state and logs the run summary —
replayed, recorded, always-live, stale, and unconsumed counts — where you call
it rather than at an arbitrary later drop.
From trace to fixtures
A finished trace is a real record of a flow that was actually exercised, and it
holds the server’s real values — exactly what makes it unfit to commit and
perfect to remask. remask_from is the exit ramp:
CAPI_REMASK=1 CAPI_REMASK_FROM=.capi_dev/onboard_customer cargo test onboard_customer
The test runs unchanged. Its real endpoints build the real requests, so the
test’s own declarations ride them, and every response comes from the trace
rather than the network; each exchange is masked under those declarations and
promoted into the test’s fixture directory through the same fail-closed path a
live recording uses. One call to the API produced both the application’s trace
and the test’s fixtures. ReplayClient::for_test consults both switches, so the
promotion needs no harness change; remask_from(config, source, dir, name) is
the programmatic form.
Live experiments as tests
Sometimes the experiment belongs in the test suite — a probe against the live
service that must never run by accident. #[capi_test(development)] marks one:
the test is #[ignore]d out of an ordinary cargo test (which compiles, lists,
and reports it as ignored, development: …), and runs deliberately with
-- --ignored or -- --include-ignored. #[development] on a module marks
every #[capi_test] inside it.
The marker and the harness hold each other honest. ReplayClient::dev_for_test
is dev for a test, and it refuses to construct outside a development body:
a live experiment whose author forgot the marker — and which an ordinary
cargo test would therefore run — panics with the fix in the message, before
any network traffic, instead of quietly going live. Applications keep using
dev; the handshake is a test-only contract.
Seeing the exchange
Two tools show what crossed the wire when a fixture or a trace is not what you
want. ReplayClient::diagnostic(config, dir, name) captures an exchange for
inspection rather than replay — always recording, never draining, so stream
timing survives — into diagnostic/<name>/ under the root, and .unmasked()
turns masking off for it; this is pass one of the onboarding
workflow. And
capi_debug_dump’s DebugClient wraps any client and writes every exchange
that passes through it — request head, response head, trailer block, as much of
each body as asked — in the same canonical text format the fixtures use,
unredacted; it is a development tool, and a dump is the secret it contains.
Next: Verifying a Repo — the gate that runs the lanes, lints the manifest, and audits the fixtures, in any repo built on the framework.