# Why Autonomous AI Agents Enter Infinite Retry Loops (And How to Build SHA-256 Idempotency Arbiters in TypeScript)

> Source: <https://dev.to/dev_kestrel/why-autonomous-ai-agents-enter-infinite-retry-loops-and-how-to-build-sha-256-idempotency-arbiters-4e05>
> Published: 2026-11-14 06:12:11+00:00

If you give an autonomous LLM agent (running in Cursor, Claude Code, LangChain, or CrewAI) the ability to invoke external tools—like dispatching an email, pinging a webhook, or charging a card—you will eventually experience the **Infinite Retry Loop**.

It usually starts innocently: an upstream API experiences a 1.2-second socket timeout or returns a transient HTTP 503 Service Unavailable.

In traditional software, your client executes an exponential backoff policy and fails gracefully. But an autonomous agent does not read error codes like a compiler; it reads error text as context in its conversational history.

When the LLM observes:

```
{
  "status": "error",
  "code": "ETIMEDOUT",
  "message": "Gateway timeout connecting to mail relay"
}
```

The agent's next autoregressive turn is almost always:

*"The dispatch failed due to a timeout. I will try calling the tool again with the same parameters."*

If the socket remains congested for 10 seconds, a fast agent runtime executing in a while-loop can easily fire **60 to 120 duplicate outbound calls**, obliterating your third-party API quota, triggering provider spam alerts, or spamming your end users.

Here is the fundamental rule of agentic engineering: **System prompts are not security boundaries, and they are not concurrency controllers.**

A suggestion in a prompt (`"Do not retry tool calls if they fail"`) can and will be overridden when the model hallucinates or prioritizes completing its user goal. 

You need deterministic, stateful **Idempotency Arbiters** written in code.

An Idempotency Arbiter sits directly in front of your tool execution layer. Before any external I/O or network socket is touched, the arbiter inspects the intent and payload of the tool invocation:

```
┌─────────────────────┐
│ Autonomous LLM Loop │
└──────────┬──────────┘
           │ Tool Call: send_email(to, subject, body)
           ▼
┌──────────────────────────────────────────────┐
│        Deterministic Idempotency Arbiter      │
├──────────────────────────────────────────────┤
│ 1. Canonicalize argument keys                │
│ 2. Hash payload (SHA-256)                    │
│ 3. Check sliding-window TTL cache (e.g. 60s) │
└──────────┬────────────────────────┬──────────┘
           │                        │
     [New Hash]              [Duplicate Hash]
           │                        │
           ▼                        ▼
┌──────────────────────┐   ┌────────────────────────────────┐
│ Execute Tool Socket  │   │ Intercept & Return HTTP 409    │
│ (Cache Hash in TTL)  │   │ "Duplicate invocation dropped" │
└──────────────────────┘   └────────────────────────────────┘
```

`{ a: 1, b: 2 }` vs `{ b: 2, a: 1 }`). We must recursively sort all keys before serialization.` 409 Conflict`, preserving the socket, API budget, and recipient sanity.` OPEN` state, halting execution and notifying a human operator.
Here is a zero-dependency, type-safe implementation of a sliding-window Idempotency Arbiter for tool-calling agents:

``` js
import { createHash } from "node:crypto";

export interface ToolInvocation<T = Record<string, unknown>> {
  toolName: string;
  arguments: T;
  sessionId: string;
}

export interface ArbiterResult<R = unknown> {
  allowed: boolean;
  idempotencyKey: string;
  cachedResponse?: R;
  reason?: "DUPLICATE_CALL_SUPPRESSED" | "CIRCUIT_BREAKER_TRIPPED";
  cooldownRemainingSeconds?: number;
}

interface CacheEntry<R> {
  idempotencyKey: string;
  firstSeenAt: number;
  lastAttemptAt: number;
  attemptCount: number;
  response?: R;
}

export class IdempotencyArbiter {
  private cache = new Map<string, CacheEntry<unknown>>();
  private readonly windowMs: number;
  private readonly maxDuplicatesBeforeTrip: number;

  constructor(options?: { windowMs?: number; maxDuplicatesBeforeTrip?: number }) {
    this.windowMs = options?.windowMs ?? 60_000; // 60-second sliding cooldown
    this.maxDuplicatesBeforeTrip = options?.maxDuplicatesBeforeTrip ?? 3;

    // Background cleanup of expired keys every 30 seconds
    setInterval(() => this.sweepExpired(), 30_000).unref();
  }

  /**
   * Deterministically canonicalizes any JSON-compatible object
   * by deeply sorting keys, guaranteeing identical hashes for identical inputs.
   */
  public canonicalize(value: unknown): string {
    if (value === null || typeof value !== "object") {
      return JSON.stringify(value);
    }
    if (Array.isArray(value)) {
      return "[" + value.map((item) => this.canonicalize(item)).join(",") + "]";
    }
    const record = value as Record<string, unknown>;
    const sortedKeys = Object.keys(record).sort();
    const pairs = sortedKeys.map(
      (key) => `${JSON.stringify(key)}:${this.canonicalize(record[key])}`
    );
    return "{" + pairs.join(",") + "}";
  }

  /**
   * Computes a SHA-256 fingerprint for the specific tool and payload.
   */
  public generateKey<T>(invocation: ToolInvocation<T>): string {
    const canonicalPayload = this.canonicalize(invocation.arguments);
    const raw = `${invocation.sessionId}:${invocation.toolName}:${canonicalPayload}`;
    return createHash("sha256").update(raw).digest("hex");
  }

  /**
   * Evaluates whether the tool invocation is safe to execute or should be suppressed.
   */
  public evaluate<T, R>(invocation: ToolInvocation<T>): ArbiterResult<R> {
    const key = this.generateKey(invocation);
    const now = Date.now();
    const existing = this.cache.get(key);

    if (!existing) {
      // First time seeing this invocation: allow execution
      this.cache.set(key, {
        idempotencyKey: key,
        firstSeenAt: now,
        lastAttemptAt: now,
        attemptCount: 1,
      });
      return { allowed: true, idempotencyKey: key };
    }

    // Check if the sliding window has expired
    if (now - existing.firstSeenAt > this.windowMs) {
      this.cache.set(key, {
        idempotencyKey: key,
        firstSeenAt: now,
        lastAttemptAt: now,
        attemptCount: 1,
      });
      return { allowed: true, idempotencyKey: key };
    }

    // Duplicate call detected inside active window!
    existing.attemptCount += 1;
    existing.lastAttemptAt = now;
    const remainingSeconds = Math.ceil((this.windowMs - (now - existing.firstSeenAt)) / 1000);

    if (existing.attemptCount > this.maxDuplicatesBeforeTrip) {
      return {
        allowed: false,
        idempotencyKey: key,
        reason: "CIRCUIT_BREAKER_TRIPPED",
        cooldownRemainingSeconds: remainingSeconds,
      };
    }

    return {
      allowed: false,
      idempotencyKey: key,
      reason: "DUPLICATE_CALL_SUPPRESSED",
      cachedResponse: existing.response as R | undefined,
      cooldownRemainingSeconds: remainingSeconds,
    };
  }

  /**
   * Records the successful execution result to serve on subsequent duplicate requests.
   */
  public recordResult<R>(idempotencyKey: string, response: R): void {
    const entry = this.cache.get(idempotencyKey);
    if (entry) {
      entry.response = response;
    }
  }

  private sweepExpired(): void {
    const now = Date.now();
    for (const [key, entry] of this.cache.entries()) {
      if (now - entry.firstSeenAt > this.windowMs) {
        this.cache.delete(key);
      }
    }
  }
}
```

Here is how you wire the arbiter into your agent's dispatch dispatcher (e.g., in a Model Context Protocol tool handler or LangChain dynamic tool):

``` js
const arbiter = new IdempotencyArbiter({
  windowMs: 60_000, // 1 minute protection
  maxDuplicatesBeforeTrip: 3,
});

async function handleAgentToolCall(invocation: ToolInvocation) {
  const check = arbiter.evaluate(invocation);

  if (!check.allowed) {
    if (check.reason === "CIRCUIT_BREAKER_TRIPPED") {
      // Return a fatal error to force the LLM loop to halt
      return {
        isError: true,
        content: [
          {
            type: "text",
            text: `[CIRCUIT BREAKER] Fatal: Agent attempted identical invocation ${check.idempotencyKey} repeatedly. Action locked for ${check.cooldownRemainingSeconds}s. Requesting human review.`,
          },
        ],
      };
    }

    // Return synthetic 409 Conflict with cached response or drop message
    return {
      isError: false,
      content: [
        {
          type: "text",
          text: `[IDEMPOTENT] Duplicate action detected. Prior dispatch acknowledged. Cooldown active for ${check.cooldownRemainingSeconds}s.`,
        },
      ],
    };
  }

  try {
    // Safe to execute real network socket!
    const result = await realToolExecution(invocation.arguments);
    arbiter.recordResult(check.idempotencyKey, result);
    return { isError: false, content: [{ type: "text", text: JSON.stringify(result) }] };
  } catch (error) {
    // If the network call failed, do NOT clear the arbiter immediately.
    // Let the cooldown prevent immediate identical retry storms.
    return {
      isError: true,
      content: [{ type: "text", text: `Execution failed: ${(error as Error).message}` }],
    };
  }
}
```

💡 **Developer Infrastructure Note**: When building autonomous agents in Cursor, Claude Code, or CrewAI that trigger real-world actions (email, Slack, webhooks), pairing client-side idempotency arbiters with server-enforced recipient allowlists and spend caps is critical. We use [SadaSend](https://www.sadasend.com/?utm_source=devto&utm_medium=article&utm_campaign=dankestrel&utm_content=agent-idempotency-arbiters) for hosted MCP tools and transactional email guardrails with active Port 25 pre-send bounce shields.

*(Entity Note: SadaSend is an independent developer MTA & MCP service, unrelated to Pakistani consumer mobile wallet sadapay.pk).*

Before deploying any autonomous agent with write or send permissions to production, verify:

What strategies are you using to prevent agent tool retry storms in your production stacks? Let me know in the comments below!
