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.
Where when applies
Section titled “Where when applies”when gates every step: scenario steps, steps inside a keyword, scenario teardown steps, and suite teardown steps.
- A scenario step skipped by
whencascades: any later step referencingresult.<skipped-step>is skipped too, with the reason naming the blocker. The scenario itself stays green. - A keyword sub-step skipped by
whenrecords no result, so downstreamcan(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 thecan(...)guard usable, and the reason keeps the skip visible.
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.
- Teardown runs on a context detached from
--timeout, with its own budget. See Teardown and--timeout.
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.
Suite-level teardown
Section titled “Suite-level teardown”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:
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]) } }}Suite teardown semantics
Section titled “Suite teardown semantics”- 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/--scenarioselected 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 withwhen. - 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_dirpoint at a dedicated workspace underbuild/artifacts/suite-teardown/, andscenario.nameis the reserved valuetales: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.
Teardown and --timeout
Section titled “Teardown and --timeout”--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:
tales test ./e2e --timeout 5m # teardown gets 30s of grace (default)tales test ./e2e --timeout 5m --teardown-grace 2m # longer cleanup budgettales 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.