Recipe: Fail Gracefully (Failure Edges and Safe Error Text)
此内容尚不支持你的语言。
When a workflow calls another service and that call fails, the run stops and the user gets the standard failure reply. This recipe keeps the conversation going instead: the failing node takes a failure edge, the workflow branches on a stable error code, and the user sees a short, neutral sentence plus a next step, never the raw error.
Status: live in production. Failure edges work on
tool,action,webhook,llm,sub_agent,set_variableandpdf_exportnodes, and_lastNodeErrorCode/_lastNodeErrorPublicare written on every handled failure. Per-node retry is not part of this recipe; it is live in production since 2026-10-24, see Build a reliable workflow.
Prerequisites
Section titled “Prerequisites”- An agent with a workflow you can edit, and a dashboard sign-in (
Authorization: Bearer <token>plusx-huat-platform: customer). - A Custom Action for the service you call (this recipe uses id
9, an order-status API that answers JSON such as{ "status": "shipped" }). The same pattern works for awebhookortoolnode.
How a handled failure flows
Section titled “How a handled failure flows”flowchart TD S[start] --> A[action: look up order] A -->|success| OK[response: order status] A -->|failure| W{switch on _lastNodeErrorCode} W -->|busy| B[response: busy, try again shortly] W -->|not_accepted| R[response: check the order number] W -->|default| H[response: safe sentence + hand-over line]The action node has exactly two outgoing edges, labelled success and failure. A failure writes three context variables before the failure branch runs:
| Variable | Holds | Show it to end users? |
|---|---|---|
_lastNodeError | The raw error, with recognisable secrets redacted. Can name hosts, ids, HTTP status lines, the API’s own message. | Never |
_lastNodeErrorCode | A stable code to branch on (table below). | No, branch on it |
_lastNodeErrorPublic | A fixed, neutral sentence for that code. It never contains any part of the original error. | Yes |
_lastNodeErrorCode | _lastNodeErrorPublic | Typical cause |
|---|---|---|
TIMEOUT | The service took too long to respond. | The request timed out. |
UNREACHABLE | The service could not be reached. | Connection refused or reset, a host name that did not resolve. |
RATE_LIMITED | The service is busy right now. | HTTP 429 or a rate-limit message. |
REJECTED | The service did not accept the request. | Any other HTTP 4xx, for example 400 or 404. |
UPSTREAM_ERROR | The service had a problem handling the request. | An HTTP 5xx, or the model provider was overloaded. |
INVALID_DATA | The information could not be processed. | A reply that was not valid JSON, a structured llm reply that did not match its outputSchema, MCP schema drift, a bad expression. |
UNKNOWN | Something went wrong with that step. | Anything else. |
The code is worked out from the error message only, and anything not recognised is UNKNOWN.
Step 1: Build the workflow
Section titled “Step 1: Build the workflow”The order id arrives as per-turn data (turnContext: { "orderId": "A-1042" } on sdkSendMessage), so no model call is needed to find it.
mutation UpdateWorkflow($input: UpdateWorkflowInput!) { updateWorkflow(input: $input) { id version }}{ "input": { "id": 74, "nodes": [ { "id": "start", "type": "start", "label": "Start", "config": {}, "position": { "x": 0, "y": 0 } }, { "id": "lookup_order", "type": "action", "label": "Look up order", "config": { "customActionId": 9, "argsTemplate": "{\"orderId\": \"{{turn.context.orderId}}\"}", "outputVar": "orderResult", "timeoutMs": 8000 }, "position": { "x": 0, "y": 100 } }, { "id": "reply_status", "type": "response", "label": "Order status", "config": { "messageTemplate": "Order {{turn.context.orderId}} is {{orderResult.status}}." }, "position": { "x": -300, "y": 200 } }, { "id": "why_failed", "type": "switch", "label": "Why did it fail?", "config": { "cases": [ { "label": "busy", "expression": "_lastNodeErrorCode == \"RATE_LIMITED\"" }, { "label": "not_accepted", "expression": "_lastNodeErrorCode == \"REJECTED\"" } ] }, "position": { "x": 200, "y": 200 } }, { "id": "reply_busy", "type": "response", "label": "Busy", "config": { "messageTemplate": "{{_lastNodeErrorPublic}} Please try again in a minute." }, "position": { "x": 0, "y": 300 } }, { "id": "reply_check_id", "type": "response", "label": "Check the order number", "config": { "messageTemplate": "I could not find order {{turn.context.orderId}}. Could you check the order number?" }, "position": { "x": 200, "y": 300 } }, { "id": "reply_handover", "type": "response", "label": "Hand over", "config": { "messageTemplate": "{{_lastNodeErrorPublic}} I have passed your question to a colleague, who will follow up here." }, "position": { "x": 400, "y": 300 } } ], "edges": [ { "id": "e1", "source": "start", "target": "lookup_order" }, { "id": "e2", "source": "lookup_order", "target": "reply_status", "label": "success" }, { "id": "e3", "source": "lookup_order", "target": "why_failed", "label": "failure" }, { "id": "e4", "source": "why_failed", "target": "reply_busy", "label": "busy" }, { "id": "e5", "source": "why_failed", "target": "reply_check_id", "label": "not_accepted" }, { "id": "e6", "source": "why_failed", "target": "reply_handover", "label": "default" } ] }}Why it is built this way:
- Only
_lastNodeErrorPublicreaches the user. No response node renders_lastNodeError. If anllmnode on the failure branch needs the detail (to write an internal note, say), keep its output away from the reply. - The
switchhas adefaultedge, which every switch must have.TIMEOUT,UNREACHABLE,UPSTREAM_ERROR,INVALID_DATAandUNKNOWNall land there. - The hand-over sentence is fixed text. If your platform watches replies for a fixed phrase to open a human takeover, this is the place to put it.
- The failed node’s
outputVaris not written, so nothing on the failure branch should readorderResult.
Publish with publishWorkflow. Before that, workflowDiagnostics flags the usual mistakes: SUCCESS_WITHOUT_FAILURE (a success edge alone, so a failure still fails the run) and FAILURE_EDGE_ON_UNROUTABLE_NODE (a failure label on a node type that ignores it).
Step 2: Set a fallback for everything else
Section titled “Step 2: Set a fallback for everything else”A failure on a node without a failure edge still ends the run. Give the workflow its own failureResponse so even those failures get your wording instead of the platform’s standard reply, and consider notifyOnFailure so workspace owners hear about unhandled ones. A failure handled by a failure edge sends no owner notice and does not count as a failed run.
What counts as a failure
Section titled “What counts as a failure”Only an error raised while the node itself runs takes the failure edge: a failed or timed-out request, a malformed argsTemplate, a refused URL, a model that is not allowed. The run’s execution budget, the node’s maxVisits limit and a newer message interrupting the turn never do. Two node types behave differently:
sub_agent: a target that fails or times out is reported in itsresultVarentry (status: "error"or"timeout") and the node still takessuccess. Thefailureedge is only for configuration errors. See Delegate to a specialist.webhookwithasync: true: only a failure before the request is sent takesfailure.
The full per-type table is in Failure routing.
Step 3: Test it
Section titled “Step 3: Test it”Test the failure branch against a service you control (a mock-mode draft test with draftMocks can script the failure without calling anything; see the note after the steps):
- Point a test copy of the Custom Action at an endpoint you own that answers
429, then one that answers404, then one that answers503(or set the node’stimeoutMslow against a slow endpoint). - Send a turn from a test session (dashboard
sendMessagewithtestDraft: true, or a normal session after publishing). - Read the run with
workflowRuns(sessionId)andworkflowRun(id):
query Runs($sessionId: Int!) { workflowRuns(sessionId: $sessionId) { id status nodeTrace context }}A handled failure shows the run as COMPLETED, the lookup_order trace row with "status": "FAILED_HANDLED", and _lastNodeErrorCode / _lastNodeErrorPublic in context. Mock-mode draft tests, which script a failure without calling anything, are live in production since 2026-10-24; see Testing a workflow safely.
Common errors
Section titled “Common errors”| What you see | Why | Fix |
|---|---|---|
publishWorkflow refuses: node has a 'failure'-labeled edge but no 'success'-labeled edge | A failure edge needs a success edge (BAD_REQUEST). | Add the success edge. |
publishWorkflow refuses the switch | Its edges do not match its cases, or the default edge is missing. | One edge per case label, plus exactly one default. |
updateWorkflow refuses a case expression (BAD_USER_INPUT) | A syntax error, or an = assignment. | Use == to compare. |
| The user still gets the standard failure reply | The failing node has no failure edge, or it is a node type that ignores the label. | Check workflowDiagnostics; set failureResponse for the rest. |
The reply shows Order A-1042 is . | The call succeeded but the answer had no status field. | Match messageTemplate to the real response shape. |
A switch always takes default | The code is compared with the wrong case or quotes. | Codes are upper-case strings: _lastNodeErrorCode == "TIMEOUT". |
Test checklist
Section titled “Test checklist”- A
200answer gives the status reply. - A
429gives the “busy” reply; a404the “check the order number” reply; a503and a timeout the hand-over reply. - No reply ever contains a host name, URL, status line or the other service’s message.
- The trace row is
FAILED_HANDLEDand the run isCOMPLETED. - A failure on a node without failure edges gets your
failureResponsetext.
What this does not do
Section titled “What this does not do”- It does not retry. A failed call is not repeated for you; add a per-node
retrypolicy (live in production since 2026-10-24) for that. - It does not tell you which service failed in a way that is safe to show; that is what
_lastNodeErrorPublicis for. - It does not open a human takeover by itself. The hand-over line is text; acting on it is up to your platform.