DEV Community

wzg0911
wzg0911

Posted on

Unkillable Agent Engineering #2: The Idempotency Shield — Making Your Agent Execute Once, No Matter How Many Times It Retries

Unkillable Agent Engineering #2: The Idempotency Shield — Making Your Agent Execute Once, No Matter How Many Times It Retries

Prerequisites:

  • [x] Observability deployed (07-19)
  • [x] This is "Unkillable Agent Engineering" Series #2

At 3 AM, your Agent receives one instruction: "Send annual reports to all paid users."

It starts processing—querying the database, generating reports, sending emails. 998 emails out of 1,200 are sent.

Then your server forces a restart.

The Agent wakes up, looks at its to-do list: "202 emails remaining." It continues.

Result: 1,400 emails sent to the same user base—998 of them are duplicates.

Your clients call to ask: "What kind of garbage is this?"

This isn't a bug. This is missing idempotency.


1. Why Agents Are More Vulnerable to Idempotency Disasters Than Regular Programs

Traditional programs: You press one button, it runs once.
Agent: You press one button, it calls 17 tools, accesses 3 APIs, and might restart 5 times mid-process.

The root cause: Agent execution granularity is far larger than "one function call."

Scenario Non-Idempotent Consequence Typical Loss
Payment request retry Double charge $2,000~50,000
Email broadcast restart Duplicate sends User complaints + unsubscribes
Database write retry Duplicate records Data pollution
API call timeout retry Duplicate resource creation Resource waste
Task interruption recovery Partial task runs twice Logic errors

2. Three-Layer Idempotency Defense Architecture

┌─────────────────────────────────────────────────────────┐
│                    IdempotencyGuard                      │
├───────────────┬─────────────────┬─────────────────────┤
│  Layer 1      │   Layer 2        │   Layer 3           │
│  Request      │   Execution      │   Result            │
│  Deduplication │  Idempotency     │   Idempotency       │
│               │                  │                     │
│  IdempotencyKey│ RequestDedupe   │ ResultIdempotent    │
│               │                  │                     │
│  Entry Guard  │  Process Safety  │ Output Guarantee     │
└───────────────┴─────────────────┴─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

3. Layer 1: IdempotencyKey Request Deduplication

import hashlib
import time
from datetime import datetime, timedelta
from typing import Optional, Any, Callable
from dataclasses import dataclass, field
import json

@dataclass
class IdempotencyRecord:
    """Idempotency record"""
    key: str
    status: str  # processing | completed | failed
    result: Optional[Any] = None
    created_at: datetime = field(default_factory=datetime.now)
    expires_at: datetime = None

    def __post_init__(self):
        if self.expires_at is None:
            self.expires_at = self.created_at + timedelta(hours=24)

class IdempotencyGuard:
    """
    Idempotency Guardian — Ensures Agent operations execute only once,
    regardless of how many times they are retried.

    Use cases:
    - Payment/refund requests
    - Resource creation operations
    - State changes
    - External API calls
    """

    def __init__(self, storage=None, ttl_seconds: int = 86400):
        """
        Args:
            storage: Storage backend (Redis/Dict/custom supported)
            ttl_seconds: Idempotency key TTL, default 24 hours
        """
        self.storage = storage or {}  # In-memory for dev; use Redis in prod
        self.ttl = ttl_seconds
        self._lock_timeout = 30  # seconds

    def generate_key(self, agent_id: str, operation: str, params: dict) -> str:
        """
        Generate idempotency key

        Formula: hash(agent_id + operation + sorted_params)
        """
        normalized = json.dumps(params, sort_keys=True, default=str)
        raw = f"{agent_id}:{operation}:{normalized}"
        return hashlib.sha256(raw.encode()).hexdigest()[:32]

    def check_and_lock(self, key: str) -> tuple[bool, Optional[Any]]:
        """
        Check idempotency status

        Returns:
            (is_new_request, existing_result)
            - is_new_request=True: New request, safe to execute
            - is_new_request=False, existing_result has value: Already ran, return cached result
            - is_new_request=False, existing_result=None: Currently processing
        """
        now = datetime.now()

        if key in self.storage:
            record = self.storage[key]

            if now > record.expires_at:
                del self.storage[key]
                return True, None

            if record.status == "completed":
                return False, record.result
            elif record.status == "processing":
                return False, None

        return True, None

    def mark_processing(self, key: str) -> bool:
        """Mark as processing"""
        self.storage[key] = IdempotencyRecord(
            key=key,
            status="processing",
            expires_at=datetime.now() + timedelta(seconds=self.ttl)
        )
        return True

    def mark_completed(self, key: str, result: Any):
        """Mark as completed"""
        if key in self.storage:
            self.storage[key].status = "completed"
            self.storage[key].result = result

    def mark_failed(self, key: str):
        """Mark as failed"""
        if key in self.storage:
            self.storage[key].status = "failed"

    def execute(self, 
                 agent_id: str,
                 operation: str, 
                 params: dict, 
                 func: Callable[[], Any],
                 allow_retry: bool = True) -> Any:
        """
        Execute with idempotency guarantee

        Args:
            agent_id: Agent identifier
            operation: Operation name
            params: Operation parameters
            func: Actual execution function
            allow_retry: Allow retry on failure

        Returns:
            func result (possibly cached)
        """
        key = self.generate_key(agent_id, operation, params)

        is_new, cached_result = self.check_and_lock(key)

        if not is_new:
            if cached_result is not None:
                print(f"[IdempotencyGuard] Returning cached result for key={key}")
                return cached_result
            else:
                print(f"[IdempotencyGuard] Request in progress for key={key}")
                raise Exception("Operation already in progress")

        self.mark_processing(key)

        try:
            result = func()
            self.mark_completed(key, result)
            print(f"[IdempotencyGuard] Execution completed for key={key}")
            return result
        except Exception as e:
            self.mark_failed(key)
            print(f"[IdempotencyGuard] Execution failed for key={key}: {e}")
            if not allow_retry:
                raise
            raise
