DEV Community

Crzy undefined (undefined)
Crzy undefined (undefined)

Posted on

7 Silent Failures in n8n AI Workflows (and the Error Trigger Setup That Catches Them)

A normal n8n workflow fails the way you'd expect. A node errors, the execution turns red, and you go and fix it.

Workflows with an LLM in the middle fail differently. The model returns something, the HTTP node is happy, the Code node is happy, and the run turns green. Then a few days later someone asks why the invoice sheet has a row with no invoice number and a total of ₹18,000 on an ₹11,800 bill.

I wanted to know how many of these failures my own workflows would actually catch, so I tested it.

The test setup

  • n8n 1.123.84, self-hosted, workflows triggered through production webhooks.
  • The workflow under test: one of my own, an invoice extractor. It goes Webhook → Config → Build Prompt → HTTP Request to /chat/completions (JSON mode, retry on fail, 3 tries) → a Code node that parses the JSON → Respond to Webhook. That's a very common shape for n8n AI workflows.
  • The model: a small mock OpenAI-compatible server on localhost. A real model fails at random, so I couldn't use one here. The mock reads a CASE:<name> marker in the request and returns one specific bad response every time, which makes each failure reproducible. Everything n8n did with those responses is real.

The original parse step looked like this, and I suspect a lot of you have something similar:

const raw = $json.choices?.[0]?.message?.content || '{}';
let out; try { out = JSON.parse(raw); } catch (e) { out = { error: 'LLM returned non-JSON', raw }; }
return [{ json: { result: out }, pairedItem: 0 }];
Enter fullscreen mode Exit fullscreen mode

It looks defensive, and it is. That turns out to be the problem.

The results

# What the "model" returned Original workflow Execution status
1 Empty content HTTP 200, body {} ✅ success
2 JSON cut off mid-object (finish_reason: "length") HTTP 200, {"error":"LLM returned non-JSON", ...} ✅ success
3 Valid JSON wrapped in


```json

fences | HTTP 200, {"error":"LLM returned non-JSON", ...} | ✅ success |
| 4 | A refusal: "I'm sorry, but I can't help…" | HTTP 200, {"error":"LLM returned non-JSON", ...} | ✅ success |
| 5 | Valid JSON, but total: 18000 when subtotal + tax = 11,800 | HTTP 200, the wrong total passed straight through | ✅ success |
| 6 | Valid JSON with invoice_number: null | HTTP 200, the null passed straight through | ✅ success |
| 7 | HTTP 200 with an {"error": {...}} body instead of choices | 3 calls (retry fired), then HTTP 200, body {} | ✅ success |
| bonus | HTTP 429 rate limit on every try | HTTP 200 with an empty body to the caller | ❌ error, but nobody was told |

Seven of the eight bad responses produced a green execution. The only one n8n marked as failed was the 429, and even then the webhook caller got an HTTP 200 with an empty body, and no alert went anywhere.

Here's each one, and why it slips through.

1. Empty content becomes {}

content || '{}' is meant to stop JSON.parse from crashing. What it actually does is turn "the model gave me nothing" into a valid, empty object. Whatever sits downstream (a Google Sheets append, a CRM update) now writes a blank row and reports success.

2. Truncated output looks like "non-JSON"

When a response hits the token limit, OpenAI-compatible APIs set finish_reason: "length" and you get half an object. The parse fails, the catch block wraps it in { error: ... }, and that error object is returned as a successful result. The useful signal was finish_reason, and the original code never looked at it.

3. Markdown fences

Even in JSON mode, some models and proxies wrap the output in

```json

fences. The data is fine, but the parse fails. This one should be recovered, not alerted on.

4. Refusals

"I'm sorry, but I can't help with that" is a perfectly valid chat completion. It gets an HTTP 200, finish_reason: "stop", and plain prose. Unless you check, it goes down the same "non-JSON but successful" path.

5. Confident wrong numbers

This is the dangerous one. The JSON is valid and every field is there, but total is 18000 instead of 11800. No parser will catch it. Only a check that knows what an invoice is will, and with invoices that check is cheap: subtotal + tax should equal total.

6. Missing required fields

invoice_number: null is valid JSON, and my own prompt even says "use null when unknown". That's the correct thing for the model to do, but it still means this record can't go into the books, and the workflow should say so.

7. Errors hidden inside an HTTP 200

Some providers and gateways return an HTTP 200 whose body is {"error": {"message": "Rate limit exceeded..."}} instead of choices. In my run, n8n's HTTP Request node made three calls (retry on fail kicked in), then finished as a success and passed the error body on. choices?.[0] was undefined, so the parse step fell back to {} again.

Bonus: a real failure that's still silent

With a plain 429 on every try, the LLM node did fail, and the execution was marked as an error. But the workflow uses "Respond to Webhook", and the run died before reaching that node, so the caller got an HTTP 200 with an empty body. Unless someone opens the Executions list, nobody finds out.

Fix part 1: make the workflow fail loudly

The idea is simple: anything that isn't usable data should throw. A thrown error in a Code node marks the execution as failed. A failed execution is something n8n can tell you about. A { error: ... } object returned with a 200 is not.

I replaced the parse step with a Validate Code node:

// Fail loudly: throw on anything that isn't complete, valid JSON.
// A thrown error marks the execution as failed, which fires the Error Workflow.
const res = $json;

// Provider error hidden inside an HTTP 200
if (res.error) throw new Error(`Provider returned an error inside a 200: ${res.error.message || JSON.stringify(res.error)}`);
const choice = res.choices?.[0];
if (!choice) throw new Error('No choices in LLM response');

