Files
playbook/antigravity-awesome-skills/skills/n8n-error-handling/references/API_WORKFLOWS.md
T
2026-07-22 10:09:47 +00:00

257 lines
14 KiB
Markdown

# 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.