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.

when gates every step: scenario steps, steps inside a keyword, scenario teardown steps, and suite teardown steps.

  • A scenario step skipped by when cascades: any later step referencing result.<skipped-step> is skipped too, with the reason naming the blocker. The scenario itself stays green.
  • A keyword sub-step skipped by when records no result, so downstream can(result.<step>.<field>) guards inside the keyword skip in turn.
  • When the expression cannot be evaluated at all (it is not a boolean, or it raises), the step is skipped, not failed, and the reason says when condition failed to evaluate: .... That keeps the can(...) guard usable, and the reason keeps the skip visible.

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.
  • Teardown runs on a context detached from --timeout, with its own budget. See Teardown and --timeout.

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.

Scenarios are unordered and run in parallel, so no scenario can be “the last one”. When cleanup belongs to the whole suite rather than to one scenario, declare a top-level teardown block:

_suite.tales
version = 1
teardown {
step "sql" "purge_test_users" {
connection = "app"
exec {
sql = "DELETE FROM users WHERE email LIKE '%@tales.test'"
}
}
step "http" "drop_sandbox" {
when = can(config.sandbox_id)
request {
method = "DELETE"
url = "${config.base_url}/sandboxes/${config.sandbox_id}"
}
expect { status = one_of([204, 404]) }
}
}
  • Runs exactly once, after every scenario has finished and before providers are closed, so SQL pools, gRPC connections and browser sessions are still usable.
  • Runs even when --tag / --scenario selected no scenario at all. A safety net that only fires when the run happened to match something is not a safety net. Gate individual steps with when.
  • Steps run sequentially in source order. A failing step does not stop the ones after it: cleanup is a list of independent obligations, not a pipeline.
  • Later steps can read result.<earlier-step> from the same block. They cannot read scenario results, which are per-scenario state.
  • A failure makes the run exit 1, even when every scenario passed, and is reported in the console, JSONL (phase: "suite_teardown"), JUnit (a synthetic testcase) and the HTML report.
  • Only one file per suite may declare the block. Concatenating across files would make the cleanup order depend on the alphabetical order of filenames, so a second declaration is a load error.
  • Inside the block, scenario.workdir / scenario.artifacts_dir point at a dedicated workspace under build/artifacts/suite-teardown/, and scenario.name is the reserved value tales:suite-teardown.
  • Generated values are seeded from that reserved name, so they do not depend on --parallel, on the filters, or on how many scenarios ran.

--timeout bounds the whole run. It must not, however, cancel the cleanup that an aborted run makes necessary: the run that ran out of budget is precisely the one that left resources behind.

Teardown steps (scenario and suite) therefore run on a context detached from the run’s, with their own budget:

Terminal window
tales test ./e2e --timeout 5m # teardown gets 30s of grace (default)
tales test ./e2e --timeout 5m --teardown-grace 2m # longer cleanup budget
tales test ./e2e --timeout 5m --teardown-grace 0 # legacy: teardown inherits the cancelled context

--teardown-grace 0 restores the historical behavior, where an exhausted --timeout (or Ctrl-C) also aborts cleanup.