n8n error_flow persistently failing — Discord node HTTP 429 (rate limited)

Discovered: 2026-07-01 via alert issue #2488 (N8nWorkflowPersistentlyFailing on error_flow / gG3SsUPtcqgtsbje, n8n-bms4-exporter:9226). Affected element: the global n8n Error Trigger workflow error_flow on bms-4 (n8n.bms-4.infra.zintegrowana.online). This is the instance-wide error handler that fires on every failed execution of every other workflow.

Sibling of docs/playbooks/n8n/n8n-alert-router-empty-webhook.md — same failure symptom (N8nWorkflowPersistentlyFailing on a Discord-posting workflow) but a different root cause. Check the actual node error before applying either fix.


Symptom / trigger

  • Prometheus fires N8nWorkflowPersistentlyFailing for error_flow (steady trickle of errored executions among a high total volume — e.g. 51 errors/1h).

  • The failing node is the Discord node (n8n-nodes-base.discord, webhook auth) and the error is:

    You are being rate limited.
    The resource is being rate limited.
    

    i.e. Discord returns HTTP 429.

Root cause

error_flow fires once per failed execution across the whole n8n instance. During an error burst it posts to Discord faster than the webhook’s rate limit allows, so Discord answers 429. The Discord node had no onError handling, so a single 429 marked the entire error_flow execution as failed — even though the error had already been logged to Supabase earlier in the same run.

Flow (connections): Error TriggerCreate a row (Supabase, logs the error) andMerge; Create a rowMergeDiscord (terminal). Because the Supabase row is created before Discord runs, a Discord 429 loses only the chat notification — the error record is already persisted.

Confirmation commands (no secret values printed)

export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"   # worker: /home/claude-runner/.age/...
N8N_KEY=$(sops -d --input-type dotenv --output-type dotenv secrets/n8n-bms4.env.sops \
            | grep '^BMS4_N8N_API_KEY=' | cut -d= -f2-)
curl -s -H "X-N8N-API-KEY: $N8N_KEY" \
  "https://n8n.bms-4.infra.zintegrowana.online/api/v1/executions?workflowId=gG3SsUPtcqgtsbje&status=error&limit=3&includeData=true" \
  | python3 -c "import sys,json; [print(e['data']['resultData']['error']['node']['name'],'->',e['data']['resultData']['error']['message']) for e in json.load(sys.stdin)['data']]"
unset N8N_KEY

If the message is URL parameter cannot be empty instead of a 429, it is the blank-webhook problem — use n8n-alert-router-empty-webhook.md, not this playbook.

Fix (agent-applicable — resilience hardening)

Make the error handler degrade gracefully: a rate-limited notification must not fail the whole run. Set the Discord node’s On Error to Continue (using regular output) (onError: "continueRegularOutput"). The Supabase error-log row is unaffected (it runs first), so no error record is lost — only a redundant Discord ping during a burst.

Applied to the live workflow via the n8n public API (key passed as header, never echoed):

export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"
export N8N_KEY=$(sops -d --input-type dotenv --output-type dotenv secrets/n8n-bms4.env.sops \
                  | grep '^BMS4_N8N_API_KEY=' | cut -d= -f2-)
export N8N_URL="https://n8n.bms-4.infra.zintegrowana.online"
python3 - <<'PY'
import os,json,urllib.request
KEY=os.environ["N8N_KEY"]; URL=os.environ["N8N_URL"]; WID="gG3SsUPtcqgtsbje"
def req(m,p,b=None):
    d=json.dumps(b).encode() if b is not None else None
    r=urllib.request.Request(URL+p,data=d,method=m,
        headers={"X-N8N-API-KEY":KEY,"Content-Type":"application/json","Accept":"application/json"})
    import urllib.request as u
    with u.urlopen(r) as resp: return json.load(resp)
w=req("GET",f"/api/v1/workflows/{WID}")
for n in w["nodes"]:
    if n.get("type")=="n8n-nodes-base.discord": n["onError"]="continueRegularOutput"
# PUT accepts ONLY name/nodes/connections/settings[/staticData] — strip everything else or it 400s
payload={k:w[k] for k in ("name","nodes","connections","settings") if k in w}
if w.get("staticData") is not None: payload["staticData"]=w["staticData"]
req("PUT",f"/api/v1/workflows/{WID}",payload)
print("onError:", [n.get("onError") for n in req("GET",f"/api/v1/workflows/{WID}")["nodes"] if n.get("type")=="n8n-nodes-base.discord"])
PY
unset N8N_KEY

Do NOT add retryOnFail on the Discord node. Retrying a 429 sends more requests into the same rate limit and amplifies the storm. Continue-on-error is the correct, load-shedding behaviour for a best-effort notification channel.

Then update the one-way sanitized repo backup so git reflects live state (n8n-workflows/error_flow_gG3SsUPtcqgtsbje.json — add "onError": "continueRegularOutput" to the Discord node). n8n is the source of truth; the repo JSON is a backup (see docs/n8n-operations.md).

Verify

  • curl …/executions?workflowId=gG3SsUPtcqgtsbje&status=error&limit=1 — new errored executions stop accumulating; the N8nWorkflowPersistentlyFailing alert clears after Firing for window elapses.
  • Supabase error rows keep being created (Discord failure no longer aborts the run before logging — logging already ran first, so this was always true; now the run is simply marked successful).

Deeper root cause (separate concern — not this alert)

A high error_flow total execution volume means many other workflows are failing and triggering it. That is a distinct investigation (an error storm across the instance), out of scope for clearing this specific alert. If the Discord-429 trickle persists at high volume, open a separate issue to find and fix the upstream workflows that are erroring en masse.

Prevention

  • Every notification-only node (Discord / Telegram / Slack HTTP) in a monitoring/error-handler workflow should use Continue On Error so a dead or throttled channel never turns into an N8nWorkflow*Failing alert. Audit error_flow, mezmo-alert-router, alertmanager-to-incidents-*.