Exceptions and escalations

Runs fail, systems time out, and some steps need a person. This page shows how each case appears on the record, how to retry without doing work twice, and how a run that waits for approval reaches the right person.

For
Developers and operations
Terminal
failed · partial · timed_out · canceled
Waiting
reason: "approval"
Retry key
instance_id

01How a failure shows up

A failure is never silent. The run ends in a terminal status, the step that failed carries the error, and the events say what happened before it. A problem found after the route answered, such as an agent that no longer exists in the organization, ends the run as failed with the reason on its record.

You seeWhereRead next
status: "failed"The run headerThe step with status: "failed" and its error
status: "partial"The run headerSteps that succeeded, failed, and were skipped
step.failed, step.skippedEventsThe message and data on the event
log with level: "error"EventsWhat the step saw before it gave up
status, result, errorA Scan run, GET …/scan/runs/{run_id}The step the routine stopped on
Eventsa connection refused, then the step failed · sample values

{ "seq": 7, "type": "log", "step_key": "tool:query_positions#1", "level": "error", "message": "Snowflake: 503 from warehouse WH_PROD", "data": {} }
{ "seq": 8, "type": "step.failed", "step_key": "tool:query_positions#1", "message": "connection refused after 3 attempts", "data": {} }
{ "seq": 9, "type": "failed", "step_key": null, "message": "tool:query_positions#1 failed", "data": {} }

02Retry without doing work twice

Two layers can fail: your call, and the run it started. Handle each with the signal NSTACK gives you.

AnswerMeansDo
503 machine_token_unverifiedThe token could not be verified right now.Retry with backoff. Nothing started.
503 production_check_unavailableThe service could not confirm whether the environment is production, so it refused.Retry with backoff. Nothing changed.
409 conflictThis instance_id already ran.Treat as success. Read the run instead of starting one.
403, 401Wrong credential, organization, permission, or environment.Stop. Fix the credential or the attachment; a retry will not help.
A run in failedThe work stopped partway.Read the failing step. Start a new run with the same input once the cause is fixed.

For a webhook you control, make the start idempotent on your side: set the run up with test=true, store the instance_id next to your own record, then start it by id. A crash between the two calls leaves an id you can start again; a duplicate start answers 409.

A start that survives a crash

async function startOnce(payload: object, key: string) {
  let instanceId = await db.get(`nstack:instance:${key}`);
  if (!instanceId) {
    const setup = await post(`${wf}/workflows/${workflowId}?env=${envId}&test=true`, payload);
    instanceId = setup.instance_id;
    await db.set(`nstack:instance:${key}`, instanceId);
  }
  const res = await postRaw(`${wf}/workflows/${workflowId}?env=${envId}&instance_id=${instanceId}`, payload);
  if (res.status === 409) return instanceId;             // already started: fine
  if (res.status === 503) throw new Retry(res);          // back off and call startOnce again
  if (!res.ok) throw new Error(`refused: ${res.status}`);
  return instanceId;
}

def start_once(payload, key):
    instance_id = db.get(f"nstack:instance:{key}")
    if not instance_id:
        setup = post(f"{wf}/workflows/{workflow_id}", params={"env": env_id, "test": "true"}, json=payload)
        instance_id = setup["instance_id"]
        db.set(f"nstack:instance:{key}", instance_id)
    res = post_raw(f"{wf}/workflows/{workflow_id}", params={"env": env_id, "instance_id": instance_id}, json=payload)
    if res.status_code == 409:
        return instance_id            # already started: fine
    if res.status_code == 503:
        raise Retry(res)              # back off and call start_once again
    res.raise_for_status()
    return instance_id

An agent run answers its run_id before the agent starts. Store the id before you do anything else, and check the record before you call again; the same thread_id keeps a repeated task in one conversation.

03Escalations: when a run needs a person

A step that changes something in Safe mode does not run until a person approves it. The run moves to waiting with reason: "approval", the approver decides in Studio, and the server stamps who decided and when. The run then continues or ends. Your side of this is routing: find the runs that wait, get them in front of the right person, and stop waiting when they decide.

  1. Find them. GET …/runs?status=waiting&env_id=…, on a timer. Read title, subject_id, actor, and queued_at.
  2. Route them. Post the run to your pager, chat, or case system with the run id. Studio's Runs page finds a run by id, and the approver acts there.
  3. Age them. A run that waits past your own limit is an escalation. Re-post it to the next person; the record keeps waiting until someone decides.
  4. Close the loop. When the status leaves waiting, read the outcome and update your case.
A watcherruns every minute

const WAIT_LIMIT_MS = 30 * 60 * 1000;

async function sweep() {
  const [waiting, failed] = await Promise.all([
    list({ status: "waiting", env_id: prodEnvId }),
    list({ status: "failed", from: new Date(Date.now() - 60_000).toISOString() }),
  ]);
  for (const r of waiting.items) {
    const age = Date.now() - Date.parse(r.queued_at);
    const to = age > WAIT_LIMIT_MS ? ops.escalationChannel : ops.approverFor(r.subject_id);
    await ops.post(to, `${r.title} is waiting for approval (${Math.round(age / 60000)} min). Run ${r.run_id}.`);
  }
  for (const r of failed.items) await ops.post(ops.oncall, `${r.title} failed. Run ${r.run_id}.`);
}
Reads and drafts

Agents hand a draft to a person; they write nothing on their own. The same routing applies: the draft lands in front of the owner with the run id, and the person acts in your system or in Studio.

04Exceptions in data

AnswerMeansDo
409 in_useA connection, dataset, or template still has active dependents.Read GET …/usage, retire the dependents first.
409 restore_window_passedThe 30 days after a delete have passed.Recreate it.
422The body does not validate, such as an env_id that is not a UUID.Fix the request.
A dataset_run in failedThe query or the connection failed in that environment.Test the connection in the environment, then the query.
A sync in failedThe knowledge index did not build.Read the run's events; request the sync again.

05In design

  • In design Approvals through the API. approval.requested and approval.decided events, a decision route for your own approval UI, and SLA timers that escalate on their own.
  • In design Idempotency-Key on every route that starts a run, so a retry never starts a second one.
  • In design Signals, so your system resumes a waiting run with a callback or a decision.

06Next