Your n8n AI Agent Works Until Something Fails: Add Laravel Queues, Retries, and Recovery

The worst failure mode in an n8n AI agent isn't the red node. It's the retry that quietly makes things worse.

An agent hits its iteration limit, the node-level retry kicks in, and instead of failing cleanly, it starts over — without the tool-call history it had accumulated. The retry budget resets with each cycle, so the loop can repeat indefinitely. n8n's own issue tracker documents this exact behavior on the AI Agent V3 node: retryOnFail wipes the agent's suspend/resume context, and the retry counter is scoped to a single dequeue rather than the whole execution. Eleven consecutive reset cycles and 105 tool calls for a task that should have needed a fraction of that.

That bug is not the point. The point is what it reveals: n8n's error handling is node-level, not job-level. It knows how to retry a node. It does not know how to own a business transaction across failures, restarts, and time.

That's the gap Laravel queues fill. Not by replacing n8n, but by putting a durable job boundary around the work n8n performs.

📋 Table of Contents

1. The Retry That Made Things Worse

Scenario:
Your agent calls a tool that times out. n8n retries the node three times, one second apart. The tool eventually succeeds on the third try. Everything looks green. But the agent had already begun composing a response based on the failed call, and the retry didn't replay the model's decision — it only re-executed the node. The final output is a blend of stale reasoning and fresh tool data.

Why it matters:
Node retries assume the failure is local. For an HTTP request, that's often true. For an AI agent, the node's "state" includes the conversation, the tool-call history, and the model's accumulated reasoning. Retrying the node without that context is not a retry. It's a restart with amnesia.

Solution:
Disable retryOnFail on AI Agent nodes. Route the failure to an error output and let Laravel decide what happens next. The agent node should fail fast and expose the failure; the queue job should own the retry policy.

{
  "name": "AI Agent",
  "type": "@n8n/n8n-nodes-langchain.agent",
  "parameters": {},
  "retryOnFail": false,
  "onError": "continueErrorOutput"
}

The error output feeds an HTTP Request node that reports the failure to Laravel:

{
  "name": "Report Failure",
  "type": "n8n-nodes-base.httpRequest",
  "parameters": {
    "method": "POST",
    "url": "={{ $env.LARAVEL_URL }}/api/agent-runs/{{ $json.run_id }}/failed",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpHeaderAuth",
    "sendBody": true,
    "bodyParameters": {
      "parameters": [
        { "name": "reason", "value": "={{ $json.error.message }}" },
        { "name": "node", "value": "={{ $json.error.node.name }}" }
      ]
    }
  }
}

Why this works:
The agent node no longer hides its failure behind a retry. Laravel receives a structured failure event, records it against the run, and decides whether to retry the whole job, escalate, or mark it failed. The decision moves from the node to the job boundary, where the full context is available.

🚨 Production warning: Do not set retryOnFail on AI Agent nodes just because it's the default fix for flaky HTTP calls. The retry semantics are different. A retry that resets the agent's context is worse than a clean failure, because it produces a plausible output from a corrupted trajectory.

2. Node Retries Are Not Job Retries

The confusion starts with vocabulary. n8n has "retries." Laravel has "tries." They are not the same thing.

An n8n node retry re-runs a single node inside an execution. The execution stays alive; the node gets another attempt after waitBetweenTries milliseconds, up to maxTries. That's useful for a transient 503 from a third-party API. It does nothing for a process that dies, a container that restarts, or an execution that never gets scheduled because the instance is at its concurrency limit.

A Laravel queue retry releases the entire job back onto the queue. The worker process can die between attempts. The job is serialized, persisted, and rehydrated. The retry survives deploys, restarts, and worker crashes. That's a different category of durability.

The distinction matters most when the failure is not a thrown exception. A job can fail because it timed out, because it exhausted its attempts, or because it was released back to the queue by middleware. n8n's node-level retry has no concept of any of those.

Failure typen8n node retryLaravel queue retry
Transient API 503✅ Works✅ Works
Worker process dies❌ Execution lost✅ Job requeued
Container restart❌ Execution lost✅ Job requeued
Timeout❌ Node may retry, execution state unclear✅ TimeoutExceededException, job failed cleanly
Concurrency queue❌ Cannot retry a queued execution✅ Job waits, then runs
Agent context corruption❌ Retry wipes context✅ Whole job restarts from persisted state

🔍 Why this matters: If your only retry mechanism is node-level, you're covered for flaky APIs and nothing else. The failures that actually page you — restarts, timeouts, stuck queues — require a durable job boundary.

3. Put Laravel in Front of n8n, Not Behind It

Scenario:
Your AI agent is triggered by an n8n webhook. The webhook handler calls the model, calls three tools, and writes to the database. When the model provider rate-limits you at 2 a.m., the execution fails. There's no queue, no backoff, and no record of what was lost. The user sees a 500, and the run disappears.

Why it matters:
n8n is an orchestration layer. It's good at sequencing nodes, branching, and integrating APIs. It's not a durable job system. If the entry point to the whole agent is an n8n webhook, the entire run inherits n8n's execution semantics: no queue, no job-level retry, no replay, no dead-letter.

Solution:
Invert the topology. Laravel owns the request, the job, the state, and the retry policy. n8n owns the execution steps. Laravel dispatches a job; the job calls n8n; n8n reports progress and completion back to Laravel.

Vue / API client
      │
      ▼
Laravel controller
      │  dispatch(ExecuteAgentRun)
      ▼
Queue worker
      │  POST /webhook/agent-run
      ▼
n8n workflow
      │  tools, model calls, approvals
      │
      ├── callback: progress
      └── callback: completed / failed

The Laravel job is thin. It doesn't contain the agent logic. It contains the durable boundary around the n8n call:

namespace App\Jobs;

use App\Models\AgentRun;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;

class ExecuteAgentRun implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $timeout = 120;
    public array $backoff = [10, 60, 300];

    public function __construct(public AgentRun $run) {}

    public function handle(): void
    {
        $this->run->update(['status' => 'dispatched']);

        $response = Http::withToken(config('services.n8n.token'))
            ->timeout(90)
            ->post(config('services.n8n.agent_webhook'), [
                'run_id' => $this->run->id,
                'callback_url' => route('api.agent-runs.callback', $this->run),
                'idempotency_key' => $this->run->id,
            ])
            ->throw();

        $this->run->update([
            'status' => 'running',
            'n8n_execution_id' => $response->json('executionId'),
        ]);
    }

    public function failed(Throwable $e): void
    {
        $this->run->update([
            'status' => 'failed',
            'failure_reason' => $e->getMessage(),
        ]);
    }
}

Why this works:
The job is the unit of retry. If n8n is unreachable, the job backs off and retries. If the worker dies, the job is requeued. If the job exhausts its attempts, failed() records a terminal state that the UI can display. n8n remains the execution engine, but it's no longer the source of durability.

💡 Practical note: Pass an idempotency key to n8n and include it in every callback. n8n will create a new execution each time the webhook is called, even if it's a retry of the same logical run. The key lets Laravel correlate duplicates and ignore a second callback for a run that's already completed.

4. Make the Job Idempotent Before You Make It Retryable

Scenario:
The n8n workflow times out after issuing a refund but before sending the confirmation callback. Laravel retries the job. The workflow runs again and issues the refund a second time. The customer gets two refunds, and your reconciliation report is now wrong.

Why it matters:
Retries are at-least-once delivery. The job will run again; that's the point. If the job isn't idempotent, the retry duplicates the side effect. The more reliable you make the system, the more often this happens, because a "reliable" retry is one that actually reaches the side-effecting code again.

Solution:
Make the job idempotent at the business-transaction level, not just the HTTP level. Two Laravel primitives help: ShouldBeUnique prevents duplicate concurrent dispatches, and an explicit idempotency record prevents duplicate execution after the fact.

class ExecuteAgentRun implements ShouldQueue, ShouldBeUnique
{
    public int $uniqueFor = 3600;

    public function uniqueId(): string
    {
        return $this->run->id;
    }
}

ShouldBeUnique takes a cache lock at dispatch time. But it only prevents a second job from being dispatched while the first is pending or running. It does not prevent a retry of the same job from executing after the lock is released. That's why you need a separate execution ledger:

Schema::create('agent_run_attempts', function (Blueprint $table) {
    $table->ulid('id')->primary();
    $table->foreignUlid('agent_run_id')->index();
    $table->string('idempotency_key')->unique();
    $table->string('status')->default('started');
    $table->timestamp('completed_at')->nullable();
    $table->timestamps();
});

In the job:

public function handle(): void
{
    $attempt = AgentRunAttempt::firstOrCreate(
        ['idempotency_key' => $this->run->id . ':' . $this->attempts()],
        ['agent_run_id' => $this->run->id],
    );

    if ($attempt->status === 'completed') {
        return;
    }

    // ... call n8n, wait for callback, write result

    $attempt->update(['status' => 'completed', 'completed_at' => now()]);
}

Why this works:
The unique key includes the attempt number, so each legitimate retry gets its own row. The firstOrCreate plus the completed check makes the job safe to re-run: if the side effect already happened and the status was recorded, the retry is a no-op. If the side effect happened but the status wasn't recorded, the n8n workflow's own idempotency key should catch the duplicate downstream.

⚠️ Gotcha: ShouldBeUnique locks are released when the job finishes or fails permanently, not when the retry succeeds. If you rely on the unique lock alone to prevent duplicates, a retry that runs after the lock is released will execute again. The lock is a concurrency guard, not an idempotency guarantee.

5. Match Backoff to the Failure, Not to a Default

Scenario:
Your n8n workflow calls a model provider that returns 429 with a Retry-After header of 30 seconds. Your Laravel job retries after 10 seconds, hits the rate limit again, retries after 30, hits it again, and burns all three attempts before the provider's window resets. A failure that should have recovered in one wait becomes a permanent failure.

Why it matters:
A single backoff array is a guess. Different failures have different recovery times. A 429 with Retry-After is not the same as a connection reset, and neither is the same as a 500 from a downstream service that's restarting. Treating them identically either retries too early (wasting attempts) or too late (wasting latency).

Solution:
Use per-failure backoff. Laravel's backoff() method can return different delays based on the exception, and retryUntil() can set an absolute deadline for time-sensitive runs.

public function backoff(): array
{
    return [10, 60, 300];
}

public function retryUntil(): DateTime
{
    return now()->addMinutes(15);
}

For more control, catch the exception and release with a computed delay:

public function handle(): void
{
    try {
        // ... call n8n
    } catch (RequestException $e) {
        if ($e->response?->status() === 429) {
            $retryAfter = (int) $e->response->header('Retry-After', 60);
            $this->release($retryAfter);
            return;
        }

        throw $e;
    }
}

Why this works:
release() puts the job back on the queue without consuming an attempt, so a rate limit doesn't burn your retry budget. The delay comes from the provider, not from a hardcoded array. Other exceptions fall through to the normal retry policy.

🧠 The important part: $tries and release() interact. If you release manually on every rate limit, the job can loop longer than $tries suggests. Pair manual releases with $maxExceptions so unhandled exceptions still have a hard cap, and with retryUntil() so the job can't loop forever.

n8n's own retry settings use a fixed waitBetweenTries in milliseconds, with no jitter and no header awareness. That's fine for a node-level retry on a flaky API. It's not sufficient for a job that needs to respect a provider's rate-limit window.

6. Use Three Retry Budgets, Not One

Most teams set $tries and stop there. That's one budget, and it doesn't distinguish between a job that threw five exceptions and a job that was released five times because it kept hitting a rate limit.

Laravel gives you three independent budgets, and they solve different problems:

$tries is the total number of attempts, including manual releases. It's the outer bound.

$maxExceptions counts only unhandled exceptions. A job that catches an exception and releases itself doesn't consume this budget. That's useful when you want to allow many rate-limit waits without allowing many genuine failures.

retryUntil() is a wall-clock deadline. It stops retries even if attempts remain, which matters for time-sensitive runs where a success after the deadline is worthless.

class ExecuteAgentRun implements ShouldQueue
{
    public int $tries = 10;
    public int $maxExceptions = 3;
    public int $timeout = 120;
    public array $backoff = [10, 30, 60, 120, 300];

    public function retryUntil(): DateTime
    {
        return now()->addMinutes(20);
    }
}

Why this works:
The job can be released many times for rate limits ($tries = 10), but it can only throw three unhandled exceptions ($maxExceptions = 3). The 20-minute deadline caps the whole thing, so a job that keeps getting released can't outlive the user's patience. Each budget catches a different runaway pattern.

🚨 Production warning: A job whose worker dies mid-run does not count toward $maxExceptions. Laravel 13.34 added handling for worker crashes as job exceptions, but this is recent behavior and worth verifying against your exact version. If you use retryUntil() with a rate limiter instead of $tries, a job that keeps crashing workers can keep retrying until the deadline passes.

7. When the Workflow Waits, the Job Must Not

Scenario:
The n8n workflow pauses at a Wait node for human approval. The Laravel job is still holding a queue worker while it waits for the callback. The approval takes two days. For two days, that worker is blocked, and your queue depth climbs.

Why it matters:
n8n's Wait node offloads execution data to the database and resumes when the condition is met. That's a durable pause inside n8n. But the Laravel job that called n8n doesn't automatically know how to pause. If it blocks on a synchronous HTTP call, you've coupled a two-day human workflow to a queue worker's timeout.

Solution:
Split the job into two phases. The first job dispatches the n8n workflow and exits. The second job is triggered by n8n's completion callback. The queue worker is never held across the wait.

class StartAgentRun implements ShouldQueue
{
    public function handle(): void
    {
        Http::withToken(config('services.n8n.token'))
            ->post(config('services.n8n.agent_webhook'), [
                'run_id' => $this->run->id,
                'resume_url' => route('api.agent-runs.resume', $this->run),
            ]);
    }
}

When n8n reaches the Wait node, it sends the resume URL to your approval system. When a human approves, your application calls the resume URL. n8n continues, and on completion it calls back to Laravel, which dispatches the second job:

class CompleteAgentRun implements ShouldQueue
{
    public function handle(): void
    {
        $this->run->update([
            'status' => 'completed',
            'result' => $this->payload['result'],
        ]);
    }
}

Why this works:
The queue worker is only occupied while work is actually happening. The wait is owned by n8n's Wait node, which persists the execution and resumes on the webhook call. The Laravel job boundary is short, retryable, and independent of the human timeline.

⚠️ Gotcha: A Wait node set to resume on a webhook call has no timeout by default. If the approval never arrives, the execution waits forever. Set Limit Wait Time explicitly, and make Laravel's approval expiry shorter than the n8n timeout so the human sees a clean "expired" state instead of a workflow that resumes into a stale decision.

n8n 2.0 changed how sub-workflows return data when they resume from a wait. Previously, the parent received the child's input; now it receives the child's output. If your parent workflow was built around the old behavior, the completion callback may contain the wrong payload. Audit any parent workflow that calls a sub-workflow containing a Wait node before upgrading.

8. Catch the Failure That Never Throws

Scenario:
The n8n workflow completes successfully. The callback says status: completed. But the agent's answer is wrong because a tool returned an empty result and the model filled the gap with a hallucination. Nothing failed. Nothing retried. The queue job is green.

Why it matters:
Queue retries are designed for exceptions. They don't fire when the workflow "succeeds" at the transport level but fails at the semantic level. AI agents are especially prone to this: a malformed tool response, an empty retrieval, or a truncated model output can produce a confident wrong answer without throwing anything.

Solution:
Add an explicit validation step between n8n's completion and the job's success. The validation should be deterministic and independent of the model. If validation fails, the job throws a domain exception and the normal retry policy applies.

class CompleteAgentRun implements ShouldQueue
{
    public function handle(): void
    {
        $payload = $this->payload;

        if (! $this->isStructurallyValid($payload)) {
            throw new InvalidAgentResult('Agent returned an incomplete result.');
        }

        if ($payload['grounded'] === false && $payload['confidence'] < 0.5) {
            throw new UngroundedAgentResult('Agent answered without evidence.');
        }

        $this->run->update([
            'status' => 'completed',
            'result' => $payload['result'],
        ]);
    }

    private function isStructurallyValid(array $payload): bool
    {
        return isset($payload['result'], $payload['tool_calls'])
            && is_string($payload['result'])
            && $payload['result'] !== '';
    }
}

Why this works:
Semantic failures become exceptions, and exceptions become retries. The retry gets a fresh n8n execution, which may retrieve different context or take a different tool path. If the failure is deterministic, the job exhausts its attempts and lands in the failed state where a human can inspect it.

💡 Practical note: Don't put the validator inside the n8n workflow. n8n can check whether a field exists, but it can't reliably judge whether an answer is grounded. Keep the semantic validation in Laravel, where it's testable and version-controlled alongside the rest of the application.

9. Recovery Is a State Machine, Not a Retry Button

Scenario:
A job fails permanently after three attempts. An engineer clicks "Retry" in the queue dashboard. The job runs again, calls n8n again, and creates a second execution. The original execution is still sitting in n8n's history, and now there are two records of the same logical run.

Why it matters:
php artisan queue:retry retries the Laravel job. It does not cancel or reconcile the n8n execution. Without a correlation key, the retry creates a new execution and leaves the old one orphaned. The retry "works," but the system now has duplicate state.

Solution:
Treat recovery as a state transition, not an isolated retry. Before re-dispatching, the recovery process should:

  1. Check the current run state.
  2. Cancel or mark the previous n8n execution as superseded.
  3. Create a new attempt with a new idempotency key.
  4. Dispatch the job with a reference to the attempt.
final class RecoverAgentRun
{
    public function recover(AgentRun $run, User $actor): AgentRun
    {
        DB::transaction(function () use ($run, $actor) {
            $run->update([
                'status' => 'recovering',
                'recovered_by' => $actor->id,
                'recovered_at' => now(),
            ]);

            $run->attempts()
                ->where('status', 'started')
                ->update(['status' => 'superseded']);

            $attempt = $run->attempts()->create([
                'idempotency_key' => $run->id . ':' . Str::ulid(),
                'status' => 'started',
            ]);
        });

        ExecuteAgentRun::dispatch($run->fresh());

        return $run->fresh();
    }
}

The n8n side needs a cancellation endpoint or a workflow-level check that aborts if a newer attempt exists for the same run ID. Without that, the old execution can still complete and write a stale result.

Why this works:
Recovery becomes an auditable event with an actor, a timestamp, and a new attempt. The old attempt is explicitly superseded instead of silently abandoned. The new job starts with a clean idempotency key, and the run's state machine records the transition.

🚨 Production warning: queue:retry is a blunt instrument. It's fine for a job that has no external side effects and no correlated execution. For an agent run that has already called n8n, retrying the Laravel job without reconciling the n8n execution is how you get duplicate tool calls and orphaned workflow history.

A dead-letter queue is not just failed_jobs with a different name. failed_jobs is a passive store: it holds the serialized job until someone retries or deletes it. A dead-letter pipeline is active: it alerts, classifies the failure, and routes to a recovery path based on the failure type. The difference matters at scale, because no one is going to manually triage thousands of failed rows.

What I’d Build First

The pattern that survives production is not "retry everything." It's a layered boundary where each layer owns the failure it's actually equipped to handle.

LayerOwnsDoes not own
n8n node retryTransient API failures inside a single executionProcess death, queue backlog, agent context corruption
n8n Wait nodeDurable pauses for approvals and long-running stepsJob retry, idempotency, recovery
Laravel queue jobWhole-run retry, backoff, timeout, failure stateSemantic validation, business idempotency
Laravel attempt ledgerIdempotency across retries and recoveryn8n execution cancellation
Recovery state machineSuperseding old attempts, auditing, re-dispatchThe actual agent logic

If I were starting from a working n8n AI agent today, I'd change four things in this order:

First, disable retryOnFail on every AI Agent node. The V3 issue is real, and the workaround is documented: let the node fail cleanly and route the error output to a failure callback. You lose nothing except a retry behavior that was actively harmful.

Second, put a Laravel job in front of the n8n webhook. The job doesn't need to contain the agent logic. It needs to own the retry boundary, the timeout, and the failed state. Without it, you have no durable place to record what happened.

Third, make the job idempotent before you tune the backoff. Retries are at-least-once. A well-tuned retry policy on a non-idempotent job just duplicates side effects more efficiently. Start with ShouldBeUnique for concurrency, then add an attempt ledger for execution-level idempotency.

Fourth, split long waits out of the queue. If the workflow can wait for a human, the queue worker should not be holding the job while it waits. n8n's Wait node is built for this. Let it own the pause, and let the callback start a second job when the wait resolves.

The test I'd run before trusting the system is simple. Kill the queue worker while a job is waiting for n8n. Restart it. Replay the n8n callback. Check whether the run completes once, whether the attempt ledger shows one completed attempt, and whether n8n shows one execution for that run ID. If any of those is wrong, the retry boundary isn't durable yet, and the next failure will find the gap.

Post a Comment

Previous Post Next Post