Enter fullscreen mode Exit fullscreen mode

4. Layer 2: Request Deduplication Middleware

from functools import wraps
import asyncio
from typing import TypeVar, ParamSpec

P = ParamSpec('P')
T = TypeVar('T')

class IdempotencyMiddleware:
    """Idempotency Middleware — Decorator form"""

    def __init__(self, guard: IdempotencyGuard):
        self.guard = guard

    def idempotent(self, 
                   agent_id: str,
                   operation: str,
                   ttl_seconds: int = 3600):
        """
        Idempotency decorator

        Usage:
        ```

python
        guard = IdempotencyGuard()
        middleware = IdempotencyMiddleware(guard)

        @middleware.idempotent(
            agent_id="my-agent",
            operation="send_email"
        )
        async def send_email_task(recipients: list, subject: str):
            # Actual sending logic
            return await email_service.send(recipients, subject)


        ```
        """
        def decorator(func: Callable[P, T]) -> Callable[P, T]:
            @wraps(func)
            async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
                idempotency_params = {
                    "args": str(args),
                    "kwargs": str(sorted(kwargs.items()))
                }

                key = self.guard.generate_key(
                    agent_id, 
                    operation, 
                    idempotency_params
                )

                is_new, cached = self.guard.check_and_lock(key)

                if not is_new and cached is not None:
                    return cached

                self.guard.mark_processing(key)

                try:
                    result = await func(*args, **kwargs)
                    self.guard.mark_completed(key, result)
                    return result
                except Exception as e:
                    self.guard.mark_failed(key)
                    raise

            @wraps(func)
            def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
                idempotency_params = {
                    "args": str(args),
                    "kwargs": str(sorted(kwargs.items()))
                }

                key = self.guard.generate_key(
                    agent_id, 
                    operation, 
                    idempotency_params
                )

                is_new, cached = self.guard.check_and_lock(key)

                if not is_new and cached is not None:
                    return cached

                self.guard.mark_processing(key)

                try:
                    result = func(*args, **kwargs)
                    self.guard.mark_completed(key, result)
                    return result
                except Exception as e:
                    self.guard.mark_failed(key)
                    raise

            return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper

        return decorator
Enter fullscreen mode Exit fullscreen mode

5. Layer 3: Natural Idempotency — Designing Self-Idempotent Operations

Not all operations need idempotency guards. Designing naturally idempotent operations is a better strategy:

class NaturallyIdempotent:
    """
    Natural Idempotency Library — These operations don't produce side effects
    when run multiple times.

    Design Principles:
    1. UPSERT > INSERT/UPDATE (update if exists, create if not)
    2. DELETE is idempotent: deleting non-existent things doesn't error
    3. GET operations: naturally idempotent
    4. SET operations: overwrite old value, always maintain last state
    """

    # ✅ Naturally idempotent operations
    NATURALLY_IDEMPOTENT = [
        "GET",      # Read — always idempotent
        "SET",      # Set — overwrites old value
        "UPSERT",   # Update or insert
        "DELETE",   # Deleting non-existent doesn't error
        "COUNT",    # Count — naturally idempotent
        "EXISTS",   # Existence check — naturally idempotent
    ]

    # ⚠️ Operations needing guard
    NEEDS_GUARD = [
        "CREATE",   # Creation — errors on duplicate
        "SEND",     # Sending — duplicates on retry
        "PAY",      # Payment — charges double on retry
        "REFUND",   # Refund — refunds double on retry
        "SUBSCRIBE",# Subscription — double subscribes on retry
        "CHARGE",   # Charge — charges double on retry
    ]

    @classmethod
    def design_upsert(cls, 
                      table: str, 
                      unique_key: dict, 
                      data: dict) -> str:
        """
        Generate idempotent UPSERT SQL

        ```

sql
        -- Example: User subscription status update
        UPDATE subscriptions 
        SET status = 'active', updated_at = NOW()
        WHERE user_id = '123'

        INSERT INTO subscriptions (user_id, status, created_at, updated_at)
        SELECT '123', 'active', NOW(), NOW()
        WHERE NOT EXISTS (
            SELECT 1 FROM subscriptions WHERE user_id = '123'
        )


        ```
        """
        pass  # Adjust based on your database

    @classmethod
    def needs_guard(cls, operation: str) -> bool:
        """Check if operation needs idempotency guard"""
        return operation.upper() in cls.NEEDS_GUARD
