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 see | Where | Read next |
|---|---|---|
status: "failed" | The run header | The step with status: "failed" and its error |
status: "partial" | The run header | Steps that succeeded, failed, and were skipped |
step.failed, step.skipped | Events | The message and data on the event |
log with level: "error" | Events | What the step saw before it gave up |
status, result, error | A Scan run, GET …/scan/runs/{run_id} | The step the routine stopped on |
{ "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.
| Answer | Means | Do |
|---|---|---|
503 machine_token_unverified | The token could not be verified right now. | Retry with backoff. Nothing started. |
503 production_check_unavailable | The service could not confirm whether the environment is production, so it refused. | Retry with backoff. Nothing changed. |
409 conflict | This instance_id already ran. | Treat as success. Read the run instead of starting one. |
403, 401 | Wrong credential, organization, permission, or environment. | Stop. Fix the credential or the attachment; a retry will not help. |
A run in failed | The 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.
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.
- Find them.
GET …/runs?status=waiting&env_id=…, on a timer. Readtitle,subject_id,actor, andqueued_at. - 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.
- 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.
- Close the loop. When the status leaves
waiting, read the outcome and update your case.
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}.`);
}
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
| Answer | Means | Do |
|---|---|---|
409 in_use | A connection, dataset, or template still has active dependents. | Read GET …/usage, retire the dependents first. |
409 restore_window_passed | The 30 days after a delete have passed. | Recreate it. |
422 | The body does not validate, such as an env_id that is not a UUID. | Fix the request. |
A dataset_run in failed | The query or the connection failed in that environment. | Test the connection in the environment, then the query. |
A sync in failed | The knowledge index did not build. | Read the run's events; request the sync again. |
05In design
- In design Approvals through the API.
approval.requestedandapproval.decidedevents, a decision route for your own approval UI, and SLA timers that escalate on their own. - In design
Idempotency-Keyon 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.