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() } }}Attributes
Section titled “Attributes”| Attribute | Required | Description |
|---|---|---|
attempts | yes | Total number of attempts (not retries). attempts = 3 means up to 3 runs. |
interval | yes | Wait between attempts. Standard Go duration string ("100ms", "1s", "2m500ms"). |
Semantics
Section titled “Semantics”- The step runs up to
attemptstimes. The first attempt that satisfiesexpectwins. - Between attempts, the runtime sleeps for
interval(interruptible by--timeoutor context cancellation). - All attempts share the same
vars,request, andexpectdefinitions. 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.
When to use it
Section titled “When to use it”- 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.
When not to use it
Section titled “When not to use it”- 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.
teardown
Section titled “teardown”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]) } } }}Teardown semantics
Section titled “Teardown semantics”- Always runs when the scenario was not skipped. Even a hard failure in the main flow still triggers teardown.
- Steps inside
teardownfollow 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.
Patterns
Section titled “Patterns”Forgiving status codes
Section titled “Forgiving status codes”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]) } }}SQL teardown
Section titled “SQL teardown”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]) } } }}Multiple cleanups, ordered
Section titled “Multiple cleanups, ordered”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.