Enter fullscreen mode Exit fullscreen mode

6. Complete Agent Idempotency Integration Example

# agent_idempotency_example.py
"""
Complete Agent Idempotency Integration Example
Scenario: User requests Agent to notify all customers
"""

guard = IdempotencyGuard()
middleware = IdempotencyMiddleware(guard)

class NotificationAgent:
    def __init__(self):
        self.guard = IdempotencyGuard()
        self.middleware = IdempotencyMiddleware(self.guard)
        self.sent_records = {}  # Mock database

    def _generate_batch_id(self, batch_name: str, recipients: list) -> str:
        """Generate batch ID"""
        return hashlib.sha256(
            f"{batch_name}:{len(recipients)}".encode()
        ).hexdigest()[:16]

    @middleware.idempotent(
        agent_id="notification-agent-v1",
        operation="send_batch_notification"
    )
    def send_batch_notification(self, batch_name: str, recipients: list, message: str):
        """
        Batch notification sending — Idempotency guaranteed

        No matter how many times this is called, each user receives only one notification
        """
        batch_id = self._generate_batch_id(batch_name, recipients)

        print(f"[Agent] Starting batch {batch_id}, {len(recipients)} recipients")

        sent = 0
        for recipient in recipients:
            # Check if already sent (Layer 2 protection)
            if recipient in self.sent_records:
                print(f"[Agent] {recipient} already sent, skipping")
                continue

            self._send_single(recipient, message)
            self.sent_records[recipient] = batch_id
            sent += 1

        result = {
            "batch_id": batch_id,
            "sent_count": sent,
            "total": len(recipients),
            "skipped": len(recipients) - sent
        }

        print(f"[Agent] Batch {batch_id} complete: {sent}/{len(recipients)}")
        return result

    def _send_single(self, recipient: str, message: str):
        """Send single notification"""
        print(f"[Agent] Sending to {recipient}: {message[:20]}...")


# Usage
agent = NotificationAgent()

# First call — normal send
result1 = agent.send_batch_notification(
    batch_name="monthly_report",
    recipients=["user1", "user2", "user3"],
    message="Your monthly report is ready..."
)
print(f"First call: {result1}")

# Simulate restart + retry — returns cached result, no duplicate send
result2 = agent.send_batch_notification(
    batch_name="monthly_report",
    recipients=["user1", "user2", "user3"],
    message="Your monthly report is ready..."
)
print(f"Second call (retry): {result2}")

# Verification: same results, no duplicate sends
assert result1 == result2
Enter fullscreen mode Exit fullscreen mode

7. Idempotency Deployment Checklist

Pre-deployment checks:

  • [ ] All payment/refund operations use idempotency guard
  • [ ] All creation operations use UPSERT instead of INSERT
  • [ ] All external API calls have idempotency keys
  • [ ] Task restart recovery logic is idempotent
  • [ ] Database transaction rollbacks don't affect idempotency state
  • [ ] Idempotency keys have reasonable TTL (24h is usually sufficient)
  • [ ] Idempotency guard itself is thread-safe

8. Integration with Unkillable Agent Engineering

The Idempotency Guard is one core component of "Unkillable Agent Engineering":

Layer Component Responsibility
Observability TraceGuard Discover problems
Idempotency IdempotencyGuard Ensure no duplicate operations
Assertion AssertionGuard Verify result correctness
Drift Detection DriftDetector Detect behavior drift
Circuit Breaker CircuitBreaker Prevent cascade failures

These five layers work together to form a complete Agent survival system.

This is also the core thinking behind how we built the idempotency module in ARK Trust — not remediation after the fact, but ensuring every operation is safely re-executable by design.


Further Reading:

  • "Unkillable Agent Engineering #1: The Three Pillars of Observability"
  • "Seven Ways Your AI Agent Dies"

Top comments (0)