Skip to content

Retry & teardown

Two resilience patterns sit at the step / scenario boundary: retry re-runs a single step until it passes (or runs out of attempts), and teardown always runs at the end of a scenario regardless of pass / fail status.

step "http" "find_verification_email" {
retry {
attempts = 10
interval = "100ms"
}
request {
method = "GET"
url = "${config.base_url}/mail/messages?to=${result.register.email}"
}
expect {
status = 200
json = {
messages = is_array()
}
}
}
AttributeRequiredDescription
attemptsyesTotal number of attempts (not retries). attempts = 3 means up to 3 runs.
intervalyesWait between attempts. Standard Go duration string ("100ms", "1s", "2m500ms").
  • The step runs up to attempts times. The first attempt that satisfies expect wins.
  • Between attempts, the runtime sleeps for interval (interruptible by --timeout or context cancellation).
  • All attempts share the same vars, request, and expect definitions. Generators with deterministic seeds produce the same value across attempts, so polling does not unexpectedly change inputs.
  • Reports surface the number of attempts taken in JSONL and JUnit outputs.
  • Polling for an external event, a confirmation email lands, a webhook is delivered, a background job updates a row.
  • Soft retries on slow infrastructure, first request after a cold start takes longer than a steady-state call.
  • Hiding flaky tests. If a step needs three retries to pass in steady state, fix the test or the system, don’t paper over it.
  • Working around assertion logic bugs. Retry doesn’t change matchers; it just runs them again.

Each step accepts an optional when expression that gates execution at runtime. The step runs if when evaluates to a truthy value, otherwise it is reported as skipped with reason when condition was false.

step "http" "delete_user" {
when = can(result.create_user.id)
request {
method = "DELETE"
url = "${config.base_url}/users/${result.create_user.id}"
}
}

can(expr) returns true when expr evaluates without error and false otherwise, perfect for “skip if the prerequisite capture is missing”.

when is evaluated before the step body, so it cannot see vars from that step. It can see config, result.*, host, and the result of any HCL function.

A scenario can declare a single teardown { ... } block holding one or more steps. Teardown runs after the main flow, in the order steps are declared, even when a previous step failed.

scenario "Create blog post with cleanup" {
step "http" "create_user" {
// ...
capture { id = response.json.id }
}
step "http" "create_post" {
// ...
}
teardown {
step "http" "delete_user" {
when = can(result.create_user.id)
request {
method = "DELETE"
url = "${config.base_url}/users/${result.create_user.id}"
}
expect {
status = one_of([200, 204, 404])
}
}
}
}
  • Always runs when the scenario was not skipped. Even a hard failure in the main flow still triggers teardown.
  • Steps inside teardown follow the same rules as scenario steps: source order, when, retry, vars, capture, expect.
  • A failing teardown step marks the scenario as failed and triggers an exit code of 1, but later teardown steps still run, so all cleanup gets a chance to happen.
  • Captures produced by teardown steps are local to the teardown block; the scenario is already over by then.

Use one_of([200, 204, 404]) so the cleanup does not fail when the resource was never created or was already gone:

teardown {
step "http" "delete_post" {
when = can(result.create_post.id)
request {
method = "DELETE"
url = "${config.base_url}/blog/posts/${result.create_post.id}"
}
expect { status = one_of([200, 204, 404]) }
}
}
teardown {
step "sql" "reset_org_vip" {
when = can(result.create_org.id)
connection = "app"
exec {
sql = "UPDATE organizations SET vip = false WHERE id = $1"
args = [result.create_org.id]
}
expect { json = { rows_affected = one_of([0, 1]) } }
}
}
teardown {
step "http" "delete_membership" { when = can(result.create_membership.id) /* ... */ }
step "http" "delete_team" { when = can(result.create_team.id) /* ... */ }
step "http" "delete_user" { when = can(result.create_user.id) /* ... */ }
}

Each step is independent, a failing delete_team doesn’t prevent delete_user from running.