Steps
The http step
An http step sends one request and reads the whole response, or, with repeat, sends the same
request several times. Nothing is retried, redirected or kept between steps unless the plan asks,
because each of those changes what the step measures.
| Key | Type | Meaning |
|---|---|---|
method |
string | upper case |
url |
string | templated |
headers |
map of strings | templated values; quote anything that is not a word |
query |
map of strings | added to the URL |
form |
map of strings | application/x-www-form-urlencoded body |
json |
any | JSON body; strings are templated, numbers are sent as written |
body |
string | raw body |
bodyFile |
string | raw body read from a file relative to the plan; Content-Type defaults to application/octet-stream |
multipart |
object | multipart/form-data body, see below |
followRedirects |
bool | follow up to 10 hops, each shown; off by default |
retry |
object | opt-in retry, see below |
The request
One of form, json, body, bodyFile, multipart at most. query values are added to any
query the URL already has and re-encoded; headers, query, form and multipart fields go out in
sorted key order, since a YAML map has no order. Content-Type defaults to
application/x-www-form-urlencoded for form, application/json for json and
application/octet-stream for bodyFile, and a header the plan sets wins. Two headers are set
on every request unless the plan sets them: Accept-Encoding: gzip, so the driver sees and
reports the wire size as well as the decoded size, and User-Agent: vero.
The URL must be http or https with a host. HTTP_PROXY, HTTPS_PROXY and NO_PROXY in the
environment are honoured, as net/http honours them. vero keeps one connection pool per
scheme://host:port, sized to --jobs, and tries HTTP/2 when the server offers it; proto says
which protocol answered. The request body is never replayable: when a reused connection turns out
to be closed, net/http would otherwise re-send an idempotent request, and the server would see
one request more than the plan sent.
A plan with secrets writes them into headers and bodies as any other value; see the secretRef
rules on the plan format page for what is scrubbed from the output.
The response
Bodies are read to the end, up to 8 MiB, so connections are reused. Past the cap the body is cut, marked truncated, and the connection is closed instead of reused. A gzip body is decompressed when it is complete, and the failure output and the event log show both sizes.
body is decoded as JSON only when Content-Type is application/json or ends in +json;
any other body is a string, and an assertion that reads a field of it errors with the note the response body was not JSON (Content-Type "text/plain"), so body has no fields. An empty JSON body
leaves body nil. A JSON body that was cut ends the step errored with response 1: the JSON body was cut at the 8388608 byte cap and cannot be decoded, and a body with a key twice ends it
errored with response 1 is not valid JSON: duplicate key "a" at $. Numbers decode exactly;
see Assertions.
TLS verification can be turned off only by vero run --insecure-skip-verify, never from a plan,
and a private CA or a client certificate comes from --ca-cert and --client-cert in the same
way: certificates belong to the environment a run targets, not to the plan. A failed handshake is
errored with the reason in words and the flag that fixes it:
| Failure | Reason |
|---|---|
| unknown authority | TLS handshake failed: the server's certificate is signed by an authority this run does not trust; pass its CA with --ca-cert |
| name mismatch | TLS handshake failed: the server's certificate is not valid for api.example.test |
| expired | TLS handshake failed: the server's certificate has expired or is not valid yet |
| client certificate wanted | TLS handshake failed: the server requires a client certificate; pass --client-cert and --client-key |
| client certificate refused | TLS handshake failed: the server refused the client certificate |
Timing
duration has five phases: dns, connect, tls, ttfb and total. ttfb runs from having
a connection to the first byte, so connection setup is excluded from it. On a reused connection
dns, connect and tls did not happen and are n/a; an assertion that compares an n/a phase
is n/a and fails the task. The failure output always ends with the timing line and says whether
the connection was new.
When the step's deadline fires, the reason says what the request was doing: deadline exceeded resolving the host name, connecting, in the TLS handshake, writing the request, waiting for first byte or reading body, and the timing line ends deadline hit waiting for first byte.
A plan
examples/orders.yaml logs in, creates an order and checks it. The create step reads another task's result in a header, which is the edge between the two tasks:
- name: create-order
steps:
- http:
method: POST
url: "{{ env.baseUrl }}/orders"
headers:
Authorization: "Bearer {{ tasks.login.results.token }}"
json: { sku: "ABC-{{ run.id }}", qty: 2 }
assert:
- status == 201
- body.order.status == "pending"
- body.order.total == 2000
- body matches schema "schemas/order-response.json"
results:
orderId: body.order.id
A step that fails prints the request as sent, the response, each assertion that did not hold with the values it read, and the timing line:
FAIL health › step 1 › assert #1 fail-http.yaml:11
GET http://127.0.0.1:18080/fixtures/status?code=503 HTTP/1.1 503 Service Unavailable 0.5ms
> accept-encoding: gzip
> user-agent: vero
< content-length: 17
< content-type: application/json
< date: Sat, 03 Oct 2026 02:19:42 GMT
< {"status":"503"}
assert status == 200 failed
status = 503
fail-http.yaml:11
timing dns n/a connect 0.2ms tls n/a ttfb 0.3ms total 0.5ms (new connection)
Headers are printed lower-cased and sorted, with repeated headers joined by , . A body is cut at
2 KiB in this output, with … (cut at 2 KiB of N bytes) saying how long it was. The event log
carries the whole exchange up to --events-body-cap.
Cookies
No cookie jar spans tasks: a shared jar is state the graph cannot see. A task with cookies: true
gets a jar of its own for that task run, and every http step and waitFor step in the task sends
the cookies it holds and stores what each response sets.
Redirects
With followRedirects, vero follows 301, 302, 303, 307 and 308 by hand, so every hop is
an exchange of its own with its own status and timing, and each hop counts as a request in the
summary line. A 303, or a 301 or 302 answering a POST, is followed with GET and no body.
Authorization is dropped when the host changes. A hop from https to http is refused and ends
the step errored: redirect from https to http refused: GET https://a.example/x 302 to http://b.example/y. Past 10 hops the step is errored with more than 10 redirects; followed GET ... 302 -> GET ... 302 ..., then 302 to ..., and a redirect with no Location ends the chain with
that response as the step's response. The failure output prints redirect GET <url> 302 <Location>
per hop before the final exchange, and redirects in scope lists each hop's method, url and
status.
Without followRedirects, a 302 is a response with a status code, and status == 302 is what
the plan asserts.
Multipart
| Key | Type | Meaning |
|---|---|---|
fields |
map of strings | text parts, templated; typed like form |
files |
map of file parts | file parts by part name |
| Key | Type | Meaning |
|---|---|---|
path |
string | the file, relative to the plan; required |
filename |
string | the filename sent; defaults to the path's base name |
contentType |
string | the part's type; defaults to application/octet-stream |
Files, for bodyFile and for multipart parts, are read when the plan loads: a missing file, a
directory or a file over 8 MiB is a load error, and the bytes sent are the bytes that were there
at load. A path, a filename and a content type are literals, never templates. Parts go out fields
first, then files, each sorted by name. vero sets the multipart Content-Type with its boundary,
so a plan may not set that header next to multipart. The failure output and the event log show
each file as «file <path>, <size> bytes, sha256 <hex>», never its bytes.
Retry
| Key | Type | Meaning |
|---|---|---|
attempts |
int, 2 to 10 | total tries |
on |
list: connect, 5xx |
required; never an assertion failure |
connect covers an attempt that got no status at all and did not time out: refused, reset, no
route. 5xx covers a status from 500 to 599. An assertion failure is never retried, and no retry
is made once the step's deadline has passed. Retried attempts are marked and left out of
responses; they count in the summary's request count and appear in the failure output as
retried GET <url> 503 Service Unavailable, and the summary block gets a retried line naming
the step.
retry is refused in any step of a task that holds a resource: a retry adds requests not
explicitly listed in the plan while a shared resource is claimed.
Repeat
| Key | Type | Meaning |
|---|---|---|
count |
int, 1 to 1000 | a literal, never a template |
mode |
serial or parallel |
serial sends one request after another and stops at the first transport failure; responses
holds what completed, in order, and the failed exchange is last, so responses count N fails
rather than passing on a sequence that was never sent. parallel sends the requests together,
with at most --jobs in flight, and 1 in flight when the task holds a resource, because a holder
is counting a budget; they are collected in completion order, and indexing or slicing
responses is a load error: index 3 has no meaning in a set. Assert on the set instead, as in
responses where (it.status == 429) count 1. Only a task's last step may repeat, and only
one step per task, so the steps before it run once, in order, inside the same holds claim.
examples/rate-limit.yaml spends a budget of three with four serial requests:
- name: rate-limit-trips-on-fourth
holds: [ratelimit/orders-api-key]
steps:
- http:
method: GET
url: "{{ env.baseUrl }}/orders"
headers: { Authorization: "Bearer {{ tasks.login.results.token }}" }
repeat: { count: 4, mode: serial }
assert:
- responses count 4
- responses[0:3] all (it.status == 200)
- responses[3].status == 429
- responses[3].headers["Retry-After"] != nil
A failing repeat prints every status first, then the last exchange in full, and the quantifier names each element that failed:
FAIL three-tries › step 1 › assert #1 fail-http.yaml:17
responses 503 503 503 (serial, in send order, 3 sent)
GET http://127.0.0.1:18080/fixtures/status?code=503 HTTP/1.1 503 Service Unavailable 0.2ms
> accept-encoding: gzip
> user-agent: vero
< content-length: 17
< content-type: application/json
< date: Sat, 03 Oct 2026 02:19:42 GMT
< {"status":"503"}
assert responses all (it.status == 200) failed
responses = [503 503 503] (3 responses, by status)
responses element 0 fails: it.status = 503
responses element 1 fails: it.status = 503
responses element 2 fails: it.status = 503
fail-http.yaml:17
timing dns n/a connect n/a tls n/a ttfb 0.1ms total 0.2ms (reused connection)
A request that got no response shows as --- in the status list. That plan also drew a lint
warning when it loaded, because an all over responses holds on an empty list:
"responses all (it.status == 200)" holds on an empty responses; add responses count N or a len() assertion on it in the same step (chapter 5.5).
Names in scope
| Name | Value |
|---|---|
request |
method and url as sent |
status |
the status code |
proto |
"HTTP/1.1" or "HTTP/2.0" |
headers |
response headers, looked up without regard to case |
body |
the decoded JSON, or the body as a string |
duration |
dns, connect, tls, ttfb, total |
redirects |
the hops followed, each with method, url, status |
responses |
with repeat: every response, each with the names above; the top-level names are the last one |
env, tasks, run |
as on every step |
Load errors
| Plan | Load error |
|---|---|
method: post |
method "post" must be an upper case HTTP method such as GET or POST |
| two bodies | an http step sends one body; this one sets form and json |
multipart with neither key |
multipart needs fields, files or both |
a Content-Type header next to multipart |
multipart sets Content-Type itself, with the boundary it writes; remove this header |
a file part without path |
file part photo needs path, the file relative to the plan |
| a templated file name | a file path is a literal, not a template: name the file, relative to the plan |
| a file over the cap | big.bin is 9000000 bytes; a file sent in a request may be at most 8388608 (8 MiB) |
retry without on |
retry needs on: a list of connect and 5xx; vero never retries on an assertion failure |
retry in a holding task |
task pagination holds ratelimit/orders-api-key, so its steps may not retry: a retry is a request the plan did not write |
| a repeat that is not the last step | task t repeats step 1, which is not its last step; what a repeat means for the steps after it is ambiguous (chapter 10.3), so only the last step may repeat |
| two repeating steps | task t repeats more than one step; a task may repeat only its last step |
As every load error, each names the step by path and plan line, and nothing is sent:
load-errors.yaml:9: tasks.two-bodies.steps[0].http.method: method "post" must be an upper case HTTP method such as GET or POST
load-errors.yaml:9: tasks.two-bodies.steps[0].http: an http step sends one body; this one sets form and json
load-errors.yaml: 2 load error(s); nothing ran
This page is docs/content/steps/http.md in the repository.