// Truncated output
if (choice.finish_reason === 'length') throw new Error('LLM output was truncated (finish_reason=length). Raise max_tokens or shorten the input.');

// Empty output
let raw = (choice.message?.content || '').trim();
if (!raw) throw new Error('LLM returned empty content');

// Markdown fences are recoverable: strip them. Refusals and prose are not.
raw = raw.replace(/^```
{% endraw %}
(?:json)?\s*/i, '').replace(/\s*
{% raw %}
```$/, '');
let out;
try { out = JSON.parse(raw); } catch (e) { throw new Error(`LLM returned non-JSON: ${raw.slice(0, 120)}`); }

// Required fields (set in Config)
const required = $('Build Prompt').first().json.requiredFields || [];
const missing = required.filter(k => out[k] === undefined || out[k] === null || out[k] === '');
if (missing.length) throw new Error(`Missing required fields: ${missing.join(', ')}`);

// Domain check: only runs when the fields exist. Add your own rules here.
const num = v => (typeof v === 'number' ? v : Number(v));
if (out.subtotal != null && out.tax != null && out.total != null && Math.abs(num(out.subtotal) + num(out.tax) - num(out.total)) > 1)
  throw new Error(`Totals don't add up: ${out.subtotal} + ${out.tax} != ${out.total}`);

return [{ json: { result: out }, pairedItem: 0 }];
Enter fullscreen mode Exit fullscreen mode

requiredFields lives in the Config node next to the base URL and model (['vendor', 'invoice_number', 'invoice_date', 'total', 'currency'] for invoices), so the same node works for other extraction workflows.

Two smaller changes on the LLM node:

  • I set max_tokens explicitly (800 for this schema), so truncation happens at a limit I chose rather than one I don't know about.
  • I set Wait Between Tries to 2000 ms. On my run the default 1-second gap meant all three tries were used up within about two seconds, which doesn't help much against a rate limit.

Notice what the Validate node does not do: it doesn't try to "fix" a refusal or guess a missing invoice number. A pipeline that quietly repairs bad AI output is still a pipeline nobody trusts.

Fix part 2: an Error Trigger workflow that tells you

Throwing only helps if someone hears about it. n8n's answer is the Error workflow: a separate workflow that starts with an Error Trigger node, which you then select in each workflow's settings. The "Handle errors gracefully" page in the n8n docs covers the basics.

Mine is three nodes: Error Trigger → Format Alert (Code) → Send to Slack (HTTP Request).

The Format Alert node:

const e = $json.execution || {};
const wf = $json.workflow || {};
const lastNode = e.lastNodeExecuted || 'unknown node';
const msg = e.error?.message || 'unknown error';
return [{ json: {
  text: `:rotating_light: n8n workflow failed: *${wf.name}*\nNode: ${lastNode}\nError: ${msg}\nExecution: ${e.url || e.id || 'n/a'}`,
  workflow: wf.name, node: lastNode, error: msg, execution_id: e.id, mode: e.mode
} }];
Enter fullscreen mode Exit fullscreen mode

Send to Slack POSTs { "text": $json.text } to a Slack incoming webhook. You could swap it for Telegram, email, or Discord, since it's only one HTTP call.

To wire it up, open the AI workflow, go to Settings → Error workflow, pick the alert workflow, and save. One error workflow can serve all your workflows.

Re-running the same eight cases

Same mock and same inputs, but now with the Validate node and the error workflow set:

# Case Execution Slack alert received
1 Empty content ❌ error LLM returned empty content
2 Truncated ❌ error LLM output was truncated (finish_reason=length)…
3 Fenced JSON ✅ success, correct data returned none needed (recovered)
4 Refusal ❌ error LLM returned non-JSON: I'm sorry, but I can't help…
5 Wrong total ❌ error Totals don't add up: 10000 + 1800 != 18000
6 Missing invoice number ❌ error Missing required fields: invoice_number
7 Error inside a 200 ❌ error Provider returned an error inside a 200: Rate limit exceeded: free-models-per-day
bonus 429 on every try ❌ error The service is receiving too many requests from you (node: LLM)

That's seven alerts, one clean recovery, and the good invoice still passed through untouched. Each alert included the workflow name, the failing node, and a link to the execution.

One thing this doesn't fix

Look at the bonus row again. When the run fails before the Respond to Webhook node, the caller in my test still got an HTTP 200 with an empty body. The error workflow tells you, but it doesn't tell whatever called the webhook.

If another system depends on the response, treat an empty body as a failure on that side, or add an error output on the risky nodes that goes to its own Respond node with a 4xx/5xx code. I haven't built that version yet. It's next on my list.

Quick checklist

  • [ ] No || '{}' or catch → return { error } in parse steps. Throw instead.
  • [ ] Check finish_reason and set max_tokens yourself.
  • [ ] Check for an error key even on HTTP 200 responses.
  • [ ] Strip fences, but don't "repair" refusals.
  • [ ] List required fields and fail when they're missing or null.
  • [ ] Add at least one domain check (totals, dates in range, email looks like an email).
  • [ ] Set an Error workflow on every production workflow.
  • [ ] Decide what webhook callers should get when a run fails.

My other free n8n AI workflows (a lead qualifier and a review reply agent, both using the same OpenAI-compatible HTTP pattern) are on GitHub: arkintof/n8n-ai-agent-workflows.

What's the sneakiest silent failure you've had in an n8n workflow, AI or not? I'm especially curious whether anyone has a clean pattern for returning proper error codes to webhook callers.


Written with AI assistance; workflow built and tested by me.

Top comments (0)