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.

verodocs