Skip to content

Failure Handling

Scott Singleton edited this page May 10, 2026 · 1 revision

Failure Handling

Tamp distinguishes five distinct things that all sound similar but mean different things. Pick the right one.

The decision matrix

Need Use
"If this target's predicate is false, skip silently and move on" OnlyWhen(Func<bool>)
"If this target's predicate is false, abort the build" Requires(Func<bool>)
"This target's failure shouldn't stop the build" FailureMode(FailureMode.Continue)
"Run this target only when target X fails" OnFailureOf(nameof(X))
"Run this target whether the build succeeded or failed" AssuredAfterFailure()
"Retry on transient failure" Retry(count, Backoff, ...exitCodes)

The rest of this page explains each one in depth.

OnlyWhen — silent skip

The default condition. [CallerArgumentExpression] captures the predicate text for skip messages and dry-run output.

Target Push => _ => _
    .OnlyWhen(() => Git.Branch == "main")
    .DependsOn(nameof(Pack))
    .Executes(() => DotNet.NuGetPush(...));

Output:

==> Push (skipped: Git.Branch == "main")

Multiple OnlyWhen calls accumulate — all must hold.

A throwing predicate is treated as a skip with the exception type and message included in the skip reason.

Requires — hard precondition

Same shape as OnlyWhen, opposite outcome. Failing condition aborts the build with the captured expression text.

Target Deploy => _ => _
    .Requires(() => DeploymentToken != null)
    .Requires(() => Environment != "production" || ApprovedAt.HasValue)
    .Executes(() => /* … */);

Use Requires for "this target can't possibly run without X." Use OnlyWhen for "this target is irrelevant when X." Different intent → different signal in the build summary.

A failing Requires triggers OnFailureOf handlers the same way any other failure does.

FailureMode

Three modes, applied per-target:

Mode Behaviour
Fatal (default) Target failure aborts the build. Other downstream targets become NotRun.
Continue Target failure is logged but the build keeps going.
Retry Retry per Backoff. Use the Retry(...) setter, which sets this mode automatically.
Target SoftCheck => _ => _
    .FailureMode(FailureMode.Continue)
    .Executes(() => /* check that's nice-to-pass but not blocking */);

Target Flaky => _ => _
    .Retry(count: 3, Backoff.Exponential(TimeSpan.FromSeconds(1)))
    .Executes(() => /* network-dependent thing */);

Backoff factories:

Factory Shape
Backoff.Linear(step) step * attempt
Backoff.Exponential(initial, factor=2.0) initial * factor^(attempt-1)
Backoff.Constant(delay) always the same
Backoff.Custom(Func<int, TimeSpan>) bring your own

Status: Retry is recorded on the spec but not yet implemented in the v0 sequential executor. The setting carries forward to a v0.x executor.

OnFailureOf — catch handlers

Tamp-specific addition NUKE doesn't have. The handler runs only when one of the named targets fails. Distinct from anything else:

Target Deploy => _ => _
    .Executes(() => /* the risky thing */);

Target NotifyOpsOnFailure => _ => _
    .OnFailureOf(nameof(Deploy))
    .Description("Slack the on-call channel if Deploy fails")
    .Executes(() => Slack.Post("#oncall", $"Deploy failed at {Git.Commit[..7]}"));

Semantics

  • NotifyOpsOnFailure is not in the normal plan. tamp Deploy does not pull it in; it runs only as a reaction to Deploy failing.
  • The handler's own dependency tree runs first — handler can declare deps like any other target.
  • A handler's failure does not reverse the original failure. Deploy is still the FailedTarget in the result.
  • Multiple handlers for the same failed target all run; they're independent.
  • OnFailureOf is intentionally excluded from cycle detection — runtime-only.
  • The handler can read Git, Solution, [Parameter] properties — same context as any target.

vs. AssuredAfterFailure (next section)

OnFailureOf(X) AssuredAfterFailure()
Runs when X succeeds?
Runs when X fails?
In the normal plan?
Conditional on a specific target?

OnFailureOf is a catch handler. AssuredAfterFailure is unconditional cleanup.

AssuredAfterFailure — cleanup that always runs

Marks a target as cleanup that should run whether the build succeeded or failed. Used for things like:

  • Container docker logout
  • Tearing down a test database
  • Notifying telemetry
  • Uploading partial logs
Target Cleanup => _ => _
    .AssuredAfterFailure()
    .DependsOn(nameof(Compile))    // still in the plan; still has deps
    .Executes(() => /* always run this */);

When an earlier target fails, the executor switches into "post-failure mode": only assured-after-failure targets keep running. Everything else gets NotRun status.

A failing assured-after-failure target doesn't reverse the original failure. The original FailedTarget is preserved.

Retry

Target FlakyDownload => _ => _
    .Retry(count: 5,
           Backoff.Exponential(TimeSpan.FromSeconds(2)),
           retryableExitCodes: new[] { 137, 139 })   // SIGKILL/SIGSEGV from busy runners
    .Executes(() => DownloadArtifact(...));

Setting Retry automatically switches the target's FailureMode to Retry. The retryableExitCodes filter is optional — without it, every non-zero exit retries.

Status: Recorded; not yet enforced by the v0 executor. A retry-aware executor is on the v0.x track.

What the build summary tells you

The end-of-run table shows status per target:

─── Build Summary ───
  Target          Status      Duration
  Restore         ✓ Done      1.2 s
  Compile         ✓ Done      14.5 s
  Test            ✓ Done      8.3 s
  Pack            ✗ Failed    4.1 s          ← original failure
  Notify          ✓ Done      0.8 s          ← OnFailureOf(Pack) ran
  CleanArtifacts  ✓ Done      0.1 s          ← AssuredAfterFailure
                              ─────
  Total                       29.0 s

BUILD FAILED — first failed target: Pack

The build's exit code reflects the original failure, not the cleanup or handler outcome.

See also

Clone this wiki locally