TypeScript exhaustive switch over a Sume run's terminal status

A run ends as completed, failed, canceled or skipped, and the last two send no webhook. Use a never check so a new status fails the build.

5 min readSume
All posts

Type the terminal status as a union of four strings, completed, failed, canceled and skipped, and switch on it with a never assignment in the default branch. TypeScript then refuses to compile the day a fifth status is added to your types. Two of the four statuses never produce a webhook, so the same switch is also where you decide that a poll must cover them.

The reason to care is cost. A paid run that ends in a status your code does not handle is a silent hole. The order stays open in your database, the customer waits, and nobody sees an error, because nothing threw. A compile time check moves that failure to your build, where it is cheap.

Status and outcome are two fields

A Format, Action or Agent run is terminal when it reaches one of these four. The run also carries an outcome of ok, degraded or error, and the two fields answer different questions. The status says how the run ended. The outcome says how good the result is. A run can be completed with outcome degraded, and your code should not treat that as a clean success.

Keep the two fields apart in your own types as well. A RunView with a required status and an optional outcome makes the compiler remind you that a completed run still needs a review step when its outcome is not ok. Many teams collapse both into a single boolean named success, and then cannot tell a clean run from a degraded one when a customer complains.

What each status means for your code

Each status has a different consequence for your system, and the table lists them together with the webhook behaviour from the docs.

Run terminal status and webhook behaviour (read 2026-10-05)
StatusWebhook sentWhat to do
completedYes, a terminal eventRead the receipt, check outcome, store artifacts
failedYes, a terminal eventRead the error code, decide on a retry with a new key
canceledNoLearn about it by polling, mark the order as stopped
skippedNoLearn about it by polling, usually an active run policy fired

The switch

The sample below is a complete function that compiles with strict settings. The never line is the guard. If someone widens TerminalStatus, the build breaks at that line, and you handle the new case on purpose.

Notice what the sample leaves out. It does not poll, it does not call the network and it does not parse JSON. The decision is a pure function of a typed value, so it is trivial to test, and you can reuse it from a webhook handler, from a poll loop and from a nightly reconciliation script. Each of the three then makes the same choice for the same status.

type TerminalStatus = "completed" | "failed" | "canceled" | "skipped";

interface RunView {
  status: TerminalStatus;
  outcome?: "ok" | "degraded" | "error";
}

export function nextStep(run: RunView): string {
  switch (run.status) {
    case "completed":
      return run.outcome === "ok" ? "publish" : "review";
    case "failed":
      return "retry-with-new-key";
    case "canceled":
      return "mark-stopped";
    case "skipped":
      return "mark-skipped";
    default: {
      const unreachable: never = run.status;
      return unreachable;
    }
  }
}

console.log(nextStep({ status: "completed", outcome: "degraded" }));

Retry means a new key

Retrying a failed run needs a fresh idempotency key. The same key with the same body returns the original receipt with idempotency_hit: true, so reusing it would only give you the failure again. A same key with a different body returns 409 idempotency_conflict. Derive keys from stable ids such as tenant, order, Format slug and version, and add an attempt number when you retry on purpose.

A good retry policy has a ceiling. Count attempts per order, stop after a small number you choose, and escalate to a person. Do not retry a canceled run at all, because a person or a policy stopped it on purpose.

Cover the silent statuses with a poll

Because canceled and skipped runs send no webhook, never rely on the webhook alone. Poll the run on a slow timer for anything that did not report, or use waitForRun, which polls for you and tolerates up to 6 transient failures. The SDK keeps the run family as a required argument, so pass family explicitly. The default timeout of waitForRun is 10 minutes, and a timeout does not cancel the run.

If you use the SDK, the wait helper throws on a transport problem or a timeout, and it resolves with the final snapshot otherwise. Catch the timeout error separately, and treat it as unknown state, not as a failure of the run.

Webhook side of the same switch

Run webhooks deliver format.run.terminal, action.run.terminal and agent.run.terminal. Route on the event field and answer 204 to events you do not know, so new event types do not cause a retry storm. Dedupe on request_id, because delivery is at least once and a replay can arrive after a manual redeliver.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume