Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.