📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -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