📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
---
|
||||
name: n8n-error-handling
|
||||
description: Design visible, structured, recoverable n8n failures using error outputs, retries, Error Trigger workflows, and HTTP error responses.
|
||||
risk: unknown
|
||||
source: https://github.com/czlonkowski/n8n-skills/tree/main/skills/n8n-error-handling
|
||||
source_repo: czlonkowski/n8n-skills
|
||||
source_type: community
|
||||
date_added: "2026-07-21"
|
||||
author: Romuald Czlonkowski
|
||||
license: MIT
|
||||
license_source: https://github.com/czlonkowski/n8n-skills/blob/main/LICENSE
|
||||
---
|
||||
|
||||
# n8n Error Handling
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill for unattended workflows, webhook/API response contracts, retry design, error outputs, Error Trigger workflows, alerting, or any path where failure must be visible and recoverable.
|
||||
|
||||
Make retries bounded and idempotent, especially for sends, payments, and writes. Redact credentials, personal data, request bodies, and stack details from caller-facing responses and alerts; expose only the minimum diagnostic context required.
|
||||
|
||||
By default, when an n8n node throws, the **whole workflow halts**. For an interactive run you're watching, that's fine — you see the red node and fix it. For anything unattended (a webhook API, a cron job, a queue worker, an agent tool), it's the wrong default: the caller gets a timeout or an empty 500, the operator gets no alert, and the symptom is "the integration just stopped working" with no log and no clue.
|
||||
|
||||
This skill is about making failures **loud, structured, and recoverable** — and, best case, **self-healing** so transient blips never reach a human at all.
|
||||
|
||||
The two ideas that prevent most silent failures:
|
||||
|
||||
- **Per-node error outputs** — a node's failure routes down a second output you control, instead of killing the run.
|
||||
- **A workflow-level error workflow** — a catch-all that fires for anything that escapes per-node handling (timeouts, crashes between nodes, unwired failures).
|
||||
|
||||
---
|
||||
|
||||
## When you actually need this
|
||||
|
||||
| Workflow shape | Error handling posture |
|
||||
|---|---|
|
||||
| Webhook / API (anything with `Respond to Webhook`) | **Required.** Every fallible node's error output wired; status code matches cause. |
|
||||
| Scheduled / cron / queue worker / agent tool (unattended) | **Required.** A workflow-level error workflow, plus `retryOnFail` on network nodes. |
|
||||
| Internal one-off you run and watch yourself | **Optional.** Default `onError: "stopWorkflow"` is fine — you'll see the red node and re-run. |
|
||||
|
||||
The dividing line: **if anyone other than you sees the output** — a downstream system, an end user, an on-call engineer — the failure has to be handled, not swallowed. If you're the only watcher and the cost of failure is "I notice and re-run", looser is fine.
|
||||
|
||||
---
|
||||
|
||||
## The #1 silent trap: per-node error output is a TWO-step setup
|
||||
|
||||
This is the single most common way an n8n workflow "handles" errors while actually swallowing them. Routing a node's failure to a handler takes **two** changes, and doing only one looks complete but misbehaves:
|
||||
|
||||
1. **Set `onError: "continueErrorOutput"`** on the node. This is what *creates* the second output. Without it, `main[1]` doesn't exist no matter what you wire.
|
||||
2. **Wire that error output** (`connections.<node>.main[1]`, i.e. `sourceIndex: 1`) to a real handler. Without a target, the error data is emitted into the void.
|
||||
|
||||
Get one without the other and you hit a failure mode:
|
||||
|
||||
| What you did | What happens at runtime |
|
||||
|---|---|
|
||||
| `onError` set, error output **not** wired | Error data is silently discarded. Downstream doesn't fire. The dashboard shows the run as **succeeded**. Worst case — no error logged anywhere. |
|
||||
| Error output wired, `onError` **not** set | The slot never fires; the handler is unreachable. On failure the workflow just **halts** (default `stopWorkflow`). |
|
||||
| Both done | Failure routes down `main[1]` to your handler. ✅ |
|
||||
|
||||
### Doing both with `n8n_update_partial_workflow`
|
||||
|
||||
```javascript
|
||||
// 1) Turn on the error output (creates main[1])
|
||||
{ type: "updateNode", nodeName: "HTTP Request",
|
||||
changes: { onError: "continueErrorOutput" } }
|
||||
|
||||
// 2) Wire the error output to a handler. sourceIndex: 1 = the error output.
|
||||
{ type: "addConnection",
|
||||
source: "HTTP Request",
|
||||
target: "Handle Error",
|
||||
sourceIndex: 1 }
|
||||
```
|
||||
|
||||
`sourceIndex: 0` is the success path, `sourceIndex: 1` is the error path. (For IF nodes the aliases `branch: "true"`/`"false"` map to index 0/1; for a generic fallible node, use the explicit `sourceIndex: 1`.)
|
||||
|
||||
**Then verify.** This trap doesn't surface in `validate_workflow` — a half-wired error output validates clean. Pull the workflow with `n8n_get_workflow` and confirm **both** halves:
|
||||
|
||||
- The node's `onError` is `"continueErrorOutput"`.
|
||||
- `connections["HTTP Request"].main[1]` contains your handler.
|
||||
|
||||
Valid `onError` values:
|
||||
|
||||
| Value | Effect |
|
||||
|---|---|
|
||||
| `"stopWorkflow"` (default) | Error halts the whole workflow. |
|
||||
| `"continueRegularOutput"` | Error item flows out the **normal** output. Rare, usually wrong — downstream gets error-shaped data and keeps going. |
|
||||
| `"continueErrorOutput"` | Error item flows out the **separate** error output (`main[1]`). The one you wire. |
|
||||
|
||||
Full failure-mode catalog, fan-in/fan-out shapes, and verification: **references/NODE_ERROR_OUTPUTS.md**.
|
||||
|
||||
---
|
||||
|
||||
## Self-healing first: `retryOnFail` before you wire error paths
|
||||
|
||||
Before you build error branches, absorb the transient failures so they never reach those branches. On **any node that calls a network service** — HTTP Request, comms (Gmail/Slack/Discord), databases, AI nodes, third-party integrations — set node-level retry:
|
||||
|
||||
```javascript
|
||||
{ type: "updateNode", nodeName: "HTTP Request",
|
||||
changes: {
|
||||
retryOnFail: true,
|
||||
maxTries: 3,
|
||||
waitBetweenTries: 5000 // ms
|
||||
} }
|
||||
```
|
||||
|
||||
Why this comes **first**: a 429 or a brief upstream hiccup will retry and usually succeed on its own. The error output then fires only on *real, persistent* failures — so your 5xx responses and on-call alerts reflect actual problems instead of noise.
|
||||
|
||||
Engine limits to know: retry fires on **any** error (there's no per-status-code filter), `maxTries` caps at 5, and `waitBetweenTries` caps at 5000ms — so 5000 is both the max and a sensible default. See **n8n-node-configuration** (NODE_FAMILY_GOTCHAS.md) for node-specific notes.
|
||||
|
||||
---
|
||||
|
||||
## API workflows: the canonical shape
|
||||
|
||||
A webhook-triggered workflow that responds to its caller has one rule that overrides everything else: **no hanging branches**. Every path — success and every error — must end at a `Respond to Webhook`, or the caller sits there until it times out.
|
||||
|
||||
```
|
||||
Webhook (responseMode: "responseNode")
|
||||
├── validate input → process → Respond (200, body)
|
||||
└── (any fallible node's error output → sourceIndex 1)
|
||||
→ Respond (4xx/5xx, structured error body)
|
||||
→ optional: log full error privately / notify
|
||||
```
|
||||
|
||||
Three things make this work:
|
||||
|
||||
1. **Fan-in to one error responder.** Many fallible nodes can route their `main[1]` to a single `Respond` node. Keeps the graph readable.
|
||||
2. **Validation failures (4xx) are checked *upstream*, not via error outputs.** A missing field isn't a node *crashing* — it's an expected outcome with a known response. Branch on it with IF/Switch (or the schema validator below) and return 400/401/403/404 directly. Error outputs are for *unexpected* failures (5xx).
|
||||
3. **`responseCode` defaults to 200 — even on error branches.** This is its own silent trap (see references/RESPONSE_SHAPES.md and **n8n-node-configuration** at `../n8n-node-configuration/references/NODE_FAMILY_GOTCHAS.md`): an error branch that returns 200 with an error body looks like success to the caller's HTTP client, so their error handling never fires. Set `responseCode` explicitly on every Respond node.
|
||||
|
||||
### Input validation: the Set-node schema validator
|
||||
|
||||
For any endpoint doing structured input validation, run the check as an IIFE inside a single **Set** node rather than a chain of IF/Switch nodes per field. One node validates the whole payload, returns `{ valid, validationError, details, requiredSchema }`, and an IF branches on `valid` → your logic (200) or a 400 Respond that echoes the schema back so the caller can self-correct. It's also dramatically faster than a recursive validator in a Code node + sub-workflow. The full pattern, the constraint cookbook, and the expression-escaping gotchas live in **references/API_WORKFLOWS.md**.
|
||||
|
||||
---
|
||||
|
||||
## Response shapes: map cause → status code
|
||||
|
||||
A 5xx with `text/plain "Internal Server Error"` is technically an error response and practically useless. And not every failure is a 5xx. **Match the status code to *why* the request failed**, because the caller branches on it: their monitoring alerts on 5xx (your fault) but not 4xx (their fault), and 5xx suggests "retry" while 4xx suggests "don't".
|
||||
|
||||
**The common mistake:** wiring everything — including bad input — to one `Respond` that returns 500 `internal_error`. Now the caller can't tell their bug from your outage, and your error rates can't separate real incidents from client noise.
|
||||
|
||||
| Cause | Status | `error` code | Where it's handled |
|
||||
|---|---|---|---|
|
||||
| Required field missing / wrong type | 400 | `validation_error` | Upstream check (schema validator / IF), not error output |
|
||||
| Auth missing or invalid | 401 | `unauthorized` | Upstream check |
|
||||
| Authenticated but not allowed | 403 | `forbidden` | Upstream check |
|
||||
| Resource ID valid in request, absent in your data | 404 | `not_found` | Branch on the lookup *result*, not its error |
|
||||
| Conflicts with current state (duplicate, race) | 409 | `conflict` | Detect with logic |
|
||||
| Caller exceeded rate limit | 429 | `rate_limit_exceeded` | Set `Retry-After` header |
|
||||
| Node threw, cause unknown | 500 | `internal_error` | Error output path |
|
||||
| Third-party API returned an error | 502 | `upstream_error` | Error output of the HTTP node |
|
||||
| Can't process right now (downstream down) | 503 | `service_unavailable` | Detect specific error, hint retry |
|
||||
| Third-party API timed out | 504 | `upstream_timeout` | Error output filtered by message |
|
||||
|
||||
So there are two distinct flows: **4xx is decided before the work** (IF/Switch + dedicated Respond), **5xx comes out of error outputs** ("we tried, it broke").
|
||||
|
||||
**One Respond, expression-driven code.** When error paths differ only by *number and message* (same body shape, same headers), don't fan out to N Respond nodes through a Switch. The Respond node accepts expressions in both `Response Code` and body — compute the code inline:
|
||||
|
||||
```javascript
|
||||
// Response Code field on a single Respond to Webhook:
|
||||
{{ (() => {
|
||||
const msg = $json.error?.message || $json.message || '';
|
||||
if (msg.includes('INVALID_ID')) return 400;
|
||||
if (/429|too many/i.test(msg)) return 429;
|
||||
if (/timeout/i.test(msg)) return 504;
|
||||
if (/upstream|llm|api/i.test(msg)) return 502;
|
||||
return 500;
|
||||
})() }}
|
||||
```
|
||||
|
||||
Reserve Switch + multiple Responds for paths that diverge *structurally* (different headers, different body shapes, redirects). Same shape with a different number is one expression-driven Respond.
|
||||
|
||||
The default envelope is `{ "error": "<code>", "message": "<human text>" }` — the HTTP status already says success-vs-failure, so no `ok: false` flag. **Never leak internals** (stack traces, SQL, upstream bodies, tokens) into the response — log those privately, return a sanitized message. Correlation IDs, `retry_after`, validation `details`, and the full do-not-leak list are in **references/RESPONSE_SHAPES.md**.
|
||||
|
||||
---
|
||||
|
||||
## Workflow-level error workflow (the catch-all)
|
||||
|
||||
Per-node outputs handle the failures you anticipated on the nodes you remembered to wire. An **error workflow** catches everything else: a node you forgot to wire, a crash between nodes, a whole-workflow timeout, a trigger failure. For unattended workflows this is the safety net that turns "it silently stopped" into "an alert arrived".
|
||||
|
||||
Build it as a separate workflow starting with an **Error Trigger** node. n8n invokes it with the failure context:
|
||||
|
||||
```json
|
||||
{
|
||||
"execution": { "id": "...", "url": "...", "lastNodeExecuted": "Fetch order",
|
||||
"error": { "name": "NodeApiError", "message": "...", "timestamp": 1715000000000 } },
|
||||
"workflow": { "id": "...", "name": "Sync Stripe customers" }
|
||||
}
|
||||
```
|
||||
|
||||
Minimal version — **capture → notify**:
|
||||
|
||||
```
|
||||
Error Trigger → Set (build alert from execution + error) → Slack/email (post to #incidents)
|
||||
```
|
||||
|
||||
A good alert includes the workflow name, a link to the editor and a link to the failed execution, the failed node name, and the **real** error message (not "Workflow failed"). Field expressions and the optional "fetch the failing input via the n8n node" upgrade are in **references/ERROR_WORKFLOWS.md**.
|
||||
|
||||
Two traps worth flagging up front:
|
||||
|
||||
- **The recursion trap.** If the error workflow notifies Slack and Slack is what's down, the error workflow fails too — and the original error vanishes. Notify on a *different* channel than your monitored workflows use (most workflows alert Slack → error workflow uses email), and add a fallback (write to a Data Table) so a failed notification still leaves a trace.
|
||||
- **A "handled" error won't bubble up.** If a node's error output is wired to a no-op that drops the data, n8n considers the error *handled* and the error workflow does **not** fire. Only catch per-node when you're actually doing something with the error.
|
||||
|
||||
> **What the community MCP can't do:** assigning the error workflow (instance default or per-workflow override) is an n8n **UI setting** — Workflow Settings → Error Workflow. There is no MCP tool to set it. Build the error workflow with the MCP, then tell the user the exact UI step to wire it up, and to repeat it (or set the instance default) for every unattended workflow.
|
||||
|
||||
---
|
||||
|
||||
## What's NOT available via the community MCP
|
||||
|
||||
| Want to do | Reality |
|
||||
|---|---|
|
||||
| Set a workflow's **Error Workflow** setting | UI only (Workflow Settings → Error Workflow). No MCP tool. Build the workflow, then hand the user the UI step. |
|
||||
| Toggle other **workflow settings** (Save Execution Data, timezone, timeout, caller policy) | UI only. `n8n_update_partial_workflow` has `updateSettings`, but the error-workflow assignment is not reliably exposed — confirm in the UI. |
|
||||
| Enable instance-wide error logging (Sentry, server logs) | Instance config, outside n8n workflows entirely. |
|
||||
|
||||
What the MCP **can** do: build the error workflow, set `onError`/`retryOnFail` on nodes (`updateNode`/`patchNodeField`), wire error outputs (`addConnection` with `sourceIndex: 1`), validate (`validate_workflow`, `n8n_validate_workflow`), auto-fix common issues (`n8n_autofix_workflow`), test (`n8n_test_workflow`), and inspect failures (`n8n_executions`).
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
| Anti-pattern | What goes wrong | Fix |
|
||||
|---|---|---|
|
||||
| `onError` set but error output unwired | Error silently discarded; run shows as **succeeded** | Wire `sourceIndex: 1` to a real handler, or revert `onError` to `stopWorkflow` so it's loud |
|
||||
| Error output wired but `onError` not set | Slot never fires; handler unreachable; workflow halts on failure | Set `onError: "continueErrorOutput"` |
|
||||
| Webhook → process → respond, no error branch | Caller gets a timeout or n8n's generic 500 | Wire every fallible node's error output to a Respond |
|
||||
| Error branch returns 200 with an `{error}` body | Caller's client reads success; their error handling never fires | Set `responseCode` to 4xx/5xx explicitly on error Responds |
|
||||
| One 500 `internal_error` for everything | Caller can't tell their bad input from your outage | Map cause → status (4xx caller, 5xx you) |
|
||||
| Catching errors in a Code node and returning them as data | Downstream processes error-shaped data and continues | Let it throw; use `onError: "continueErrorOutput"` + wired path |
|
||||
| Network node with no `retryOnFail` | Every transient 429/blip surfaces as a 5xx; alerts fire on noise | `retryOnFail: true, maxTries: 3, waitBetweenTries: 5000` |
|
||||
| Switch → N Responds differing only by status code | 5 nodes for what's one Respond | Compute the code inline in one expression-driven Respond |
|
||||
| Unattended workflow with no error workflow | A genuine failure goes nowhere | Build an Error Trigger workflow + assign it in the UI |
|
||||
| Error workflow notifies the same channel the workflows monitor | Channel down → error workflow also fails → error vanishes | Use a different channel + a Data Table fallback |
|
||||
| Leaking `$json.error` (stack/SQL/tokens) into the response | Exposes internals to callers/attackers | Log privately, return a sanitized message |
|
||||
|
||||
---
|
||||
|
||||
## Reference files
|
||||
|
||||
| File | Read when |
|
||||
|---|---|
|
||||
| **references/NODE_ERROR_OUTPUTS.md** | Wiring a per-node error output on individual fallible nodes |
|
||||
| **references/API_WORKFLOWS.md** | Building/reviewing a webhook → Respond workflow, including the schema validator |
|
||||
| **references/RESPONSE_SHAPES.md** | Defining response body conventions, status codes, and what not to leak |
|
||||
| **references/ERROR_WORKFLOWS.md** | Setting up the workflow-level catch-all for unattended workflows |
|
||||
|
||||
---
|
||||
|
||||
## Integration with other skills
|
||||
|
||||
- **n8n-workflow-patterns** — the webhook/API and scheduled patterns are where error handling lives. Use it for the overall shape; use this skill to harden it.
|
||||
- **n8n-node-configuration** — `onError`/`retryOnFail` are node config; NODE_FAMILY_GOTCHAS.md covers the Webhook/Respond response-code traps in depth.
|
||||
- **n8n-validation-expert** — the half-wired error output (one of the two steps missing) is a connection/config audit item, not a validation error. This skill is the fix.
|
||||
- **n8n-expression-syntax** — the expression-driven `Response Code` and the alert-message expressions rely on correct `{{ }}` syntax and `$json.error` access.
|
||||
- **n8n-code-javascript / n8n-code-python** — if you catch errors *inside* a Code node, decide deliberately: re-throw to use the error output, or handle and continue. Don't return error-shaped data and pretend it succeeded.
|
||||
- **n8n-code-tool** — an agent's Code Tool surfaces thrown errors back to the LLM, which then retries; that's a different error contract from workflow nodes.
|
||||
- **n8n-binary-and-data** — file/binary operations are fallible too; wire their error outputs like any network node.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference checklist
|
||||
|
||||
For an **API / webhook** workflow:
|
||||
|
||||
- [ ] Webhook trigger uses `responseMode: "responseNode"`
|
||||
- [ ] Input validated upstream → 4xx Respond (schema validator or IF)
|
||||
- [ ] Every fallible node has `onError: "continueErrorOutput"` **and** `main[1]` wired
|
||||
- [ ] Network nodes have `retryOnFail: true, maxTries: 3, waitBetweenTries: 5000`
|
||||
- [ ] Error path ends at a Respond with an **explicit** 4xx/5xx `responseCode`
|
||||
- [ ] Status code matches cause (4xx caller, 5xx you)
|
||||
- [ ] Error body is `{ error, message }` — no stack traces, SQL, or tokens
|
||||
- [ ] Verified with `n8n_get_workflow`: both `onError` and `main[1]` present on each fallible node
|
||||
|
||||
For an **unattended** (scheduled/cron/queue) workflow:
|
||||
|
||||
- [ ] Network nodes have `retryOnFail` configured
|
||||
- [ ] An Error Trigger workflow exists (capture → notify, optional retry)
|
||||
- [ ] The error workflow notifies on a different channel + has a fallback (recursion trap)
|
||||
- [ ] The error-workflow setting is assigned in the n8n UI (MCP can't do it — remind the user)
|
||||
|
||||
---
|
||||
|
||||
**Remember**: the default is silence. Error handling is two moves — make the failure *route* (per-node `onError` + wired output, or a catch-all error workflow) and make it *speak* (a status code and body that tell the truth). Half a move is worse than none, because it looks done.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Retry safety depends on each downstream operation's idempotency and cannot be inferred from workflow shape alone.
|
||||
- MCP validation cannot assign or prove the instance-level Error Workflow setting; verify it in the n8n UI.
|
||||
- Redaction rules must be adapted to the workflow's data classification and legal requirements.
|
||||
@@ -0,0 +1,256 @@
|
||||
# API Workflows
|
||||
|
||||
When a workflow is an HTTP API — a Webhook trigger that ends at a `Respond to Webhook` — error handling stops being optional. The caller is a machine waiting on a response, and the failure modes are unforgiving: a hanging branch becomes a timeout, a wrong status code breaks the caller's error handling, a leaked stack trace becomes a security finding.
|
||||
|
||||
This file covers wiring that pattern so it behaves under failure, not just on the happy path. For the per-node mechanics, see **NODE_ERROR_OUTPUTS.md**; for body conventions and status codes, **RESPONSE_SHAPES.md**.
|
||||
|
||||
---
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
Webhook (responseMode: "responseNode")
|
||||
→ validate input ──valid──→ process ──→ Respond (200, success body)
|
||||
│ └─invalid─→ Respond (400, validation_error body)
|
||||
└── (any fallible node's error output, sourceIndex 1)
|
||||
→ Respond (5xx, structured error body)
|
||||
→ optional: Log full error privately / notify
|
||||
```
|
||||
|
||||
The non-negotiable: **every path ends at a Respond node.** Success, validation failure, execution failure — all of them. A path that doesn't reach a Respond is a hanging branch, and a hanging branch is a caller timeout.
|
||||
|
||||
Set `responseMode: "responseNode"` on the Webhook trigger — without it the trigger acknowledges immediately (`onReceived`) and the caller never sees your computed response. (See **n8n-node-configuration** NODE_FAMILY_GOTCHAS.md for the Webhook/Respond traps.)
|
||||
|
||||
---
|
||||
|
||||
## Wiring every fallible node
|
||||
|
||||
For each fallible node (HTTP, DB, third-party, file op), the two-step setup from NODE_ERROR_OUTPUTS.md:
|
||||
|
||||
1. `onError: "continueErrorOutput"` on the node.
|
||||
2. `addConnection` from its `sourceIndex: 1` to your error Respond (directly, or via a logger).
|
||||
|
||||
A two-node processing chain, both fallible, both routing to one responder:
|
||||
|
||||
```javascript
|
||||
// Turn on error outputs
|
||||
{ type: "updateNode", nodeName: "Fetch User", changes: { onError: "continueErrorOutput" } }
|
||||
{ type: "updateNode", nodeName: "Call External", changes: { onError: "continueErrorOutput" } }
|
||||
|
||||
// Success path
|
||||
{ type: "addConnection", source: "Webhook", target: "Fetch User", sourceIndex: 0 }
|
||||
{ type: "addConnection", source: "Fetch User", target: "Call External", sourceIndex: 0 }
|
||||
{ type: "addConnection", source: "Call External",target: "Respond Success", sourceIndex: 0 }
|
||||
|
||||
// Error paths — both fan in to one responder
|
||||
{ type: "addConnection", source: "Fetch User", target: "Respond Error", sourceIndex: 1 }
|
||||
{ type: "addConnection", source: "Call External",target: "Respond Error", sourceIndex: 1 }
|
||||
```
|
||||
|
||||
Three things to notice:
|
||||
|
||||
1. **One `Respond Error` for many sources.** Fan-in keeps it readable.
|
||||
2. **Both nodes have `onError` set.** Miss it on either and that node's failure halts the workflow instead of routing — and the caller times out.
|
||||
3. **If you surface the error message in the body, sanitize it.** See "Don't leak internals" below.
|
||||
|
||||
The error Respond node, in JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"name": "Respond Error",
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseCode": 502,
|
||||
"responseBody": "={{ JSON.stringify({ error: 'upstream_error', message: 'External service failed' }) }}",
|
||||
"options": {
|
||||
"responseHeaders": { "entries": [{ "name": "Content-Type", "value": "application/json" }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Always set `Content-Type: application/json` explicitly — the default depends on the body shape and isn't reliable.
|
||||
|
||||
---
|
||||
|
||||
## 4xx lives upstream, 5xx comes out of error outputs
|
||||
|
||||
This is the structural rule that keeps an API honest:
|
||||
|
||||
- **Validation / auth / not-found failures are *expected outcomes with a known response*.** They aren't nodes crashing. Check them **before** the work, with IF/Switch + a dedicated Respond, and return the right 4xx directly. Do not route them through error outputs.
|
||||
- **Execution failures (a node actually throwing) are *unexpected*.** Those come out of error outputs as 5xx.
|
||||
|
||||
A real API usually needs several upstream checks, each its own IF/Switch + Respond, *before* the processing stage:
|
||||
|
||||
```
|
||||
Webhook
|
||||
→ Auth present & valid? ── no ──→ Respond 401 unauthorized
|
||||
→ Input valid? ── no ──→ Respond 400 validation_error (with details)
|
||||
→ Caller allowed this op? ── no ──→ Respond 403 forbidden
|
||||
→ Target resource exists? ── no ──→ Respond 404 not_found
|
||||
→ Processing stage (HTTP / DB / etc.) ←── this is where 5xx errors originate
|
||||
```
|
||||
|
||||
That's not over-engineering — it's the difference between the caller getting an actionable `validation_error` and getting a generic 500 they can't act on.
|
||||
|
||||
---
|
||||
|
||||
## Input validation: the Set-node schema validator
|
||||
|
||||
For structured input validation, don't hand-roll an IF chain per field. Run the whole check as an **IIFE inside a single Set node**, branch on its result with one IF, and respond. One node does the work, and it's far faster than a recursive validator running in a Code node + sub-workflow (the sub-workflow invocation dominates that cost).
|
||||
|
||||
The validator node assigns one object field, `result`, computed by the expression below. The expression is **schema-specific** — edit the `REQUIRED_SCHEMA` constant and the per-field checks for your endpoint. The *output keys* are a contract the Respond node consumes — don't rename them.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "n8n-nodes-base.set",
|
||||
"name": "Validate Schema",
|
||||
"parameters": {
|
||||
"mode": "manual",
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "a1",
|
||||
"name": "result",
|
||||
"type": "object",
|
||||
"value": "={{ (() => { const body = $json.body || {}; const errors = []; const REQUIRED_SCHEMA = { type: 'object', properties: { name: { type: 'string', minLength: 1, description: 'Customer full name' }, email: { type: 'string', pattern: '^\\\\S+@\\\\S+\\\\.\\\\S+$', description: 'Contact email address' }, plan: { type: 'string', enum: ['starter','pro','enterprise'], description: 'Subscription plan' }, seat_count: { type: 'integer', minimum: 1, maximum: 500, description: 'Number of licensed seats' } }, required: ['name','email','plan','seat_count'], additionalProperties: false }; if (!('name' in body)) errors.push({ p: 'name', m: 'Missing required field \"name\"', d: 'Customer full name' }); else if (typeof body.name !== 'string') errors.push({ p: 'name', m: 'Expected type \"string\"', d: 'Customer full name' }); if (!('email' in body)) errors.push({ p: 'email', m: 'Missing required field \"email\"', d: 'Contact email address' }); else if (!/^\\S+@\\S+\\.\\S+$/.test(body.email)) errors.push({ p: 'email', m: '\"' + body.email + '\" is not valid', d: 'Contact email address' }); if (!('plan' in body)) errors.push({ p: 'plan', m: 'Missing required field \"plan\"', d: 'Subscription plan' }); else if (['starter','pro','enterprise'].indexOf(body.plan) === -1) errors.push({ p: 'plan', m: '\"' + body.plan + '\" is not allowed. Must be one of: starter, pro, enterprise', d: 'Subscription plan' }); if (!('seat_count' in body)) errors.push({ p: 'seat_count', m: 'Missing required field \"seat_count\"', d: 'Number of licensed seats' }); else { const v = body.seat_count; if (typeof v !== 'number' || !Number.isFinite(v) || Math.floor(v) !== v) errors.push({ p: 'seat_count', m: 'Expected type \"integer\"', d: 'Number of licensed seats' }); else if (v < 1 || v > 500) errors.push({ p: 'seat_count', m: 'Must be between 1 and 500', d: 'Number of licensed seats' }); } if (errors.length === 0) return { valid: true, validationError: null }; const lines = errors.map(e => '• ' + e.p + ': ' + e.m + (e.d ? ' - ' + e.d : '')); const details = {}; errors.forEach(e => { if (!(e.p in details)) details[e.p] = e.m; }); return { valid: false, validationError: 'Validation failed (' + errors.length + ' issue' + (errors.length > 1 ? 's' : '') + '):\\n' + lines.join('\\n'), details: details, requiredSchema: REQUIRED_SCHEMA }; })() }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then an IF on `={{ $json.result.valid }}` (boolean → true) routes to your business logic (200) on the true branch, and to a 400 Respond on the false branch:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"name": "Respond 400",
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseCode": 400,
|
||||
"responseBody": "={{ JSON.stringify({ error: 'validation_error', message: $json.result.validationError, details: $json.result.details, request_schema: $json.result.requiredSchema }) }}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### The procedure for adapting it
|
||||
|
||||
1. **Lift the three-node shape** (Webhook → Validate Schema → IF → success/400 Respond) into your endpoint. Don't reinvent the graph.
|
||||
2. **Edit `REQUIRED_SCHEMA` and the per-field checks** for your input. The pattern per field is mechanical: presence check → type check → constraint check → `errors.push(...)`.
|
||||
3. **Leave the output keys alone.** The IIFE returns `{ valid, validationError, details, requiredSchema }` and the Respond node reads exactly those names. Rename one and the response body breaks.
|
||||
|
||||
The output contract:
|
||||
|
||||
- Valid: `{ valid: true, validationError: null }`
|
||||
- Invalid: `{ valid: false, validationError: <summary string>, details: { <field>: <message> }, requiredSchema: <schema echoed back> }`
|
||||
|
||||
Echoing the schema back lets the caller — or an LLM driving the call — self-correct.
|
||||
|
||||
### Constraint cookbook
|
||||
|
||||
| Need | Inline check |
|
||||
|---|---|
|
||||
| Required field present | `if (!("name" in body)) errors.push(...)` |
|
||||
| Type check | `else if (typeof body.name !== "string") errors.push(...)` |
|
||||
| String length / regex | `body.name.length < N`, `/regex/.test(body.email)` |
|
||||
| Number range | `body.seat_count < min`, `> max` |
|
||||
| Integer | `Math.floor(v) !== v` (also reject non-numbers) |
|
||||
| Enum | `["a","b","c"].indexOf(body.plan) === -1` |
|
||||
| Array | `Array.isArray(body.tags)`, `body.tags.length < N` |
|
||||
| Conditional | nest inside `if (body.type === "X") { ... }` |
|
||||
|
||||
### The escaping gotcha (regex backslashes)
|
||||
|
||||
Inside a JSON `responseBody`/`value` string, a regex like `\S` in the `REQUIRED_SCHEMA` literal needs **four** backslashes (`^\\\\S+...`) because it survives two layers of escaping — JSON string → JS string. The regex literal *executed* inside the IIFE (`/^\\S+@\\S+\\.\\S+$/`) needs only two per `\S`. If your email validation silently never matches, this is why.
|
||||
|
||||
---
|
||||
|
||||
## 5xx: differentiate the body, but keep it one responder
|
||||
|
||||
A single error responder for all 5xx is fine. Differentiate the *body* (and code) by inspecting which failure happened, with an expression instead of a Switch:
|
||||
|
||||
```javascript
|
||||
// responseBody on one Respond node:
|
||||
{{ (() => {
|
||||
const err = $json.error ?? {};
|
||||
const msg = err.message ?? '';
|
||||
if (/timeout/i.test(msg)) return JSON.stringify({ error: 'upstream_timeout', message: 'External service did not respond in time' });
|
||||
if (/rate limit/i.test(msg)) return JSON.stringify({ error: 'service_unavailable', message: 'Upstream rate limit hit' });
|
||||
return JSON.stringify({ error: 'internal_error', message: 'An internal error occurred' });
|
||||
})() }}
|
||||
|
||||
// responseCode on the same node:
|
||||
{{ /timeout/i.test($json.error?.message ?? '') ? 504
|
||||
: (/rate limit/i.test($json.error?.message ?? '') ? 503 : 500) }}
|
||||
```
|
||||
|
||||
Reach for Switch + multiple Respond nodes only when the responses diverge *structurally* (different headers, redirect, different body shape). Same shape, different number = one expression-driven Respond.
|
||||
|
||||
---
|
||||
|
||||
## Don't leak internals
|
||||
|
||||
The tempting one-liner:
|
||||
|
||||
```javascript
|
||||
responseBody: "={{ JSON.stringify({ error: 'internal_error', details: $json.error }) }}" // ❌
|
||||
```
|
||||
|
||||
`$json.error` can carry stack traces, internal node names, connection strings, and upstream response bodies with embedded tokens. Surfacing it hands attackers a map and gives callers nothing useful.
|
||||
|
||||
Instead: log the full error privately, return a sanitized message.
|
||||
|
||||
```javascript
|
||||
// Error output → Log node (sends full $json.error to Sentry/Slack/your logger)
|
||||
{ type: "addConnection", source: "Call External", target: "Log Full Error", sourceIndex: 1 }
|
||||
{ type: "addConnection", source: "Log Full Error", target: "Respond Error", sourceIndex: 0 }
|
||||
```
|
||||
|
||||
```json
|
||||
// Respond Error keeps the body clean:
|
||||
{ "responseCode": 502,
|
||||
"responseBody": "={{ JSON.stringify({ error: 'upstream_error', message: 'External service failed' }) }}" }
|
||||
```
|
||||
|
||||
The caller sees a clean message; the detail stays internal. Full do-not-leak list in **RESPONSE_SHAPES.md**.
|
||||
|
||||
---
|
||||
|
||||
## Correlation IDs (optional)
|
||||
|
||||
If you run distributed tracing or log correlation, add a `request_id` consistently across **every** success and error response (partial coverage is worse than none). Two sources:
|
||||
|
||||
- **Caller-supplied** — read an `X-Request-ID` header, pass it through. Better for tracing across systems.
|
||||
- **Generated** — use `{{ $execution.id }}` or a UUID. Easier.
|
||||
|
||||
Don't conflate this with the `job_id` an async (202) endpoint returns — that's how the caller polls for work later, not a correlation field.
|
||||
|
||||
---
|
||||
|
||||
## Async / 202 pattern
|
||||
|
||||
If the work takes longer than the caller wants to wait, respond 202 immediately and continue async:
|
||||
|
||||
```
|
||||
Webhook → validate → Respond (202, { job_id }) → continue processing → callback / queue / email on completion
|
||||
```
|
||||
|
||||
It has its own gotchas (idempotency, callback retries, status tracking) — build it deliberately. The `job_id` is intrinsic (it's how the work is found later), distinct from the optional `request_id`.
|
||||
|
||||
---
|
||||
|
||||
## Verifying the API workflow
|
||||
|
||||
Before activating:
|
||||
|
||||
1. **Test the success path** with `n8n_test_workflow`. Confirm shape and code. **API workflows almost always have side effects (DB writes, third-party calls, comms) — ask the user before running a test that triggers them.**
|
||||
2. **Trigger an error path** — feed input that breaks a processing node, run, confirm the error Respond fires with the right code and body.
|
||||
3. **Verify connections** with `n8n_get_workflow`: every fallible node has `onError: "continueErrorOutput"` AND `main[1]` wired. (NODE_ERROR_OUTPUTS.md.)
|
||||
4. **Confirm no internal detail leaks** in the error body.
|
||||
5. **Inspect real failures** afterward with `n8n_executions` to confirm the codes you expected are what actually went out.
|
||||
|
||||
If any check fails, fix before activating.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Workflow-Level Error Workflows
|
||||
|
||||
Per-node error outputs handle the failures you anticipated on the nodes you remembered to wire. A **workflow-level error workflow** is the catch-all for everything else — and for an unattended workflow (scheduled, cron, queue worker), it's the difference between "the job silently stopped three days ago" and "an alert arrived the moment it broke".
|
||||
|
||||
What per-node outputs **don't** catch:
|
||||
|
||||
- Failures on nodes you forgot to wire.
|
||||
- Crashes between nodes.
|
||||
- Whole-workflow timeouts.
|
||||
- Trigger failures.
|
||||
|
||||
When an unhandled error escapes any of those, n8n invokes the designated **error workflow** with the failure context. You build that workflow once; it serves every workflow that points at it.
|
||||
|
||||
---
|
||||
|
||||
## What the error workflow receives
|
||||
|
||||
It starts with an **Error Trigger** node, which fires with roughly this payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"execution": {
|
||||
"id": "...",
|
||||
"url": "https://your-n8n/workflow/<wfId>/executions/<execId>",
|
||||
"retryOf": "...",
|
||||
"error": {
|
||||
"name": "NodeApiError",
|
||||
"message": "...",
|
||||
"description": "...",
|
||||
"timestamp": 1715000000000
|
||||
},
|
||||
"lastNodeExecuted": "Fetch order",
|
||||
"mode": "trigger"
|
||||
},
|
||||
"workflow": { "id": "...", "name": "Sync Stripe customers" }
|
||||
}
|
||||
```
|
||||
|
||||
Note what's **not** there: the payload carries the error message and the failed node's *name* (`lastNodeExecuted`), but **not the input data** that caused the failure. Recovering that takes an extra step (below).
|
||||
|
||||
---
|
||||
|
||||
## Minimal error workflow (capture → notify)
|
||||
|
||||
For most workflows, this is enough:
|
||||
|
||||
```
|
||||
Error Trigger → Set (build alert message) → Slack / email (post to #incidents)
|
||||
```
|
||||
|
||||
Three nodes. Fast, hard to get wrong, and it turns silence into a message. Build it with `n8n_create_workflow` (or the partial-update ops), then assign it in the UI (see "Assigning it" below).
|
||||
|
||||
---
|
||||
|
||||
## What to put in the alert
|
||||
|
||||
A good notification lets on-call act without opening n8n first. Pull these from the payload:
|
||||
|
||||
| Field | Expression |
|
||||
|---|---|
|
||||
| Workflow name | `{{ $json.workflow.name }}` |
|
||||
| Workflow ID | `{{ $json.workflow.id }}` |
|
||||
| Editor link | `{{ $json.execution.url.split('/executions/')[0] }}` |
|
||||
| Execution ID | `{{ $json.execution.id }}` |
|
||||
| Execution link | `{{ $json.execution.url }}` |
|
||||
| Failed node | `{{ $json.execution.lastNodeExecuted }}` |
|
||||
| Error message | `{{ $json.execution.error.message }}` |
|
||||
| Error description | `{{ $json.execution.error.description }}` (often empty, useful when set) |
|
||||
| Timestamp | `{{ DateTime.fromMillis($json.execution.error.timestamp).toISO() }}` |
|
||||
|
||||
The `timestamp` is a Unix-ms number — format it with Luxon's `DateTime.fromMillis(...)`. The execution `url` is `{base}/workflow/{id}/executions/{execId}`, so stripping the `/executions/...` tail gives the editor URL.
|
||||
|
||||
A useful Slack body:
|
||||
|
||||
```
|
||||
Workflow failure: *{{ $json.workflow.name }}* (`{{ $json.workflow.id }}`)
|
||||
Open editor: {{ $json.execution.url.split('/executions/')[0] }}
|
||||
Failed node: `{{ $json.execution.lastNodeExecuted }}`
|
||||
Error: {{ $json.execution.error.message }}
|
||||
Execution: {{ $json.execution.url }}
|
||||
Time: {{ DateTime.fromMillis($json.execution.error.timestamp).toISO() }}
|
||||
```
|
||||
|
||||
Two links matter: the **editor link** so on-call can start fixing, and the **execution link** so they can see the exact failed run. Skipping either costs a step. "Workflow failed." is not an alert — it's a notification that you'll have to investigate from scratch.
|
||||
|
||||
---
|
||||
|
||||
## Featureful version: recover the failing input
|
||||
|
||||
The Error Trigger payload tells you *which* node failed, not *what data* broke it. To get the offending payload, fetch the execution with the **n8n** node:
|
||||
|
||||
```
|
||||
Error Trigger
|
||||
→ n8n (resource: Execution, operation: Get,
|
||||
Execution ID: {{ $json.execution.id }},
|
||||
Include Execution Details: true)
|
||||
→ Set (extract failed-node input from the execution data)
|
||||
→ Switch (route by severity)
|
||||
├── high → PagerDuty
|
||||
├── med → Slack #incidents
|
||||
└── low → Slack #monitoring
|
||||
→ Data Table (log for tracking)
|
||||
```
|
||||
|
||||
"Include Execution Details: true" hits `GET /executions/{id}?includeData=true` and returns the full run data, so you can pluck the failed node's input out of `data.resultData.runData[<lastNodeExecuted>]`. Now the on-call message can carry the actual offending payload (which customer, which order id), not just "node X errored".
|
||||
|
||||
Caveats, all of which can turn the error workflow itself into a *new* silent failure:
|
||||
|
||||
- **Requires an n8n API credential** on this workflow (Settings → API → personal access token, then attach it to the n8n node). Without it the node throws a 401 — an unhandled error *inside the error workflow*.
|
||||
- **Requires the failing workflow to persist execution data** (Save Execution Data, instance default or per-workflow). If it doesn't, the API returns metadata only.
|
||||
- **The n8n node call can itself fail** (API down, rate-limited). Wire its error output (`sourceIndex: 1`) to a fallback that still notifies, or the original error vanishes behind a fetch failure.
|
||||
|
||||
Minimal is enough most of the time. The featureful version earns its keep on production-critical workflows where on-call minutes matter.
|
||||
|
||||
---
|
||||
|
||||
## Assigning it (UI only — the MCP can't)
|
||||
|
||||
> The error workflow is assigned in the n8n **UI**: per workflow under **Workflow Settings → Error Workflow**, or as an instance-wide default. There is **no community-MCP tool** to set this assignment. `n8n_update_partial_workflow` exposes an `updateSettings` op, but the error-workflow setting is not reliably writable through it — confirm in the UI.
|
||||
|
||||
So the agent's job is: **build the error workflow with the MCP, then hand the user the exact UI step** — "Open the failing workflow → Settings → Error Workflow → select '<name>'" — and remind them to do it for *every* unattended workflow (or set the instance default once). Building the workflow without assigning it does nothing; the trigger only fires for workflows that point at it.
|
||||
|
||||
---
|
||||
|
||||
## When the error workflow fires (and when it doesn't)
|
||||
|
||||
**Fires** when:
|
||||
|
||||
- A node throws unhandled (not routed via a wired per-node error output).
|
||||
- The workflow itself fails (timeout, OOM).
|
||||
- A trigger fails (rare, possible for non-webhook triggers).
|
||||
|
||||
**Does NOT fire** when:
|
||||
|
||||
- A node's error output is wired — even if the handler does nothing. n8n considers the error *handled*.
|
||||
- You manually stop an execution.
|
||||
- The workflow is paused / inactive.
|
||||
|
||||
That second case is the subtle one: **a per-node error output wired to a no-op that drops the data will *suppress* the error workflow.** From n8n's perspective the error was handled, even though it was swallowed. So only catch per-node when you're genuinely acting on the error; if you want a failure to bubble up to the catch-all, leave it unwired.
|
||||
|
||||
---
|
||||
|
||||
## What the error workflow should NOT do
|
||||
|
||||
- **Make external calls that can themselves fail without a fallback.** If the error workflow fails, the original error disappears — you've added a second silent failure on top of the first.
|
||||
- **Take significant time.** It runs synchronously; a slow error workflow compounds the original failure's impact.
|
||||
|
||||
Keep it fast: parse, notify, return.
|
||||
|
||||
---
|
||||
|
||||
## The recursion trap
|
||||
|
||||
If your monitored workflows alert Slack, and the *error* workflow also alerts Slack, then a Slack outage takes out both — the error workflow fails and the failure goes nowhere. n8n won't re-trigger on its own failure (no infinite loop), but you've lost the alert.
|
||||
|
||||
Mitigations:
|
||||
|
||||
- **Use a different channel than the monitored workflows.** If everything notifies Slack, the error workflow should use email (or vice versa).
|
||||
- **Add a fallback** — write to a Data Table (`n8n_manage_datatable`) if the primary notification fails, so there's always a trace.
|
||||
- **Lean on instance-level logging** (server logs, Sentry) so even an error-workflow failure surfaces somewhere outside n8n.
|
||||
|
||||
---
|
||||
|
||||
## Verifying it works
|
||||
|
||||
After building and assigning:
|
||||
|
||||
1. Make a throwaway workflow that always fails — e.g. an HTTP Request to an invalid URL, with **no** error output wired so the failure is unhandled.
|
||||
2. Run it.
|
||||
3. Confirm the error workflow fires and the notification arrives.
|
||||
|
||||
This catches the setup mistakes that otherwise stay invisible until a real incident: wrong workflow assigned, wrong channel, missing API credential. Do it once before you rely on the alerting.
|
||||
|
||||
---
|
||||
|
||||
## Drift watch
|
||||
|
||||
The Error Trigger payload shape can shift between n8n versions. If a field isn't where this file says, check current n8n docs and update your expressions — a renamed field fails silently as an empty alert, not a thrown error.
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
# Per-Node Error Outputs
|
||||
|
||||
This file is about the **error output on a single node** — the second `main` output that fires when that node throws — and the two-step setup that trips up nearly everyone. For the workflow-level catch-all (Error Trigger workflows) and the webhook/Respond shape, see the rest of `n8n-error-handling`.
|
||||
|
||||
The whole point: a node failing should route somewhere *you* control, instead of halting the run. The cost of forgetting half the setup is one of the worst silent-failure modes in n8n — a run that shows green while quietly dropping its work.
|
||||
|
||||
---
|
||||
|
||||
## The two-step setup (both are required)
|
||||
|
||||
Routing a node's failure takes exactly two changes. Either one alone looks finished and misbehaves.
|
||||
|
||||
### Step 1 — create the error output
|
||||
|
||||
Set `onError: "continueErrorOutput"` on the node. This is what *adds* the second output. Until you do, `main[1]` does not exist and nothing you wire to it can fire.
|
||||
|
||||
```javascript
|
||||
{ type: "updateNode", nodeName: "Google Sheets",
|
||||
changes: { onError: "continueErrorOutput" } }
|
||||
```
|
||||
|
||||
Surgical alternative if you're touching only this field:
|
||||
|
||||
```javascript
|
||||
{ type: "patchNodeField", nodeName: "Google Sheets",
|
||||
fieldPath: "onError", value: "continueErrorOutput" }
|
||||
```
|
||||
|
||||
The valid `onError` values:
|
||||
|
||||
| Value | Effect |
|
||||
|---|---|
|
||||
| `"stopWorkflow"` (default) | Error halts the whole workflow. The right default for runs you watch. |
|
||||
| `"continueRegularOutput"` | The error item flows out the **normal** output (`main[0]`) alongside successes. Rare and usually a mistake — downstream gets error-shaped data and keeps going. |
|
||||
| `"continueErrorOutput"` | The error item flows out a **separate** error output (`main[1]`). This is the one you wire below. |
|
||||
|
||||
### Step 2 — wire the error output
|
||||
|
||||
With `onError: "continueErrorOutput"`, the node has two outputs:
|
||||
|
||||
- `main[0]` → success path (`sourceIndex: 0`)
|
||||
- `main[1]` → error path (`sourceIndex: 1`)
|
||||
|
||||
Wire the error output to a real handler:
|
||||
|
||||
```javascript
|
||||
{ type: "addConnection",
|
||||
source: "Google Sheets",
|
||||
target: "Handle Error",
|
||||
sourceIndex: 1 }
|
||||
```
|
||||
|
||||
`sourceIndex: 1` is the error output. (IF nodes accept the friendly aliases `branch: "true"`/`branch: "false"` for index 0/1; a generic fallible node has no such alias — use the explicit `sourceIndex: 1`.)
|
||||
|
||||
---
|
||||
|
||||
## Failure modes — why "one of two" is so dangerous
|
||||
|
||||
### `onError` set, error output NOT wired
|
||||
|
||||
```javascript
|
||||
// onError: "continueErrorOutput" set on the node,
|
||||
// but no addConnection from sourceIndex 1.
|
||||
```
|
||||
|
||||
On failure the node emits to `main[1]`, which has **no targets**. The error data is silently discarded, downstream never fires, and — this is the trap — the execution is recorded as **succeeded**, because from n8n's perspective the error was "handled" by a branch that happens to go nowhere. No failed execution logged, nothing in the dashboard. The integration "just stops working" and there's no trail.
|
||||
|
||||
**Fix:** wire `sourceIndex: 1` to a real handler, *or* set `onError` back to `"stopWorkflow"` so the failure is loud again.
|
||||
|
||||
### Error output wired, `onError` NOT set
|
||||
|
||||
```javascript
|
||||
// addConnection from "Some Node" sourceIndex 1 → "Handle Error" exists,
|
||||
// but the node still has the default onError: "stopWorkflow".
|
||||
```
|
||||
|
||||
The connection sits in the JSON, but the slot it feeds from never fires. The handler is unreachable. On failure the workflow simply **halts** (default behavior). Less dangerous than the first mode — at least it's loud — but the handler you built does nothing.
|
||||
|
||||
**Fix:** set `onError: "continueErrorOutput"` on the node.
|
||||
|
||||
### Why validation won't save you
|
||||
|
||||
A half-wired error output **validates clean**. `validate_workflow` and `n8n_validate_workflow` don't flag "`onError` is set but `main[1]` is empty" or vice versa — both are structurally legal. This is a runtime behavior, not a schema violation. The only reliable check is to read the workflow back (see Verification below).
|
||||
|
||||
---
|
||||
|
||||
## Common wiring shapes
|
||||
|
||||
### Single fallible node → error handler
|
||||
|
||||
```javascript
|
||||
// Node config: onError: "continueErrorOutput"
|
||||
{ type: "addConnection", source: "HTTP Request", target: "Respond Error", sourceIndex: 1 }
|
||||
```
|
||||
|
||||
### Success path fans out, error path goes elsewhere
|
||||
|
||||
```javascript
|
||||
{ type: "addConnection", source: "HTTP Request", target: "Save Result", sourceIndex: 0 }
|
||||
{ type: "addConnection", source: "HTTP Request", target: "Notify Slack", sourceIndex: 0 }
|
||||
{ type: "addConnection", source: "HTTP Request", target: "Respond Error", sourceIndex: 1 }
|
||||
```
|
||||
|
||||
### Multiple fallible nodes → one shared error handler (fan-in)
|
||||
|
||||
```javascript
|
||||
// Each of these nodes needs onError: "continueErrorOutput" on its own config.
|
||||
{ type: "addConnection", source: "Fetch User", target: "Respond Error", sourceIndex: 1 }
|
||||
{ type: "addConnection", source: "Call External", target: "Respond Error", sourceIndex: 1 }
|
||||
{ type: "addConnection", source: "Write Database", target: "Respond Error", sourceIndex: 1 }
|
||||
```
|
||||
|
||||
Fan-in keeps the graph readable: one error responder, many sources. The handler can inspect which node failed (the error payload carries the failing node's name) to differentiate the response.
|
||||
|
||||
### Both log AND respond on the same failure
|
||||
|
||||
Wiring the error output to two targets composes without conflict — both receive the error data:
|
||||
|
||||
```javascript
|
||||
{ type: "addConnection", source: "Call External", target: "Log Full Error", sourceIndex: 1 }
|
||||
{ type: "addConnection", source: "Call External", target: "Respond Error", sourceIndex: 1 }
|
||||
```
|
||||
|
||||
Useful when you want a sanitized response *and* a private full-detail log on the same failure. (Or chain them: error output → Log → Respond, so the log runs first.)
|
||||
|
||||
---
|
||||
|
||||
## What counts as "fallible"
|
||||
|
||||
Wire an error output on anything that can throw at runtime:
|
||||
|
||||
- Network calls — HTTP Request, third-party API nodes, databases.
|
||||
- Auth failures — expired credential, rotated token.
|
||||
- Schema mismatches — missing DB column, JSON parse failure.
|
||||
- Rate limits — 429 from upstream (configure `retryOnFail` first so these self-heal).
|
||||
- File/binary operations — missing path, permission denied (see **n8n-binary-and-data**).
|
||||
- Code nodes that can throw.
|
||||
|
||||
Usually **not** worth an error output:
|
||||
|
||||
- Set / Edit Fields on already-validated data.
|
||||
- IF / Switch with simple expressions — if those throw it's a bug to fix, not a path to catch.
|
||||
- Pure transformations with no I/O.
|
||||
|
||||
When unsure, wire it. The cost is one connection; the cost of not wiring it is a silent halt.
|
||||
|
||||
---
|
||||
|
||||
## Verification (do this every time)
|
||||
|
||||
After any create/update, pull the workflow with `n8n_get_workflow` and check **both halves** on each fallible node:
|
||||
|
||||
1. **Node config** — `onError` is `"continueErrorOutput"` (or whatever you intended).
|
||||
2. **Connections** — `connections["<node>"].main[1]` contains the expected handler(s).
|
||||
|
||||
If either half is missing, you have a silent-failure setup. Fix before activating.
|
||||
|
||||
`n8n_autofix_workflow` can repair some structural issues, but it won't infer that you *meant* to wire an error path — the intent to handle a given node's failure is yours to express. Treat the read-back as mandatory.
|
||||
|
||||
---
|
||||
|
||||
## When to use an error workflow instead
|
||||
|
||||
Per-node outputs handle the failure of *one node you remembered to wire*. They do **not** catch:
|
||||
|
||||
- Failures on nodes you forgot to wire.
|
||||
- Crashes between nodes.
|
||||
- Whole-workflow timeouts.
|
||||
- Trigger failures.
|
||||
|
||||
For those, you need a workflow-level **error workflow** (Error Trigger node). And note the inverse: a per-node error output that's wired to a no-op which drops the data counts as "handled" — so it will *suppress* the error workflow. Only catch per-node when you're genuinely acting on the error. See **ERROR_WORKFLOWS.md**.
|
||||
@@ -0,0 +1,220 @@
|
||||
# Response Shapes
|
||||
|
||||
Conventions for webhook API response bodies — both success and error. The goal is **predictability**: a caller, a dashboard, or a retry loop should be able to branch on your response without guessing. Pick a shape and hold it across every endpoint on the instance.
|
||||
|
||||
This file is opinions with reasons. The one hard rule is consistency: **consistency within your project beats consistency with this file.** If your repo or company already has a documented API style, that wins.
|
||||
|
||||
---
|
||||
|
||||
## First, match what's already on the instance
|
||||
|
||||
Before adopting any shape here, look at the API workflows already running and reuse their conventions. A one-off custom shape is hard to undo once callers depend on it, and inconsistency across endpoints is worse than any single choice.
|
||||
|
||||
Search with the MCP, then read each result:
|
||||
|
||||
```javascript
|
||||
search_nodes({ query: "webhook" }) // find webhook-shaped workflows via templates
|
||||
n8n_list_workflows({ /* filter */ }) // list workflows on the instance
|
||||
n8n_get_workflow({ id: "<id>" }) // read each one's Respond to Webhook nodes
|
||||
```
|
||||
|
||||
In each existing `Respond to Webhook`, note:
|
||||
|
||||
- Top-level keys — envelope vs bare, presence of `error`/`message`/`request_id`.
|
||||
- Whether success bodies wrap the payload or return it bare.
|
||||
- The exact error-code strings in use (`validation_error` vs `bad_request` vs `INVALID_INPUT`).
|
||||
- Header conventions (`Content-Type`, `Retry-After`, `X-Request-Id`).
|
||||
|
||||
If results are sparse, mixed, or you can't tell whether a convention exists — **ask the user.** "Endpoints A and B use shape X, C uses Y; which is house style?" saves a future migration. Don't invent a domain prefix or envelope from nothing.
|
||||
|
||||
---
|
||||
|
||||
## Success shape
|
||||
|
||||
Return the data bare. For requests that **create or update** a resource, prefer returning the **full resource** with a 200, not `{ "ok": true }` or just the new ID:
|
||||
|
||||
```json
|
||||
{
|
||||
"customer_id": "cus_123",
|
||||
"balance": 4200,
|
||||
"currency": "USD",
|
||||
"created_at": "2026-04-25T12:34:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Returning the resource saves the caller a follow-up GET, lets them confirm what actually persisted (server defaults, normalized values, generated timestamps), and makes the endpoint a single round-trip for a UI that renders the result immediately.
|
||||
|
||||
Deviate only when:
|
||||
|
||||
- The resource is genuinely large and the caller doesn't need it → return the ID, document why.
|
||||
- There is no resource (event ingestion, fire-and-forget) → `{}` or `204 No Content`.
|
||||
- The payload is list-shaped → a top-level array, or `{ "items": [...] }` (friendlier to future pagination metadata).
|
||||
|
||||
---
|
||||
|
||||
## Error shape (the default envelope)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "<machine-readable code>",
|
||||
"message": "<human-readable explanation>"
|
||||
}
|
||||
```
|
||||
|
||||
- `error` is a **stable string identifier**, not a sentence. Clients branch on it.
|
||||
- `message` is the human version — safe to log, safe to show users *after* sanitization.
|
||||
- No `ok: false` flag — the HTTP status code already separates success from failure.
|
||||
|
||||
Optional fields by case:
|
||||
|
||||
| Field | When to include |
|
||||
|---|---|
|
||||
| `details` | Validation errors, with a field-by-field map |
|
||||
| `retry_after` | Rate limits (also set the `Retry-After` header) |
|
||||
| `request_id` | When you run distributed tracing (then on *every* response, not just errors) |
|
||||
| `documentation_url` | Public APIs where you want callers to RTFM |
|
||||
|
||||
---
|
||||
|
||||
## `responseCode` defaults to 200 — set it on every error branch
|
||||
|
||||
This is the single most common API error-handling bug, and it's worth its own section because it produces a *worse-than-useless* result: the body says failure while the status says success.
|
||||
|
||||
**Every `Respond to Webhook` node defaults `responseCode` to 200** — including the ones you wired to error paths. An error branch that returns 200 with `{ "error": "..." }` looks like success to the caller's HTTP client, so their error handling (which keys off the status code) **never fires**. They process your error body as if it were data.
|
||||
|
||||
So: set `responseCode` **explicitly** on every Respond node — not just the success one. (This trap is also documented in **n8n-node-configuration** NODE_FAMILY_GOTCHAS.md, "Webhook / Respond to Webhook".) A workflow can have many Respond nodes, one per response shape; n8n returns whichever fires first.
|
||||
|
||||
```json
|
||||
{ "responseCode": 502,
|
||||
"responseBody": "={{ JSON.stringify({ error: 'upstream_error', message: 'External service failed' }) }}" }
|
||||
```
|
||||
|
||||
For paths that differ only by number, set it with an expression instead of fanning out to N nodes — see **API_WORKFLOWS.md**, "5xx: differentiate the body".
|
||||
|
||||
---
|
||||
|
||||
## Status code → cause
|
||||
|
||||
The status code is the caller's first signal; be deliberate.
|
||||
|
||||
- **2xx** — success. 200 sync, 202 "accepted, processing".
|
||||
- **4xx** — caller's fault. 400 bad input, 401 no auth, 403 not allowed, 404 not found, 409 conflict, 429 rate limited.
|
||||
- **5xx** — your fault. 500 unexpected internal, 502 upstream broken, 503 temporarily down, 504 upstream timeout.
|
||||
|
||||
Distinguishing 4xx from 5xx matters because the caller's tooling depends on it:
|
||||
|
||||
- Caller monitoring alerts on 5xx (your fault) but not 4xx (their fault). Returning 500 for bad input fires *their* pager on *their* bug.
|
||||
- 5xx implies "retry", 4xx implies "don't bother".
|
||||
- Aggregated error rates segment by class — collapse everything to 500 and you lose that.
|
||||
|
||||
### Error codes (a small, stable set)
|
||||
|
||||
Adding a code is fine; renaming an existing one breaks callers.
|
||||
|
||||
**4xx — caller's fault**
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `validation_error` | Required field missing / type wrong |
|
||||
| `invalid_input` | Field present but value invalid |
|
||||
| `unauthorized` | No auth or expired auth |
|
||||
| `forbidden` | Authenticated but not allowed |
|
||||
| `not_found` | Resource doesn't exist |
|
||||
| `conflict` | Conflicts with current state (duplicate key, race) |
|
||||
| `rate_limit_exceeded` | Too many requests |
|
||||
| `unsupported_media_type` | Content-Type wrong |
|
||||
|
||||
**5xx — your fault**
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `internal_error` | Catch-all, something failed unexpectedly |
|
||||
| `upstream_error` | Third-party API returned an error |
|
||||
| `upstream_timeout` | Third-party API didn't respond in time |
|
||||
| `service_unavailable` | Temporarily can't process (down, or rate-limited upstream) |
|
||||
| `not_implemented` | Operation not supported in this version |
|
||||
|
||||
---
|
||||
|
||||
## Validation error details (400)
|
||||
|
||||
For `validation_error`, include per-field detail so the caller can fix the request without guessing. The Set-node schema validator (API_WORKFLOWS.md) produces this directly:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "validation_error",
|
||||
"message": "Validation failed (3 issues):\n• name: Missing required field \"name\"\n• email: \"not-an-email\" is not valid - Contact email address\n• plan: \"premium\" is not allowed. Must be one of: starter, pro, enterprise - Subscription plan",
|
||||
"details": { "name": "Missing required field \"name\"", "email": "\"not-an-email\" is not valid", "plan": "\"premium\" is not allowed" },
|
||||
"request_schema": { "type": "object", "properties": { } }
|
||||
}
|
||||
```
|
||||
|
||||
`message` is the human summary (safe to show), `details` is the structured per-field map (safe to bind to UI fields), and `request_schema` is the schema echoed back so an LLM-driven or programmatic caller can self-correct on the next attempt.
|
||||
|
||||
---
|
||||
|
||||
## Rate-limit responses (429)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "rate_limit_exceeded",
|
||||
"message": "Too many requests. Retry after 30s.",
|
||||
"retry_after": "2026-05-08T21:10:05.135Z"
|
||||
}
|
||||
```
|
||||
|
||||
Also set the HTTP `Retry-After` header (in the Respond node's `options.responseHeaders`). Well-behaved clients respect the header without parsing the body.
|
||||
|
||||
---
|
||||
|
||||
## What NOT to put in an error response
|
||||
|
||||
The body goes to the caller. Treat everything in it as public.
|
||||
|
||||
| Don't include | Why |
|
||||
|---|---|
|
||||
| **Stack traces** — `{ "stack": "Error at line 42 of /opt/..." }` | Reveals paths, versions, library names. A gift to attackers, useless to callers. |
|
||||
| **Upstream errors verbatim** — `{ "details": "<raw upstream body>" }` | Upstream may embed *their* tokens and PII. Surface "upstream service failed" + a request id; details go to your logs. |
|
||||
| **SQL queries** — `{ "query": "SELECT * FROM users WHERE ..." }` | Exposes schema and access patterns. |
|
||||
| **Tokens / credentials / auth values** | Even innocuous-looking `headers`, `config`, or `request` fields can carry token values. Audit error bodies — leaks are easier than you'd expect. |
|
||||
|
||||
The pattern is always the same: **log the full error privately, return a sanitized message.** See "Don't leak internals" in API_WORKFLOWS.md for the log-then-respond wiring.
|
||||
|
||||
---
|
||||
|
||||
## Respond node shape (JSON, for the community MCP)
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"name": "Respond Success",
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseCode": 200,
|
||||
"responseBody": "={{ JSON.stringify($json) }}",
|
||||
"options": { "responseHeaders": { "entries": [{ "name": "Content-Type", "value": "application/json" }] } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Error:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"name": "Respond Error",
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseCode": 502,
|
||||
"responseBody": "={{ JSON.stringify({ error: 'upstream_error', message: 'External service failed' }) }}",
|
||||
"options": { "responseHeaders": { "entries": [{ "name": "Content-Type", "value": "application/json" }] } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two notes that bite people:
|
||||
|
||||
- **Always set `Content-Type: application/json` explicitly.** Default behavior depends on the body shape and isn't reliable.
|
||||
- **With `respondWith: "json"`, pass the object, not a stringified string.** If you hand it `JSON.stringify(obj)` it serializes that string *again* and you get a double-encoded body. Either use `respondWith: "json"` with an object expression (`={{ { error: 'x' } }}`), or keep `JSON.stringify(...)` and let the node treat it as the already-final body — pick one and be consistent. (See **n8n-node-configuration** NODE_FAMILY_GOTCHAS.md.)
|
||||
Reference in New Issue
Block a user