API Integration¶
This document describes how the Vayu Manager communicates with the Vayu Engine (C++ daemon) via HTTP.
Overview¶
The app communicates with the engine through: - HTTP REST API: For CRUD operations and request execution - Server-Sent Events (SSE): For real-time load test metrics
All communication happens on localhost:9876 (configurable).
API Client Architecture¶
┌─────────────────────────────────────────┐
│ React Components │
│ (RequestBuilder, Dashboard, etc.) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Hooks + singletons │
│ (useEngine, queries/, loadTestService) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Services Layer │
│ - api.ts (HTTP client) │
│ - sse-client.ts (SSE client) │
│ - http-client.ts (fetch wrapper) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Vayu Engine │
│ (localhost:9876) │
└─────────────────────────────────────────┘
HTTP Client (services/http-client.ts)¶
Low-level fetch wrapper with error handling and timeout management.
Features¶
- Base URL Configuration:
http://127.0.0.1:9876(fromconfig/api-endpoints.ts) - Request Timeout: 30 seconds default
- Error Transformation: Converts HTTP errors to
ApiErrorwith user-friendly messages - Query Parameters: Automatic URL encoding
- JSON Serialization: Automatic request/response JSON handling
- No Caching: every request passes
cache: "no-store", matching the engine's ownCache-Control: no-storeon every response (#1507) - nothing the engine answers is valid to replay from Chromium's disk cache
Error Handling¶
class ApiError extends Error {
statusCode: number
errorCode: string
userFriendlyMessage: string
response?: any
}
Every engine error body is {"error": {"code", "message"}}, so message is the
engine's own text (a validation reason, a not-found) and errorCode is its
code - per-status (bad_request, not_found, ...) unless the route names a
more specific one. Two fallbacks sit behind that, and both matter: a body in the
legacy flat shape ({"error": "..."}, which a pre-#173 engine sends and the
sidecar's version can lag the app's) still yields its string as the message, and
a body carrying neither falls back to HTTP <status>: <statusText>. response
keeps the raw body, which is where per-error detail lives - error.item on a
failed bulk import, the provider fields on /oauth2. See
the engine's error contract.
Error Types:
- isTimeout: Request timeout
- isNetworkError: Connection/DNS errors
- isDatabaseError: Engine database errors
API Service (services/api.ts)¶
High-level service layer that wraps HTTP client with domain-specific methods.
Data Transformation¶
The service handles transformation between frontend (snake_case) and backend (camelCase) formats:
Frontend Format (snake_case):
Backend Format (camelCase):
Request bodies¶
body goes to the engine as the discriminated union { mode, content } or -
for the two form modes - { mode, fields }, built by buildExecBody
(modules/request-builder/utils/execute-mapping.ts) and passed through
untransformed. The mode strings are a contract: the engine matches
"form-data" and "x-www-form-urlencoded" exactly and reads the content out
of fields, so a renamed mode or a flattened content string sends an empty
body rather than failing. Disabled rows are sent and dropped engine-side, and the
engine writes the Content-Type each form mode implies. A form-data row may be
a file part ({type: "file", src, fileName?, contentType?}): the renderer
sends the path the user picked - never the bytes - and the engine opens the file
at send time. See the engine's body union
for the full contract.
API Methods¶
Health & Configuration¶
apiService.getHealth(): Promise<EngineHealth>
apiService.getConfig(): Promise<EngineConfig>
apiService.updateConfig(config): Promise<EngineConfig>
apiService.getRequestDefaults(scope?: RequestDefaultsScope): Promise<RequestDefaults>
getRequestDefaults reads what the engine will add to a request that names none
of it (issue #1229) - a User-Agent, a negotiated Accept-Encoding, and an
opt-in correlation id - as {headers: [{name, value?, generated, configKey?}]}.
A row with generated: true carries no value: it is made fresh per transfer.
The set is read, never derived: the engine resolves it (its libcurl decides
which encodings can even be asked for), so a renderer working it out from the
config entries would be a second definition of the same rule. The Headers tab
renders it as the read-only "Added by Vayu" group, and a row ticked off there
rides the send as disabledDefaultHeaders, which POST /execute and
POST /runs both take. None of it is persisted on the request.
scope (issue #1338) selects which send is being asked about: "design"
(the default, and the Headers tab's own answer) or "load". The two can
disagree - negotiateCompression governs a design send's Accept-Encoding
and loadNegotiateCompression governs a load run's - so the load-test dialog
reads both and names the difference rather than starting a run whose headers
silently do not match what the tab just showed.
Cookie jar¶
apiService.getCookies(): Promise<GetCookiesResponse>
apiService.clearCookies(scope?: { environmentId: string | null }): Promise<ClearCookiesResponse>
The engine keeps one cookie jar per environment for design-mode requests
(issue #301); CookiesCard in Settings → General shows and clears them.
Request examples¶
apiService.listRequestExamples(requestId): Promise<RequestExample[]>
apiService.createRequestExample(requestId, example): Promise<RequestExample>
apiService.deleteRequestExample(requestId, exampleId): Promise<void>
Saved example responses stored against a request (issue #481) - what an import
found next to it, and what a mock server will serve. Not read-only any more
(issue #588): the response viewer's Save as example keeps the response on
screen as one, and the Examples tab removes one. PUT still has no caller and
so no endpoint constant - the panel is a viewer, and editing a stored example is
its own change. The three consumers are in queries/request-examples.ts
(useRequestExamplesQuery, useCreateRequestExampleMutation,
useDeleteRequestExampleMutation); both writes settle by invalidating the one
list key rather than splicing a row in, since the engine decides the id, the
order an append lands on and the stored shape of the row.
A saved example is written with origin: "user", and an imported one keeps
the engine's "import" default. Its first reader is the OpenAPI spec sync
(#627), which may replace the examples a document produced and must never touch
one a person saved - and since #722 the panel reads it too: RequestExample
claims the field, an imported row carries an Imported chip, and the delete
dialog says what can still bring that kind of row back. The asymmetry the field
encodes is a fact about the list, so leaving it invisible made which rows the
next sync would replace unpredictable. The rest of the
payload is the importers' own mapping, contentType included (the response's
Content-Type header verbatim, "" when it stated none), so an app-saved example
and an imported one are served identically. No order is ever sent: the engine
appends, which is what keeps a restarted mock answering with the same first
example.
No transformer, unlike a request row: an example carries no timestamp the app
renders and no column that predates a schema change, so the wire shape is the
domain shape - minus the order and timestamps the RequestExample type
deliberately does not claim, since the list arrives already ordered and no
surface displays either. The stored order is the contract, not a suggestion: a mock
server answers with the first example of a matched request, so the panel renders
the list as received rather than re-sorting it.
Imported examples take a different route entirely: they ride nested on their
request item in POST /import/apply (ImportApplyRequestItem.examples), not
through this endpoint, so the whole tree still lands in one engine transaction.
Webhook inbox¶
apiService.listInboxes(): Promise<Inbox[]>
apiService.startInbox(request?: StartInboxRequest): Promise<Inbox>
apiService.stopInbox(inboxId): Promise<Inbox>
apiService.deleteInbox(inboxId): Promise<DeleteInboxResponse>
apiService.updateInboxResponse(inboxId, response: Partial<InboxCannedResponse>): Promise<Inbox>
apiService.listInboxCaptures(inboxId, limit?, offset?): Promise<InboxCapturesResponse>
apiService.clearInboxCaptures(inboxId): Promise<ClearInboxCapturesResponse>
An inbox records the requests sent to it and answers a canned response
(issue #480); modules/inbox/ is the surface. updateInboxResponse is a
merge-patch - an omitted field keeps what the inbox is serving - and the live
capture stream (INBOX_LIVE) is a plain EventSource rather than SSEClient,
which maps load-test metrics specifically. Captures arriving on that stream are
merged into the listInboxCaptures cache, so there is one list.
deleteInbox is the stronger of the two lifecycle calls: stopInbox frees the
listener and leaves the record and its captures readable for the life of the
engine process, while a delete takes both (issue #553). Its mutation removes
the captures cache entry rather than invalidating it - an invalidation would
refetch an id the engine now 404s. Inbox.captureCount is what a delete would
destroy, and is what the confirmation is worded from.
clearCookies distinguishes three cases the way the engine does, and they are
not interchangeable: omitted clears every jar, { environmentId: null }
clears only the jar used when no environment is selected, and an id clears that
environment's. It reaches DELETE /cookies with the parameter absent, present
and empty, or present with the id, respectively.
OAuth 2.0 mock issuer¶
apiService.listMockIssuers(): Promise<MockIssuer[]>
apiService.startMockIssuer(request?: StartMockIssuerRequest): Promise<StartMockIssuerResponse>
apiService.updateMockIssuer(issuerId, update: UpdateMockIssuerRequest): Promise<MockIssuer>
apiService.stopMockIssuer(issuerId): Promise<StopMockIssuerResponse>
A local issuer that mints HS256 tokens on demand (issue #479); the Services
drawer (modules/services/) is the surface, added in #502 - before it these
routes had no client but curl and the MCP tools. Two asymmetries the surface has
to respect: startMockIssuer answers with the URLs and the signing key only,
not the full record, so the list is refetched rather than patched; and a stopped
issuer leaves the list altogether, unlike an inbox, which stays listed with
running: false. updateMockIssuer accepts only expiresInSeconds,
failureMode and slowMs - a port, client list or claim set cannot move under a
bound listener and the engine refuses one with a 400 rather than half-applying
it.
Collection mock server¶
apiService.listMockServers(): Promise<MockServer[]>
apiService.startMockServer(request: StartMockServerRequest): Promise<MockServer>
apiService.stopMockServer(mockId): Promise<StopMockServerResponse>
apiService.listMockServerRoutes(mockId): Promise<MockServerRoute[]>
A loopback listener answering a collection's saved example responses on the
paths its requests describe (issue #481 phase 2). collectionId is the only
required field; latencyMs and errorRatePct are the injection knobs, and an
out-of-range value is a 400 rather than a clamp.
Two surfaces, deliberately split. CollectionDetail/MockServerControl is the
only one that can start one, because a mock needs a collection and the
Services drawer has none selected; the drawer's Mock servers group lists and
stops whatever is running, wherever it came from. Both read the same polled
list, so a mock started in one is visible in the other within a poll.
The knobs are sent from that control's options dialog and nowhere else
(StartMockServerDialog, issue #570), bounds-checked against
mock-server-options.ts before the request leaves - the engine's 400 names
the field but not the range. There is no update verb: latencyMs and
errorRatePct are read per response and so could change under a running
mock, but a run pointed at one has to be able to say which configuration
produced its numbers, so they are frozen at start like the route table.
listMockServerRoutes is polled, at MOCK_ACTIVITY_POLL_INTERVAL_MS, even
though it carries staleTime: Infinity: the route table's shape is a
snapshot taken when the mock started and a running mock does not reload the
collection, but each route's hits changes live as traffic arrives, so the
poll is what keeps that count current. staleTime: Infinity only means
mounting a second reader of the same query does not force a redundant fetch -
it does not stop the interval firing. Stopping drops the record engine-side -
unlike an inbox, which stays listed with running: false - so the mutation
removes the routes cache entry instead of invalidating it, which would
refetch an id the engine now answers 404 for.
Create vs update¶
For collections, requests and environments the engine splits the write verbs:
POST /<resource> creates and never updates; PUT /<resource>/:id updates and
answers an unknown id with 404. They are not interchangeable, so apiService
keeps one method per verb:
createX(data)posts the object to the collection path withidstripped (withoutIdinapi.ts). The engine assigns every id and answers a create carrying one with a400, and theCreate*Requesttypes declareid?: never- but TypeScript only excess-property-checks object literals, so a call site that spreads a whole record (a duplicate flow, a restored tab) would slip one through. The strip is what actually holds. (Import used to be the exception, pre-assigning ids to wireparentId/collectionIdacross a tree before anything was persisted; it sends oneapplyImportcall now, see below.)updateX(data)takesdata.id, puts it in the path, and sends the rest of the object as a merge-patch body - an omitted field keeps its stored value, an explicitnullresets it to the default. Theidis not repeated in the body; a bodyiddisagreeing with the path is a400.
The full contract, including the null-vs-absent table and which fields have no
default, is in engine/api-reference.md under
"Resource writes". src/services/api.write-verbs.test.ts pins the method and
path of every one of these calls - a regression here is invisible at every other
layer, because the payload shape does not change.
Collections¶
apiService.listCollections(): Promise<Collection[]>
apiService.createCollection(data): Promise<Collection> // POST /collections
apiService.updateCollection(data): Promise<Collection> // PUT /collections/:id
apiService.deleteCollection(id): Promise<void>
A collection carries dataSchema - the data contract it declares (issue
599): {columns?: string[], declaredAt?: number, fileName?: string}, where¶
{} means it declares none. CollectionTransformer normalizes it field by
field rather than casting, because a row can come from an engine that predates
the column and every reader treats columns as a string array; use
hasDataContract(schema) instead of hand-rolling the check, since a cleared
contract is {} and not undefined.
On updateCollection the field is CollectionDataSchema | null, like
parentId: the engine reads absent as "keep the declared contract" and an
explicit null as "reset to none", so the Data tab's Clear is only
expressible as a null that survives to the wire. The rows behind the schema are
never sent by these calls at all - they ride only the POST /runs payload, and
are persisted by neither side.
A collection also carries openapi - the spec document it is bound to
(issue #637): {specId?, specHash?, syncedAt?}, where {} means bound to
nothing. It is normalized field by field for the same reason dataSchema is,
and hasSpecBinding(binding) is the check to use - a collection that was bound
and then unbound holds {}. On updateCollection the field is
CollectionOpenApiBinding | null, and the Spec tab's Unbind is that null.
Specs¶
apiService.createSpec(data): Promise<SpecDocument> // POST /specs
apiService.getSpec(id): Promise<SpecDocument> // GET /specs/:id
apiService.getSpecMeta(id): Promise<SpecDocumentMeta> // GET /specs/:id/meta
apiService.syncSpec(payload): Promise<SpecSyncResponse> // POST /specs/sync
apiService.describeSpec(payload) // POST /specs/describe
apiService.matchSpecOperations(payload) // POST /specs/match
apiService.diffSpec(payload): Promise<SpecDiffResponse> // POST /specs/diff
apiService.bindSpec(payload): Promise<SpecBindResponse> // POST /specs/bind
apiService.exportSpec(payload): Promise<SpecExportResponse> // POST /specs/export
describeSpec is the read that replaced the last parse this app did of a spec
document (issue #869). It sends the picked bytes and gets back what they are -
the dialect ("OpenAPI 3.0", "OpenAPI 2.0 (Swagger)"), info.title, and the
identities the document declares - and the Spec tab paints its card from that and
hands those identities to matchSpecOperations. It parsed the file itself until
then, with services/openapi/spec-operations.ts, while the bind derived the same
identities engine-side from the same bytes: two readers of one document, which
could preview one pairing and commit another. A document that is not a contract,
or that will not read at all, is a 400, and the tab prints the engine's
sentence.
matchSpecOperations is a read (issue #761): it pairs the requests in a
collection's subtree with the operations a document declares and writes nothing,
which is what lets the Spec tab show the counts before the user commits to a
bind. The pairing rule lives in the engine (core/operation_match.hpp) rather
than in the renderer, so binding an existing collection is reachable from
anything that is not the Spec tab - an agent over MCP first among them. The
payload names the collection and sends the operations; it does not send the
requests, because an OpenAPI import files them under tag sub-collections and the
engine gathers the whole subtree itself.
exportSpec is a read for the same reason and sends even less (issue #855): a
collection id and a format. The engine reads the subtree, each request's stored
examples and the bound document - which is the point, since a bound export
patches those stored bytes - and answers with the finished text, a file name
and the notes the dialog prints. A 409 there is a binding whose document is
not stored, or stored bytes that will not read as OpenAPI; the renderer prints
the engine's sentence rather than falling back to a skeleton.
Neither index is sent on any write that stores a document - not by
createSpec, syncSpec or the specs section of importCollection below. The
engine reads the document it is storing and derives the operations index (#629,
moved by #853) and the responseSchemas index (#628, moved by #860) from those
very bytes, so sending either is a 400, the way a supplied hash is; a sync
that forgets one can no longer turn coverage or response validation off for a
collection that had it. The renderer stamps no identity of its own any more either
(issue #877): an import is parsed by the same reader, so the identity on a
request and the identity in the index come off one walk rather than two that had
to be pinned to each other.
Create and read-by-id only, plus the two writes that move a binding.
syncSpec is one (issue #655): it stores the re-fetched document, points
the collection at it and applies the created, updated and deleted requests in a
single engine transaction, because a sync that stopped halfway would leave a
collection bound to a document its requests do not reflect. The renderer decides
what to send (services/openapi/spec-apply.ts) and never sequences the writes
itself.
diffSpec is the read that the Sync section's Check is (issue #854). It
sends the collection and the re-fetched bytes and nothing else: the engine walks
the collection's subtree itself and reads the bound document from the binding,
because the three-way userTouched flag - the request holds neither the new
document's value nor the bound one's - is worth nothing if a caller can supply
the previous side. It writes nothing, and the answer carries, per entry, the
draft an apply would write, so spec-apply.ts builds a POST /specs/sync
payload out of it without reading a document at all. The comparison used to be
services/openapi/spec-diff.ts, which is gone.
Each entry also carries what a sync with no ticks would do to it - safe,
and safeFields on a changed request (issue #871). defaultSelection reads
those rather than deriving them from userTouched, because the rules behind
them are the ones whose silent failure destroys a person's work and they now
have one author: core::safe_spec_apply, which POST /specs/sync's
policy: "safe" applies for a caller that states no rows at all. syncSpec
still sends explicit rows from here, since the ticks a person changed are not a
policy.
The one field of that draft spec-apply.ts does not send back is
examples (issue #869): an update carries examples: true - refresh this
request's imported examples - and the engine writes the responses the document it
is storing documents. A created request states none at all, for the same reason.
Echoing the rows back was a round trip through the client of an answer the engine
had already given, and it let a payload state an example for a response no
document describes.
bindSpec is the other (issue #862), and it decides even less: the payload is a
collection and a document, with no pairing in it. Binding used to be three
calls made in order from useBindSpecMutation - store the document, move the
binding, then stamp the matched requests and clear the stale ones - and both
halves of that were defects waiting to happen: three writes are three places to
stop, and the clearing half is a list a caller can forget, which is exactly what
issue #718 was. POST /specs/bind commits all of it or none of it and works the
pairing out from the bytes it stores. matchSpecOperations above is still what
the Spec tab previews with; it is not a payload for this call, and the bind
matches again over the same rule.
A document is immutable - a changed spec is a new
document and a moved binding, which is what keeps a run's specHash stamp
meaningful - and the hash is computed engine-side on the bytes it stored,
never here. There is no delete call: unbinding is a PUT /collections/:id, and
the document stays for whatever else binds it. Both reads are cached with
staleTime: Infinity, because the row behind a given id cannot change.
Describing a document and reading one are two calls (issue #712).
getSpecMeta answers sourceUrl, fetchedAt, hash and contentBytes and
nothing else; getSpec sends content and both extracted indexes with it - up
to maxSpecDocumentBytes, which is 12 MB for Stripe's published spec. The rule
for choosing is what the caller does with the answer:
| Caller | Read | Why |
|---|---|---|
The Spec tab's card (useSpecMetaQuery) |
meta | It paints a source, a date and a size; it never renders the document |
The Sync section's Check (useSpecMetaReader) |
meta, on the click | Since #854 it needs only sourceUrl - where to re-fetch from - because the engine compares its own stored bytes |
The import dialog's bound-spec match (useBoundSpecReader) |
full, on the action | It compares the document itself |
Export needs neither read any more (issue #855): POST /specs/export assembles
the document engine-side from the bytes it already stores, so the renderer asks
for the finished text and never for the spec.
Reading the full document on tab open is the thing this split removed: a first open of a bound tab transferred the whole spec to paint two fields, on a query that then sat in cache forever. A reader that needs the text still asks for it - on the action that needs it, where the wait is something the user started.
Requests¶
apiService.listRequests(params?): Promise<Request[]>
apiService.getRequest(id): Promise<Request>
apiService.createRequest(data): Promise<Request> // POST /requests
apiService.updateRequest(data): Promise<Request> // PUT /requests/:id
apiService.deleteRequest(id): Promise<void>
A request carries specOperation - which operation of the bound spec it is
(issue #637): {operationId?, method, path}, where path is the document's
templated path and not the URL the request sends. The engine serializes
null for a request that names none, and RequestTransformer turns that into an
absent key; on updateRequest the field is SpecOperation | null, so stamping
and clearing an identity are the same verb.
A request also carries methodSource - which app setting last wrote
method, still unclaimed by the user (issue #1505): MethodSource ("graphql",
the GraphQL body mode's auto-POST) or absent. Same null-vs-absent rule as
specOperation: the engine serializes null for no marker, RequestTransformer
turns that into an absent key and drops a value it does not recognize, and
updateRequest's field is MethodSource | null, so leaving GraphQL - or
picking a method by hand - clears the marker the same way.
Trash¶
apiService.listTrash(): Promise<ListTrashResponse> // GET /trash
apiService.restoreTrashEntry(id): Promise<RestoreTrashResponse> // POST /trash/:id/restore
apiService.purgeTrashEntry(id): Promise<PurgeTrashResponse> // DELETE /trash/:id
What deleteCollection and deleteRequest now do is a soft delete (issue
988): the engine stamps the row instead of removing it, and these three read¶
that stamp back. No transformer on any of the three - a trash entry is the engine's own summary of a deleted row (a name, a stamp and two counts), not a stored record the app reshapes, so the wire shape is the only shape.
listTrash returns roots only - the row a user actually asked to delete,
never a descendant the same cascade took with it. A deleted collection's whole
subtree is represented by that one root entry, with collections / requests
counting what that delete took along.
restoreTrashEntry puts one deleted root back, with everything the same
delete took, and answers with the entry plus restored: true and
reparentedToRoot - true when the collection's parent is gone or itself in
the trash, so the engine clears its parent and returns it as a tree root
instead. It rejects with a 404 for an id the trash does not hold (a live row,
or one already purged) and a 409 for a request whose collection is itself
deleted or gone - a request has no root to land on the way a collection does,
so the engine's message names the collection to restore first; surface
ApiError.message rather than inventing wording for it.
purgeTrashEntry is the hard purge the soft delete deferred - it destroys the
row for good, with its whole subtree, and there is no undo. It answers with the
entry plus purged: true, or a 404 if the trash does not hold that id.
The full contract - request/response bodies, the restore's cohort and re-parent rules, and the startup retention sweep - is in engine/api-reference.md under Trash.
Reorder¶
The write path behind a drop. One call repositions any number of collections and
requests, and the engine applies the whole batch in one transaction - so a drop
that displaces N siblings is one round trip, not N updateRequest calls that can
half-land and race a concurrent create into the middle of their range.
The payload carries moves (each row's new order, plus parentId /
collectionId when it changes owner) and normalize (scopes to renumber dense
0..n-1 in display order first, for a collection whose rows all predate explicit
orders). modules/collections/reorder-math.ts computes both from the sibling
lists the tree is already showing; the full contract is in
engine/api-reference.md under POST /reorder.
Unlike the other writes, the response is read: it is the rows as written, and
useReorderMutation settles its caches on them so a normalization the engine
performed shows up without waiting for the refetch.
Environments¶
apiService.listEnvironments(): Promise<Environment[]>
apiService.getEnvironment(id): Promise<Environment>
apiService.createEnvironment(data): Promise<Environment> // POST /environments
apiService.updateEnvironment(data): Promise<Environment> // PUT /environments/:id
apiService.deleteEnvironment(id): Promise<void>
Global Variables¶
apiService.getGlobals(): Promise<GlobalVariables>
apiService.updateGlobals(variables): Promise<GlobalVariables>
Import¶
apiService.importFetch(url, maxBytes?, onProgress?): Promise<ImportFetchResponse> // POST /import/fetch
apiService.applyImport(payload): Promise<ImportApplyResponse> // POST /import/apply
importFetch's maxBytes is the largest response that fetch may read. The
engine has no format to derive one from - the route proxies Postman and Insomnia
exports as well as OpenAPI documents - so the caller states it: the spec paths
($ref bundling, spec re-fetch, the Spec tab's URL box) pass the live
maxSpecDocumentBytes from useSpecDocumentLimit, and the format-agnostic
import URL box passes nothing, leaving the engine's transport ceiling. Over the
bound is a 413 whose message names it, raised while the body is arriving
rather than after it has been buffered whole.
importFetch's third argument is what asks the engine to stream the download
(issue #882): with an onProgress
callback the call goes through httpClient.stream, which sends
Accept: text/event-stream and reports {received, total} as the bytes land -
total being null whenever the upstream declared no Content-Length. Without it
this is the buffered POST it has always been, which is what $ref bundling and
spec re-fetch want: they have nowhere to draw a bar and no reason to pay for a
stream.
Two things httpClient.stream does that request cannot. Its timeout is an
idle one, so a 10 MB document arriving steadily is no longer racing
proxiedRequestTimeoutMs - what is bounded is the stall, not the transfer. And a
response that comes back as buffered JSON yields one buffered message instead
of failing, because the app and the engine sidecar are not updated together and
an older engine answers this request the way it always has.
importFetch takes a fourth argument, an AbortSignal, and it is what actually
stops a download (issue
#893). Abandoning the iteration
releases the stream, but a caller that is not itself iterating - a dialog closing
while importFetch sits in the loop on its behalf - has no way to reach that, so
0.22.0 shipped a transport that could cancel and a dialog that never asked it to.
An abort raises an AbortError, deliberately distinct from the idle stall's
Request timeout: both end in an AbortController, and a deliberate cancel
reported as a timeout is an error banner about a failure nobody had.
applyImport sends a whole parsed import - collections, requests, environments
and spec documents - in one atomic call. Items reference each other by
opaque tempIds and the engine
returns the temp-id -> real-id idMap; a rejected payload persisted nothing, so
there is nothing to roll back. An OpenAPI import puts the document in the
specs section and binds its root collection with openapi.specTempId, so the
spec, the binding and every request's specOperation land in the same
transaction - specs: [] is sent for every other format rather than omitted, so
the payload is one shape. Imported globals are not in this payload: they
are a singleton written through updateGlobals after the apply succeeds. See
import-collections/README.md
for the pipeline and
engine/api-reference.md for the
contract.
Execution¶
apiService.composeRequest(data): Promise<ComposedRequest>
apiService.executeRequest(data): Promise<SanityResult>
apiService.startLoadTest(data): Promise<StartLoadTestResponse>
apiService.startScenarioRun(data): Promise<StartLoadTestResponse>
startLoadTest and startScenarioRun are the same POST /runs endpoint and
the same 202 {runId} answer - the payload is what selects the executor. A
scenario states its work as an ordered collection
({scenario: {source: "collection", collectionId, recursive?, iterations?, data?}}),
so it carries no method/url, and its iteration count lives
inside the block rather than beside a load-test mode. data is the parsed rows
of a data file (services/data-files/), sent inline because the engine never
opens a file; iterations is omitted when the user left it blank, so the
engine's "absent means one pass per row" rule stays in one place. Both send allowScriptRequests,
for the same reason: every step runs the scripts a Send of that request would
run. The engine resolves the whole plan before answering, so an empty
collection, a step that will not compose, or a plan over maxScenarioSteps is a
400 with no run row created - a failed start leaves nothing to clean up.
startLoadTest takes rows too, as a top-level data array (issue #993):
the load dialog's file picker parses the file and sends the same array it
previewed, and the engine binds one row per request the run sends. Omitted when
no file was picked - a present-but-empty array is refused engine-side, so
"no data set" has to be the absent key rather than [] - and never sent beside
a scenario block, whose rows are scenario.data and bind per iteration.
failOnSchemaError rides the design-mode payload beside environmentId, from
the dialog's Fail steps on schema errors switch (issue #720), and is
omitted when off: the engine defaults it to false, so absent already says
what the user asked for, and only a run that wanted the gate carries the key
into its stored snapshot - where SampledSchemaValidation reads it back to say
so. It is never sent on a load payload, whose executor validates after the run
has drained and cannot demote a step on it.
Adding a load mode beside the block makes it a scenario load run (issue
357): the same plan, driven by concurrency virtual users on the event loop.¶
The presence of mode is the whole discriminator, so a design-mode payload
must carry none at all - not a falsy one - and RunCollectionDialog therefore
spreads the load fields in rather than always sending them. constant_rps, and
any non-zero rps/targetRps on any mode, is a 400: an open-loop arrival
rate over a multi-step sequence is an arrival-rate executor the engine does not
implement, and it is refused rather than quietly run closed-loop. Such a run's
type is load, so it streams metrics ticks and not step events - the
caller must attach loadTestService, not scenarioRunService.
A scenario run also takes a top-level elements override (issue #1495, wired
into RunCollectionDialog by issue #1552):
{timers?: "asConfigured" | "off", scripts?: "asMarked" | "allInline" | "allDeferred", includeScriptTime?: boolean},
documented in full at
api-reference.md.
It rides beside mode/scenario, never inside the scenario block, and
RunCollectionDialog sends only the fields the user changed from the engine's
own default - the same omitted-when-default rule failOnSchemaError follows.
timers is wired to timer.pacing/timer.think end to end and applies to a
design-mode run exactly as it does a load run, so the dialog offers that
control regardless of Load test; scripts picks whether a script.* element
runs inline on the event-loop worker or stays deferred to the post-run replay,
and has no effect on a design-mode run (which already runs every script
inline), which is why the dialog offers that one control only once Load test
is on. Since issue #1594 the single-request startLoadTest payload has an
elements attachment point too - its own step-level elements ride under
requestElements instead (a distinct key, because this endpoint's own
elements is the override block, not a script source) - and
LoadTestConfigDialog sends the same elements: {scripts} override there
once the user changes the dialog's Scripts control from its "asMarked"
default, the one control the single-target payload's own preRequestScript
warning needs: "allInline" is what makes a pre-request script actually
reach the wire under load, since an unmarked one otherwise never runs on this
path either. LoadTestConfigDialog offers no Timers twin, but not because
the engine ignores the override there - elements.timers applies to a single
target's own timer.think exactly the way it does to a scenario step's - the
dialog simply has no timer control of its own on this path to pair it with
yet.
What comes back for one differs in two places worth knowing. GET /runs/:id
returns the resolved manifest in place of the block that was sent
({source, collectionId, recursive, iterations, dataRowCount, steps[]}, each
step {index, requestId, name, method, url} with the stored url, never a
composed one) - that is what the run tab's context bar reads, through
run-scenario.ts. The paginated GET /runs list row cannot carry the manifest
and instead carries summary.scenario
({collectionId, iterations, recursive, stepCount}), present on any row whose
snapshot carries a scenario block - so a scenario load run gets it too. The
history row reads it because a run whose work is a sequence has no url or
method for the ordinary row to show, and that is true of both executors.
A scenario load run's report carries the per-step breakdown the design-mode
runner has no need for: scenario.steps[]
({index, name, requestId, method, executed, errors, latency:{min,p50,p95,p99,max}})
plus virtualUsers and iterationsAbandoned. It stores no per-step results
rows, so that array is the only per-step record such a run keeps.
A step also carries tests ({sampled, passed, failed}) when its own
post-request script was replayed against that step's sampled responses - the
deferred per-step validation. The key is absent for a step that asserted
nothing or whose script drew no sample, which is not the same claim as zero
failures, so the table shows a dash there rather than a 0.
A design run's list row carries one thing the detail route says at greater
length: resultSummary ({statusCode, latencyMs}), the outcome of its single
exchange. GET /runs/:id attaches the whole result instead, trace and bodies
included, which is why the list carries the two numbers rather than that - and
why the context bar's Recent sends section is one list call and no report fetch.
Load and collection runs carry no resultSummary: their results are unbounded.
composeRequest (POST /compose, issue #226) resolves {{variables}} and
inherit auth engine-side and returns the payload the other two accept
unchanged - every send site composes first, so nothing is interpolated twice.
GraphQL schema introspection is a send site too (lib/graphql/introspect.ts,
issue #228): it composes the endpoint, overlays the introspection query onto the
composed url / headers / auth, and executes that - which is how an
endpoint whose credentials live in the Auth panel gets introspected at all. It
sends the composed request's auth but neither its body nor its script parts.
A Send-with-row carries one extra field (issue #601): data, the row the
UrlBar's caret picked, added beside the composed payload rather than passed
through POST /compose. {{data.*}} survives composition by design, so the
tokens are still written when /execute binds them against the row; composing
the row in would be composing twice. Both send handlers take it - buffered and
streaming - because the engine binds a row on either path, and a row silently
dropped on one of them is the written-but-never-read defect. An ordinary Send
passes no argument at all, so its payload is byte-identical to what it was.
The row's names do reach composition, though (issue #1007): composeForSend
sends dataColumns: Object.keys(row) on POST /compose, because a bare
{{username}} is a name the scopes can answer and composition has to be told
to leave it for the bind instead. Names only - a value there would be this row's
value written into a payload composed once - and absent for an ordinary Send, so
that path is still byte-identical. LoadTestConfigDialog carries the same field
for a run given a data file (config.dataColumns, the picker's parsed column
list rather than a re-union of the rows' keys), and a collection run sends
none: the engine composes those steps itself and fills the set from the run's
own rows.
It is the only send site that sets transient: true (issue #382), because
it is the only one the user did not initiate. The engine then runs it in full
and records nothing: no History entry, no result trace holding the credentials
composition resolved, and no retention prune evicting a real run. It also
carries the target's environmentId onto the execute payload, which is what
scopes the engine's cookie jar - without it a cookie-session endpoint answered
real requests and failed introspection alone. Every other send site omits the
flag and is recorded as usual; see
api-reference.md.
Run Management¶
// Paginated, filtered history (newest first). Rows carry a compact `summary`
// (url/method/mode/duration/concurrency/comment), not the full configSnapshot.
apiService.listRuns(params?: RunListParams): Promise<RunListResponse>
// Every page as a flat list (Settings' count + clear).
apiService.listAllRuns(params?): Promise<Run[]>
apiService.getRun(id): Promise<Run> // full configSnapshot
apiService.getRunReport(id): Promise<RunReport>
// Response headers/bodies captured for a run's samples. Its own request, not
// fields on the report - see below.
apiService.getRunSamples(id, { limit?, offset? }): Promise<RunSamplesResponse>
apiService.stopRun(id): Promise<StopRunResponse>
apiService.deleteRun(id): Promise<void>
deleteRun on a run that is still in progress stops it engine-side first, so it
takes as long as the stop does, and it rejects with a 409 if the run's worker
has not finished writing in time - nothing is deleted in that case. Callers must
handle that rejection: HistoryList turns it into a toast telling the user to
retry, and Settings' Clear run history already counts per-run failures through
Promise.allSettled. The wording of that toast is the caller's, keyed off
statusCode, rather than the engine's message - which is a caller's choice now
that httpClient reads the message on every error shape (issue #173), not a
constraint.
Captured response bodies are fetched separately, and lazily. A load run
stores the response headers and body for its failures, its slow outliers and a
few exemplars of each status code; GET /runs/:id/report deliberately does not
carry them, because that endpoint loads and JSON-parses every result row for the
run on each fetch and the dashboard polls it. useRunSamplesQuery(runId, enabled)
(queries/runs.ts) wraps getRunSamples and is enabled only once a reader
expands a sample - passing true unconditionally would reintroduce exactly the
cost the split exists to avoid. It returns a Map keyed by resultId, joined
against report.results[].id.
Two surfaces consume it - the dashboard's Sampled Requests
(RequestResponseView) and the history Samples tab - and both render
CapturedResponseNotice (truncated / dropped for budget / binary) and
CapturedDataWarning (the run stored responses verbatim, including anything
credential-shaped). Both notices live in components/shared, so the wording
exists once rather than twice.
A sample carries the response side only, so its request headers are the
composed ones. GET /runs/:id/samples returns response headers and body; what
a sample viewer shows for the request comes from the run's composed request, not
from a sent record, because the load transport keeps none. So a sampled capture
can differ from the wire in the two ways the design-mode sentHeaders record
exists to state (issue #664): an enabled header whose value is empty is listed
although libcurl dropped it, and the two the engine derives at send time - the
body-implied Content-Type and the default User-Agent - do not appear. For
load samples this is recorded as permanent (issue #677 item 7): which
completions are sampled is decided when they finish, so a record for the few
that are kept would have to be built for every transfer. Design-mode traces
store sentHeaders and do not diverge.
A sample whose transfer was a stream also carries response.events (issue
657) - {items, totalEvents, eventsTruncated}, parsed engine-side out of the¶
stored text/event-stream body. Both surfaces render it through the shared
ResponseEvents, the component the request builder's Events tab uses, so a
sampled stream reads the same way a sent one does. Absent - not an empty node -
for every sample that did not stream, which is what gates the tab.
Scripting¶
OAuth 2.0¶
apiService.fetchOAuth2Token(data): Promise<OAuth2TokenResponse> // POST /oauth2/token
apiService.getOAuth2TokenStatus(cacheKey): Promise<OAuth2StatusResponse> // GET /oauth2/token?key=
apiService.clearOAuth2Token(cacheKey): Promise<void> // DELETE /oauth2/token?key=
// Interactive Authorization Code flow (engine-hosted loopback + PKCE)
apiService.startOAuth2Authorize(data): Promise<OAuth2AuthorizeStart>
apiService.getOAuth2AuthorizeStatus(attemptId): Promise<OAuth2AuthorizeStatus>
apiService.completeOAuth2Authorize(attemptId, callbackUrl): Promise<OAuth2AuthorizeStatus>
These back the OAuth 2.0 auth editor. TanStack Query wraps the non-interactive
ones in queries/oauth.ts (useOAuth2TokenStatusQuery - polls status ~30s;
useFetchOAuth2TokenMutation, useClearOAuth2TokenMutation). The token
cacheKey is computed client-side by services/oauth/cache-key.ts, byte-identical
to the engine so the app and engine agree on cache slots without a round-trip.
The interactive flow is orchestrated in services/oauth/authorize.ts (opens the
system browser or an embedded Electron window, then polls the engine).
HttpClient.deletetakes an optionalparamsargument so the token-clear call can pass?key=.
SSE Client (services/sse-client.ts)¶
Server-Sent Events client for a run's live stream - load-test metrics, and a collection run's per-step progress.
Features¶
- Single endpoint: Connects to
/runs/:runId/live. The engine retains a replayable tick topic, so the client connects immediately afterPOST /runswith no attach race - it replays from offset 0 and tails to thecompleteevent (even for sub-second runs). - No custom reconnect loop: The engine sends an explicit
completeevent at normal run end, so aCLOSEDreadyState is treated as terminal. TransientCONNECTINGerrors are left to the browser's built-inEventSourceretry. At run end the app converges on the stored report (GET /runs/:id/report) rather than reconnecting to the stream. - Event Handling:
metricsevents,stepevents,monitorevents,planevents,completeevent,errorhandling - One client, and a hand-off when it changes hands: the client is a singleton, so a second
connecttakes the socket from whoever held it. That subscriber'sonSupersededruns first, before the new stream opens, so it can give up its wake lock and its OS progress claim (issue #1417).onCloseis not used for this: a close says the run ended and the stored report is worth fetching, where a takeover says only that the app stopped watching a run the engine is still executing.disconnect()tells nobody - a subscriber hanging up on itself, and a stream that ended on the engine'scompleteframe, are not superseded by the next run to start - Metrics Parsing:
mapSseMetrics()transforms the engine's camelCase blob to the frontendLoadTestMetricsshape (includes drops, queue-wait, percentiles, bytes, status-code map) - Step Parsing:
parseStepEvent()narrows a scenario run'ssteppayload and returnsnullfor one it cannot read. A malformed event is dropped, never defaulted - the step list keys on(iteration, stepIndex), so a defaulted0:0would collide with the real first step's row rather than merely say nothing. - Monitor Parsing:
parseMonitorEvent()narrows amonitorframe the same way and returnsnullfor one it cannot read. A sample defaulted totimestamp: 0would join onto the very start of the run's timeline and draw a reading at a moment it was never taken; individual non-numeric entries are dropped, because the rest of the scrape is still real data.
Usage¶
sseClient.connect(
runId: string,
onMessage: (metrics: LoadTestMetrics) => void,
onError: (error: Error) => void,
onClose: (status: SSETerminalStatus) => void, // how the run ended, or null
onStep?: (step: ScenarioStepEvent) => void, // scenario runs only
onMonitor?: (sample: MonitorSample) => void, // runs with a `monitor` block only
onPlan?: (plan: ScenarioRunPlanEvent) => void, // scenario runs only
onSuperseded?: () => void // another run took the client
);
sseClient.disconnect();
sseClient.isConnected(): boolean
Event Types¶
metrics: Real-time metrics update (JSON payload). Load runs only - a scenario run's work is sequential, so a per-tick aggregate would describe one request at a time.step: One step execution of a scenario run -{iteration, stepIndex, name, outcome, statusCode, latencyMs}. Listened for only whenonStepis passed, since a load run never emits one.monitor: One scrape of the run's monitored endpoint -{timestamp, series}. Listened for only whenonMonitoris passed. Interleaved withmetricsticks on one id space, soLast-Event-IDresume replays both in the order they happened.complete: The run reached a terminal statuserror: Connection error (triggers reconnection)open: Connection established
API Endpoints (config/api-endpoints.ts)¶
Centralized endpoint configuration:
export const API_ENDPOINTS = {
BASE_URL: "http://127.0.0.1:9876",
// Health & Config
HEALTH: "/health",
CONFIG: "/config",
// What a send adds on its own, resolved from config by the engine.
// `?scope=design|load` selects which send - see getRequestDefaults above.
REQUEST_DEFAULTS: "/request-defaults",
// Collections
COLLECTIONS: "/collections",
COLLECTION_BY_ID: (id: string) => `/collections/${id}`,
// Requests
REQUESTS: "/requests",
REQUEST_BY_ID: (id: string) => `/requests/${id}`,
// Batch reorder for both entity kinds - one drop, one call, one transaction
REORDER: "/reorder",
// Cookie jar - GET reads every scope, DELETE clears one or all
COOKIES: "/cookies",
// Workspace backup - one VACUUM INTO snapshot into `backups/` beside the
// database, with retention. A verb path for the reason the inbox uses one:
// the engine takes a snapshot and the file is not a row anything reads back.
// There is no restore counterpart on purpose - restoring is a manual copy
// with the engine stopped, which is why the card prints the path.
WORKSPACE_BACKUP: "/workspace/backup",
// Execution
EXECUTE_REQUEST: "/execute",
START_LOAD_TEST: "/runs",
// OAuth 2.0
OAUTH2_TOKEN: "/oauth2/token",
OAUTH2_AUTHORIZE_START: "/oauth2/authorize/start",
OAUTH2_AUTHORIZE_COMPLETE: "/oauth2/authorize/complete",
OAUTH2_AUTHORIZE_STATUS: (id: string) => `/oauth2/authorize/${id}`,
// Runs
RUNS: "/runs",
RUN_BY_ID: (id: string) => `/runs/${id}`,
RUN_REPORT: (id: string) => `/runs/${id}/report`,
RUN_STOP: (id: string) => `/runs/${id}/stop`,
// Captured response headers/bodies, fetched only when a sample is expanded
RUN_SAMPLES: (id: string, limit: number, offset: number) =>
`/runs/${id}/samples?limit=${limit}&offset=${offset}`,
// Webhook inbox - engine-hosted listener that records what is sent to it.
// START is a verb path: an inbox lives for the engine process, so the
// POST-creates/PUT-updates split does not apply to it.
INBOX: "/inbox",
INBOX_START: "/inbox/start",
INBOX_STOP: (inboxId: string) => `/inbox/${inboxId}/stop`,
// PUT patches the canned response; DELETE removes the inbox and its captures.
INBOX_BY_ID: (inboxId: string) => `/inbox/${inboxId}`,
INBOX_CAPTURES: (inboxId: string, limit: number, offset: number) =>
`/inbox/${inboxId}/requests?limit=${limit}&offset=${offset}`,
INBOX_CAPTURES_CLEAR: (inboxId: string) => `/inbox/${inboxId}/requests`,
INBOX_LIVE: (inboxId: string) => `/inbox/${inboxId}/live`,
// Collection mock server - a loopback listener answering the collection's
// saved examples. Verb paths for the same reason the inbox uses them. No PUT
// and no DELETE: the route table is a start-time snapshot, so changing what a
// mock serves means starting another one, and stopping is what ends it.
MOCK_SERVER: "/mock",
MOCK_SERVER_START: "/mock/start",
MOCK_SERVER_STOP: (mockId: string) => `/mock/${mockId}/stop`,
MOCK_SERVER_ROUTES: (mockId: string) => `/mock/${mockId}/routes`,
// Real-time stats (SSE)
METRICS_LIVE: (runId: string) => `/runs/${runId}/live`,
// Time-series metrics (JSON, paginated) - used to hydrate history
STATS_TIME_SERIES: (runId: string, limit = 5000, offset = 0) =>
`/runs/${runId}/metrics?limit=${limit}&offset=${offset}`,
// Server vitals scraped during the run (JSON, paginated, same envelope).
// Fetched by the history view only when the report says the run recorded some.
RUN_MONITOR: (runId: string, limit = 5000, offset = 0) =>
`/runs/${runId}/monitor?limit=${limit}&offset=${offset}`,
};
Note: the old
STATS_STREAMSSE constant was removed - live metrics go throughMETRICS_LIVEonly;/statsis now used solely for paginated historical reads.
Request Execution Flow¶
Single Request Execution¶
- User Action: Clicks "Send" in RequestBuilder
- Request Transformation: Frontend format → backend format (raw - no
client-side
{{variable}}resolution) - Composition:
apiService.composeRequest()→POST /compose- the engine resolves{{variables}}andinheritauth against the request'scollectionIdchain and the activeenvironmentId, and returns the execute-ready payload (issue #226) - API Call:
apiService.executeRequest()with the composed payload, unchanged →POST /execute, carryingstream: falseexplicitly - Response Transformation: Backend format → frontend format
- Display: Response shown in ResponseViewer
stream is sent on every execute, never elided. It follows the same
never-elide rule as httpVersion and the redirect policy, for a sharper
reason: the endpoint's two answers are different shapes, so a caller that let
an engine-side default decide would not know which one it was about to parse.
Streaming Request Execution (issue #574)¶
A request whose Event stream setting is on takes the same composition and a
different answer. composeForSend is shared with the buffered path - the two
must put the identical request on the wire, or a stream would measure something
Send does not - and only the last two steps differ:
- API Call:
apiService.executeStreamRequest()→POST /executewithstream: true, answered202 {runId, eventsUrl, status}at once. There is no exchange yet: the engine has created the run row and handed the transfer to a managed consumer worker. - Tail:
useExecutionEventsopens anEventSourceon the answer'seventsUrlas given - the engine names where its own events are, and a second spelling of that path inapi-endpoints.tswould be a copy that can disagree with the answer. Frames land inexecution-events-store; the Events tab renders them live. - Swap to stored truth: on the relay's
completeframe the provider fetchesGET /runs/:id/reportandrestore-response.tsmaps the trace'seventsnode onto the response, which is the record from then on.
A different EventSource from the SSEClient singleton below. That client
belongs to load and scenario runs, is a single connection whose lifetime is the
dashboard's, and deliberately never reconnects. This one owns its retry, for
the reason the inbox capture stream does (issue #506): EventSource treats any non-200
as fatal, and a reconnect landing inside the engine's stale-claim window meets
a 409 run_events_in_use from the claim the previous socket still holds - so a
single unlucky disconnect would otherwise end the stream for the life of the
tab, silently. Resume travels as ?lastEventId=, which picks up at the frame
after the one named, so a dropped consumer re-renders nothing.
An answer missing runId or eventsUrl is a malformed answer and throws,
rather than leaving the pane on "streaming" with no run to stop. Refusals -
stream with transient, or a stream cap on a payload that declares no stream -
come back as a 400 the user has to read, so they are rendered as the response
and raised as a toast. A script is no longer one of them: scripts run on a
streaming send (issue #575), the pre-request half before the transfer and the
post-request half once the stream has terminated.
The payload carries allowScriptRequests here for the same reason the
buffered one does (issue #653): the asker is a user at the request editor
pressing Send, and the Event stream setting describes the shape of the
answer, not what that user's scripts may do. The engine reads the flag before it
branches on stream, so a pm.sendRequest behaves identically with the toggle
on and off. While only the buffered call sent it, the same button allowed a
script-issued request one way and refused it the other.
The renderer sends the inline compose shape ({ request, collectionId,
environmentId }) rather than compose-by-id, because Send executes the editor
state - possibly unsaved, or a detached History replay copy that has no saved
row at all. requestId is attached to the execute payload afterwards purely to
link the run to the saved request in History; environmentId scopes both
composition and the engine's script context / variable persistence.
useVariableResolver() still exists but is preview-only (tab titles,
previews, unresolved-token painting) - see
variable-resolution.
Auth (bearer/basic/api-key/oauth2) is resolved engine-side from the request's
auth object - the app no longer builds Authorization headers itself. When a
non-interactive OAuth 2.0 token can't be obtained, the response carries an
errorCode of AUTH_REQUIRED (interactive sign-in needed) or AUTH_FAILED, and
the request builder surfaces a toast pointing the user at the Auth tab.
Example Request:
await apiService.executeRequest({
method: "GET",
url: "https://api.example.com/users",
headers: { "Authorization": "Bearer {{token}}" },
elements: [
{
id: "el_1",
kind: "script.pre",
enabled: true,
config: { script: "console.log('Pre-request');" },
origin: { kind: "request", id: "req_123" }
},
{
id: "el_2",
kind: "script.post",
enabled: true,
config: {
script: "pm.test('Status 200', () => pm.expect(pm.response.code).to.equal(200));"
},
origin: { kind: "request", id: "req_123" }
}
],
followRedirects: true,
maxRedirects: 10,
httpVersion: "auto",
verifySSL: true,
requestId: "req_123",
environmentId: "env_456"
});
elements (issue #1512) is the resolved, ordered list of typed behaviours to
run - the collection chain's (root→leaf), then the request's own, minus
whatever the request's own inherit.disable entries name, each stamped with
its origin. A script is one of these now (kind: "script.pre" /
"script.post", config.script the text) - it replaces the old
preRequestScripts / postRequestScripts ScriptPart[] fields, which the
engine now refuses outright (400 naming elements). The renderer builds the
list from its editor state (elementsParts() in
request-builder/utils/elements-parts.ts, generalizing the old
scriptParts()) and it rides through POST /compose untouched - script text
is never interpolated; the by-id compose path (used by MCP) resolves the same
list engine-side (compose_elements). The engine runs each element at its
kind's phase - see docs/engine/elements.md.
Redirect policy, protocol and TLS verification are always sent, never
elided. followRedirects, maxRedirects, httpVersion and verifySSL all
come from the request's Settings tab and are included on every execute even
when they equal the defaults. The engine defaults follow_redirects to true, so omitting a
false would follow the 3xx the user asked to inspect - a bug the app shipped
with for a long time, when nothing in the renderer sent these fields at all.
verifySSL carries the same rule for a sharper reason: the engine verifies
unless told otherwise, so an omitted false verifies the certificate the user
turned verification off for, and the send fails against the one host the
setting exists for. All four go out with startLoadTest(), so a load test
exercises the same policy, protocol and trust the request was configured with - there is no
separate, load-test-only protocol control; the Settings tab's picker is the
only one, and it governs Send and load test alike. httpVersion is
"auto" | "http1.1" | "http2": "auto" lets ALPN negotiate, "http1.1"
forces HTTP/1.1, and "http2" attempts h2 over TLS with a silent fallback to
1.1 over plain http:// (curl's CURL_HTTP_VERSION_2TLS semantics).
Example Response:
{
status: 200,
statusText: "OK",
headers: { "content-type": "application/json" },
body: { users: [...] },
bodyRaw: '{"users":[...]}',
httpVersion: "HTTP/1.1",
httpVersionDowngraded: false,
clientCertificate: "",
bodyCapped: false,
timing: { total: 150, dns: 10, connect: 20, ... },
testResults: [
{ name: "Token was issued", passed: true, source: "pre" },
{ name: "Status 200", passed: true, source: "test" }
],
consoleLogs: [
{ source: "pre", level: "log", message: "Pre-request" },
{ source: "test", level: "warn", message: "slow response" }
]
}
testResults entries name the script that asserted them, in execution order:
pm.test runs in a pre-request script too, and the Tests pane groups the list
by source so an assertion made before the request went out does not read as
one about the response (issue #810). An entry restored from a trace written
before that carries no source, and is read as the test script's.
consoleLogs entries name the script that wrote them and the console.* level
that was called. A bare string is the pre-structured shape an older engine
sidecar sends; console/parse-logs.ts decodes both and is the only place that
knows the difference (see
the engine API reference).
httpVersion here is the negotiated protocol ("HTTP/1.1" / "HTTP/2" /
"" when nothing was negotiated) - an outcome, not an echo of the request's
own httpVersion. The Raw tab in the response viewer prints it on the
request/status line instead of a hardcoded HTTP/1.1.
httpVersionDowngraded says the request asked for http2 and the connection
negotiated something older - the one thing neither httpVersion can say alone,
since neither knows about the other. The engine computes it, and the renderer
carries it as-is rather than comparing the negotiated protocol against the
tab's current setting: a restored or replayed response sits beside request
state that may have changed since, and the answer belongs to the exchange.
ResponseStatusBar draws it as a warning beside the status chip, and only when
it is true. Load runs carry the whole-run count as
report.summary.httpVersionDowngraded, which LoadTestDetail shows next to
the requested protocol - without it a run measured entirely over HTTP/1.1 still
read "HTTP/2" there
(#215).
clientCertificate names the client-certificate registry entry the exchange
presented, as host or host:port, and "" when none matched (issue #707).
It is the entry's own spelling, so a wildcard row reads as *.example.com
rather than as the host that was dialled - which is the point: it says which
row answered (issue #803).
The renderer maps that empty string to undefined and ResponseStatusBar draws
a chip only when there is one - the same show-it-only-when-it-happened rule the
downgrade warning follows. Nothing on the request names a certificate (the
engine matches one per transfer by host), which is exactly why the response
has to say which was used: two calls to what looks like the same API can differ
only in which registry entry matched. The stored trace carries the same value
under the same key, so responseFromExecuteResult and responseFromRunResult
agree - see client-certificate-funnels.test.ts.
bodyCapped says the engine stopped reading this response at
maxDesignResponseBodyBytes (Settings → Limits, default 32MB), so body,
bodyRaw and bodySize describe the prefix it read rather than what the server
sent (issue #1157). The status and headers are the server's own, so this is a
successful response carrying a flag. It is always present on the live body -
absent means an engine too old to say, not "not capped" - and the stored trace
carries the same key under trace.response.bodyCapped, written only when it
happened, so responseFromExecuteResult normalises its false to undefined
and the two funnels agree (body-capped-funnels.test.ts).
The renderer keeps it strictly apart from bodyTruncated, which is
maxTraceBodyBytes shortening a stored body after the whole of it was read:
a re-send recovers from that one, while a capped read is reproduced by a
re-send and only a bigger maxDesignResponseBodyBytes changes it. The response
pane renders one Callout per fact and both can be on screen at once - "Body
truncated for storage" tells the user to re-send, "Body capped while reading"
tells them to raise the setting, and collapsing the two into one notice would
give the wrong instruction to half the cases.
A third limit is the renderer's alone and reaches no wire field:
LARGE_BODY_BYTES (2MB, shared/response-viewer/utils.ts). Above it
responseFromExecuteResult stops building the indented body copy - it hands
back bodyRaw, which is what would be displayed anyway - and ResponseBody
stops formatting, hides the view toggle and shows the first 2MB of the raw body
under its own notice. Both are about rendering cost, not about what arrived.
report.sampling carries what each of the run's bounded stores thinned away -
successTracesDropped / slowTracesDropped for the trace records behind
report.results, and responseSamplesDropped for the buffer post-run test
scripts are graded on. That buffer is bounded twice, and one counter reports
both: by count (max_response_samples), which displaces an incumbent and so
keeps the graded set uniform over the run, and by bytes
(max_response_sample_bytes), which stops admitting once the retained bodies
fill it. The renderer now reads sampling.responseSampleBudgetSpent to tell
which one applied (issue #1192): true means the byte budget ended at least
one sample, so the graded set is drawn from the part of the run whose bodies
fit rather than uniformly from the whole of it; false means only the count
cap displaced anything, so the graded set stays uniform. The key is absent on
a summary written before the marker existed, and absent keeps today's
uniformity sentence rather than weakening it - only a target whose retained
bodies average more than ~256 KiB reaches the budget at the defaults, so
absent-as-uniform is the accurate message for nearly every older run.
The renderer treats a non-zero count as "this list is a
sample of a larger set": SampleRetentionNote (shared) renders under the
dashboard's Sampled Requests, the history Samples tab and the Test Validation
card, and the sample-count badges say shown rather than captured, since
results is capped at 100 by the report route irrespective of retention.
An absent sampling is a run whose stored summary predates the counts, not
a run that dropped nothing, so the note stays out rather than asserting a
completeness it cannot verify - the same absent-vs-zero rule
httpVersionDowngraded follows above.
report.auth follows that rule too. The engine refreshes a header-placed
OAuth 2.0 token while a load run is going, and this section is what it did:
refreshes[].atSeconds per renewal, plus refreshFailures and a lastError
when one was refused. LoadTestDetail renders it as a one-line note in the
header (authRefreshNote), a warning when a refresh failed - that failure is
what explains 401s appearing partway through an otherwise healthy run. Absent
means the run could never refresh (no OAuth 2.0 auth, a non-expiring or
query-placed token, autoRefreshToken: false, or an older sidecar); present
with an empty refreshes means it was watched and never needed to. The same
eligibility rule decides whether OAuth2LoadTestGuard still blocks a run
longer than its token - isMidRunRefreshable mirrors the config-and-token
cases of the engine's plan_auth_refresh, not its last one, a user-supplied
Authorization header that beats the token and that the guard is never handed
the headers to see. The two must change together.
Load Test Execution¶
- User Action: Configures and starts load test
- Composition: the request half (method/url/headers/body/auth/
testsscript parts) goes raw throughapiService.composeRequest()→POST /compose, same as Send - so a load test measures the same composed request Send sends - Request Transformation: composed request half + frontend
LoadTestConfig→ backend format - API Call:
apiService.startLoadTest()→POST /runs - Response:
{ runId: "run_123", status: "running" } - Dashboard Initialization:
useDashboardStore().startRun(runId) - SSE Connection:
loadTestService.startMonitoring(runId)connects to/runs/:runId/live(a module singleton, so the stream outlives the view) - Metrics Streaming: Real-time metrics update dashboard
- Completion: When test completes, fetch final report via
GET /runs/:id/report
Example Load Test Request:
await apiService.startLoadTest({
request: {
method: "POST",
url: "https://api.example.com/data",
headers: { "Content-Type": "application/json" },
body: { mode: "json", content: '{"key": "value"}' }
},
followRedirects: true,
maxRedirects: 10,
httpVersion: "auto",
verifySSL: true,
mode: "constant_rps",
duration: "30s",
targetRps: 100,
concurrency: 50,
requestId: "req_123",
environmentId: "env_456",
comment: "Stress test",
// Optional pass/fail budgets. Omitted entirely when none were declared - the
// engine rejects an empty object rather than starting an unjudged run.
thresholds: { latencyP99Ms: 50, maxErrorRatePct: 0.1 },
// Optional data rows, bound one per request sent. Omitted when the dialog's
// picker holds no file.
data: [{ id: "1" }, { id: "2" }]
});
Streaming load runs. A load run of a request whose stream setting is on
sends stream: true plus maxStreamDurationMs / maxStreamEvents
(issue #576). The split is deliberate: whether a request streams is read off
the request, because that is a property of the request and lives on its Settings
tab, while how much of each stream this run measures comes from the load
dialog's two cap fields, which appear only for a streaming request. The caps are
always sent for such a run rather than elided as "the engine has defaults" - the
engine's default is the user's sseMaxStreamDurationMs setting, which the
dialog does not show, so eliding would leave the run bounded by a number the
user was never told about while two others were on screen.
Under load a stream is bounded by construction, and reaching a cap is a
successful completion, not a timeout - so a streaming run's clean error rate
is not evidence the caps went unused. The report answers that with a stream
section (RunReport.stream): the per-completion event distribution, the totals,
a derived eventsPerSecond, and capped - how many streams a cap ended rather
than the server. StreamMetrics (modules/dashboard/components/charts) renders
it, and says plainly when every stream was capped, because those counts then
measure the caps rather than the target. The section is undefined for a run
that did not stream, which is not the same claim as a stream that delivered
nothing, so a report without it renders exactly as it did before.
Budgets and the verdict. LoadTestConfig.thresholds (RunThresholds) rides
through to POST /runs under the engine's own camelCase metric names, and the
report comes back with thresholdValidation - one check per budget plus a
verdict of "passed" / "failed" - which ThresholdVerdict
(components/shared) renders on the dashboard report and the history Overview.
It is the aggregate counterpart to testValidation: a pm.test script sees one
response at a time and cannot assert a p99 or an error rate, so a run whose
every assertion passed can still have missed its budget. undefined is a run
that declared none - not a run that passed nothing - so a report without the
section renders exactly as it did before budgets existed. The dialog seeds its
p99 field from the client sloThresholdMs setting, which until then only
annotated a chart.
maxAssertionFailureRatePct (issue #1497) is the sixth budget, judged against
the run's combined assert.* element and pm.test tally rather than against
testValidation's per-response view. thresholds.failRun: true
(LoadTestConfigDialog's own Switch, beside the budget fields rather than in
BUDGET_FIELDS - it is a flag over the budgets, not one itself) changes what a
missed budget does: the run's terminal status becomes "failed" instead of
"completed", so the history list and the taskbar failure cue pick it up, not
only the verdict section.
A collection (design-mode) run is judged too (issue #1564):
RunCollectionDialog's own Pass/fail budgets disclosure sends the identical
thresholds block, top-level on the payload beside failOnSchemaError and
elements, reusing budgets.ts's field table and payload builder rather than
a second copy. It is not gated by the dialog's Load test switch - the
engine evaluates thresholds for a design-mode run and a load run of the same
folder alike - so the same thresholdValidation section and ThresholdVerdict
rendering apply whichever executor ran the sequence. See
POST /runs's thresholds section for
how a collection run's error rate, latency percentiles and throughput are
derived from the steps it actually sent.
custom.<name>.<stat> budgets (issue #1579) are the seventh thing the
block can carry, and the only dynamic one: a ceiling on a value the run records
itself through a metric.record element or a pm.metrics call, keyed by the
name it was recorded under and one of p50, p95, p99, max, value,
rate. RunThresholds holds them as a pattern index signature
([key: `custom.${string}`]: number | undefined), so the six fixed fields
keep their own types and failRun keeps its boolean. Both dialogs declare
them as free-text rows (CustomBudgetRows.tsx) rather than as a picker of
recorded names: the engine checks the key's shape and the value's sign and does
not cross-check the name against the run's own metric.record elements - an
unmatched name is reported evaluated: false rather than refused - which is the
call MCP's thresholdsInput already made for this family (catchall). Like the
six fixed keys, these are not gated by RunCollectionDialog's Load test
switch either - execute_scenario_run evaluates a design-mode run's custom
metrics against the same declared budgets a load run's are judged against.
The engine range-checks this payload before it creates the run row and answers a
violation with 400 invalid_run_config (accepted ranges are tabulated under
POST /runs). The renderer's own limits
live in LOAD_TEST_LIMITS (src/constants/load-test.ts) and must stay at or
inside the engine's.
success_sample_rate is a period, not a percentage¶
The engine keeps a success trace when counter % success_sample_rate == 0 - one
in every N. The dialog's control is a percentage, and the renderer converts
between them with successSamplePeriod (constants/load-test.ts): 100% becomes
a period of 1, 1% becomes 100, and the default 10% is the fixed point where
the two units coincide. Sending the percentage straight through, as the renderer
did before, inverts the slider - "100% - everything" kept 1%.
A 0 is a division by zero engine-side, so the control's floor is 1%. "Keep no
success traces" is the Save timing breakdown toggle, which gates storage
outright.
Dialog ceilings are a user setting¶
LOAD_TEST_LIMITS (constants/load-test.ts) holds the ranges the load dialog
offers, and four of its ceilings are user-adjustable in Settings → Load
testing (loadTestCeilings on client-settings-store). The dialog reads them
through resolveLoadTestLimits, never off the constant.
These are the app's policy and sit inside the engine's own bounds, which are
crash guards rather than throttles: a run's concurrency becomes an eager
per-worker curl-handle pre-allocation, so the engine caps it at 10x
event_loop::MAX_CONCURRENT. LOAD_TEST_CEILING_BOUNDS pins each settable
ceiling at that guard, so no value on the settings screen can compose a run the
engine rejects. The floors are not settable at all - below them the values are
not "small", they are unusable (a concurrency of 0, a sample period of 0).
Error Handling¶
HTTP Errors¶
All HTTP errors are transformed to ApiError:
try {
await apiService.executeRequest(...);
} catch (error) {
if (error instanceof ApiError) {
console.error(error.userFriendlyMessage);
console.error(error.statusCode);
console.error(error.errorCode);
}
}
Network Errors¶
Network errors (timeout, connection failed) are caught and displayed:
if (error.isTimeout) {
// Show timeout message
} else if (error.isNetworkError) {
// Show network error message
}
SSE Errors¶
The SSE client does not reconnect. EventSource cannot set Last-Event-ID
on a fresh connection, so a manual reconnect would re-request the topic from
offset 0 and duplicate every tick already plotted.
- Transient errors (
CONNECTING): left to the browser's own retry, which does carryLast-Event-ID - Terminal errors (
CLOSED): connection disposed and the close handler runs, which converges onGET /runs/:id/report
Connection Management¶
Health Checking¶
The app polls /health endpoint to verify engine connectivity, at one of two
speeds depending on whether the last poll succeeded:
useHealthQuery() // TIMING.HEALTH_RECONNECT_POLL_INTERVAL_MS (1s) while erroring,
// TIMING.HEALTH_CHECK_INTERVAL_MS (30s) once connected
The window loads alongside the engine rather than after it, so an ordinary
launch spends its first seconds starting rather than connected; polling that
state at the 30s cadence could leave a launch showing it for half a minute
after the engine was already serving. A poll that succeeds right after one that
failed also triggers queryClient.invalidateQueries() once - collections, runs
and config gave up after two retries while the engine was down, a connection
refused by a closed port is a plain Error rather than an ApiError, and
refetchOnReconnect only fires on the browser's online/offline event, which
localhost never changes - so nothing else would ever revisit their error state
once the engine came back.
A failed poll does not mean engineStatus becomes unreachable outright:
engineStatusAfterFailedPoll (queries/health.ts) reads starting while an
engine is known to be coming up and is still inside
TIMING.ENGINE_STARTUP_GRACE_MS (45s) of the moment it began, and unreachable
otherwise - past that window, or after an engine that had answered stops
answering with nothing starting. The moment it began is engineStartWindow on
engine-store, opened by this hook on mount - which is when the main process
spawns the engine and starts spending its own budget on the same wait - and
again by useEngineRestart before it invokes the restart IPC, because a restart
kills the daemon and spawns a fresh one that repeats the whole cold start with
the port down for all of it (#1227). Measuring from the hook's mount alone
called every such restart a failure, since an engine had answered: the one this
one replaced. The restart opens the window and never writes engineStatus, so
this hook remains the only thing that classifies; a restart the main process
reports as failed closes the window again, and the next failed poll goes back
to owing the user its reason.
That 45s is the same budget the main process spends on a cold
engine before it gives up and logs EngineNotReadyError
(ENGINE_HEALTH_POLL_BUDGET_MS in electron/constants.ts) - it is the same
question asked from the other side of the process boundary - and since the
two files' tsconfigs share no module graph, health.test.ts reads the
constant out of electron/constants.ts's source text and asserts it equals
TIMING.ENGINE_STARTUP_GRACE_MS, so the two cannot drift apart unnoticed.
Health Response:
{
status: "ok",
version: "0.3.0",
workers: 8,
// Optional (issue #922): present only when this engine startup restored the
// database from its backup or started fresh because it could not. Absent on a
// clean start, which is also what a genuine first run gives.
recovery?: {
outcome: "restored_from_backup" | "started_fresh_quarantined"
| "backup_also_corrupt" | "deleted_corrupt",
at: 1755870000000, // epoch ms
databasePath: "/home/someone/.local/share/vayu/vayu.db",
// Optional (issue #984): where the unopenable database was moved to, when
// it was moved rather than deleted. Absent for `deleted_corrupt`.
quarantinedPath?: "/home/someone/.local/share/vayu/vayu.db.corrupt-1755870000000"
}
}
useHealthQuery mirrors recovery onto engine-store, and RecoveryBanner is
its only reader - it is the one thing that tells the user their data was
restored or wiped, and the only place the quarantined file and its sqlite3
... .recover command are named. Its copy is a Record over the outcome union,
so an outcome the engine adds without copy for it fails pnpm type-check rather
than being announced as whichever branch came last.
Engine Startup¶
The Electron main process (electron/sidecar.ts) manages engine lifecycle:
- Spawns engine process on app start, alongside creating the window rather than
before it - first paint does not wait on the engine
- Monitors health via /health endpoint, on a ramped poll that leaves a
live-but-slow engine to the renderer's own health poll instead of quitting
the app
- Handles graceful shutdown on app quit
Best Practices¶
- Always use
apiService: Don't callhttpClientdirectly - Handle errors: Always wrap API calls in try/catch
- Use user-friendly messages: Display
error.userFriendlyMessageto users - Transform data: Use service layer for format transformation
- Cache queries: Use TanStack Query for automatic caching
- Disconnect SSE: Always disconnect SSE when component unmounts
Testing¶
Mocking API Calls¶
Use TanStack Query's query client for testing:
import { QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
Mocking Services¶
Mock apiService methods:
Troubleshooting¶
Engine Not Responding¶
- Check if engine is running:
curl http://127.0.0.1:9876/health - Check engine logs in Electron console
- Verify port 9876 is not blocked
SSE Not Connecting¶
- Verify load test is running (
status: "running") - Check browser console for SSE errors
- Verify endpoint:
/runs/:runId/liveor/runs/:runId/metrics
CORS Errors¶
Should not occur (same-origin: localhost), but if they do: - Verify engine CORS settings - Check if request is going to correct origin