mech.app

The mech.app newsletter

Agentic AI, minus the noise.

Get practical field notes on AI agents, automation, developer tools and security delivered to your inbox.

No spam. Unsubscribe anytime.

Dev Tools

Idempotent Webhooks and Dead-Letter Queues: What Agent Orchestration Keeps Rediscovering About Distributed Systems

Agent workflows are re-encountering classic distributed systems patterns. Here's how idempotency, reconciliation loops, and DLQs matter more than the LLM.

Source: dev.to
Idempotent Webhooks and Dead-Letter Queues: What Agent Orchestration Keeps Rediscovering About Distributed Systems

Agent orchestration is rediscovering distributed systems the hard way. Three separate conversations in one week surfaced the same primitives: idempotent webhooks, reconciliation cron jobs, dead-letter queues, and graph engineering. These are not new problems. They are the same problems microservices teams solved five years ago, now reappearing because agent workflows introduce asynchronous tool calls, partial failures, and retry semantics.

The LLM layer gets the attention. The orchestration layer gets the production incidents.

The Double Charge Problem

An agent calls a payment API. The network times out. The orchestrator retries. The payment API processes both requests. The customer sees two charges.

This is not a testing gap. It is a missing idempotency guarantee. The fix is not to send another request to verify the integration. The fix is to ensure the second request does not create a second side effect.

Idempotency key implementation:

import hashlib
import json

def generate_idempotency_key(event_id: str, action: str, params: dict) -> str:
    """
    Deterministic key from event identity and action parameters.
    Same input always produces same key.
    """
    payload = json.dumps({
        "event_id": event_id,
        "action": action,
        "params": params
    }, sort_keys=True)
    return hashlib.sha256(payload.encode()).hexdigest()

def execute_tool_call(event_id: str, action: str, params: dict, db):
    key = generate_idempotency_key(event_id, action, params)
    
    # Atomic check-and-insert
    result = db.execute(
        """
        INSERT INTO processed_events (idempotency_key, event_id, action, result, created_at)
        VALUES (%s, %s, %s, NULL, NOW())
        ON CONFLICT (idempotency_key) DO NOTHING
        RETURNING id
        """,
        (key, event_id, action)
    )
    
    if not result:
        # Key already exists, return cached result
        cached = db.query(
            "SELECT result FROM processed_events WHERE idempotency_key = %s",
            (key,)
        )
        return cached['result']
    
    # First time seeing this key, execute action
    action_result = perform_action(action, params)
    
    db.execute(
        "UPDATE processed_events SET result = %s WHERE idempotency_key = %s",
        (action_result, key)
    )
    
    return action_result

The database uniqueness constraint handles the race condition. Two requests arrive simultaneously. Both try to insert the same key. One succeeds. One fails. The one that fails reads the result the other one wrote.

The Reconciliation Loop

Agent workflows fail partway through. Step one completes. Step two times out. Step three never runs. The system is in an inconsistent state.

A reconciliation cron job finds incomplete workflows and decides what to do with them. This is not error handling. This is state repair.

Reconciliation architecture:

ComponentResponsibilityFailure Mode
Workflow executorExecute steps, record progressCrashes mid-workflow, leaves partial state
State tableTrack workflow status, last completed stepBecomes stale if executor dies
Reconciliation cronQuery stale workflows, resume or abortMisses workflows if query logic drifts
Dead-letter queueStore workflows that fail repeatedlyGrows unbounded if no manual review process

The reconciliation job runs every five minutes. It queries workflows where status = 'in_progress' and updated_at < NOW() - INTERVAL '10 minutes'. For each workflow, it checks the last completed step and decides whether to retry, skip, or move to the dead-letter queue.

State transitions:

  • Retry: Step failed due to transient error (network timeout, rate limit). Re-execute from last checkpoint.
  • Skip: Step failed due to invalid input. Mark step as failed, continue to next step if workflow allows partial success.
  • DLQ: Step failed three times. Move entire workflow to dead-letter queue for manual review.

The key decision is whether to re-execute a step or replay from a checkpoint. If the step has side effects (send email, charge card, update external system), re-execution creates duplicates. The idempotency key prevents this. If the step is pure computation (transform data, call LLM), re-execution is safe.

Dead-Letter Queues Versus Inline Retry

Inline retry logic handles transient failures. The orchestrator catches an exception, waits, and tries again. This works for network blips and rate limits.

Dead-letter queues handle persistent failures. The orchestrator tries three times, fails three times, and moves the task to a separate queue. A human reviews the queue and decides whether to fix the input, update the code, or discard the task.

When to use each:

  • Inline retry: Transient errors (503, timeout, rate limit). Exponential backoff with jitter. Max three attempts.
  • DLQ: Persistent errors (400, invalid input, missing dependency). No automatic retry. Manual review required.

The mistake is treating all failures as transient. An agent calls an API with malformed JSON. The API returns 400. The orchestrator retries. The API returns 400 again. The orchestrator retries again. The cycle continues until the retry limit is reached. The workflow fails, and the error log is full of identical 400 responses.

The fix is to classify errors at the boundary. If the error is a client error (4xx), do not retry. Move to DLQ immediately. If the error is a server error (5xx) or network error, retry with backoff.

Graph Engineering for Agent Control Flow

Agent workflows are directed acyclic graphs (DAGs). Each node is a tool call. Each edge is a dependency. The orchestrator executes nodes in topological order, respecting dependencies.

The graph is not static. An agent decides at runtime which tool to call next based on the result of the previous tool. The graph grows as the workflow executes.

Graph state management:

class WorkflowGraph:
    def __init__(self, workflow_id: str):
        self.workflow_id = workflow_id
        self.nodes = {}  # node_id -> {status, result, dependencies}
        self.edges = []  # (from_node, to_node)
    
    def add_node(self, node_id: str, dependencies: list[str]):
        self.nodes[node_id] = {
            "status": "pending",
            "result": None,
            "dependencies": dependencies
        }
        for dep in dependencies:
            self.edges.append((dep, node_id))
    
    def mark_complete(self, node_id: str, result: dict):
        self.nodes[node_id]["status"] = "complete"
        self.nodes[node_id]["result"] = result
    
    def get_ready_nodes(self) -> list[str]:
        """Return nodes whose dependencies are all complete."""
        ready = []
        for node_id, node in self.nodes.items():
            if node["status"] != "pending":
                continue
            deps_complete = all(
                self.nodes[dep]["status"] == "complete"
                for dep in node["dependencies"]
            )
            if deps_complete:
                ready.append(node_id)
        return ready

The orchestrator polls get_ready_nodes() and executes them in parallel. When a node completes, the orchestrator checks whether new nodes are ready and executes those.

The failure mode is a node that never completes. Its dependents wait forever. The reconciliation cron detects this by querying workflows where status = 'in_progress' and no node has completed in the last ten minutes. It marks the stuck node as failed and moves the workflow to the DLQ.

Observability Boundaries

Agent workflows span multiple systems. The orchestrator calls an LLM. The LLM calls a tool. The tool calls an external API. Each hop introduces latency, failure modes, and retry semantics.

Trace propagation:

  • Workflow ID: Unique identifier for the entire workflow. Logged at every boundary.
  • Node ID: Unique identifier for each tool call. Logged when the node starts and completes.
  • Idempotency key: Logged when a tool call is deduplicated.
  • Retry count: Logged when a tool call is retried.

The observability stack must answer three questions:

  1. Where did the workflow fail? Query by workflow ID, find the last completed node.
  2. Why did the workflow fail? Query by node ID, find the error message and retry count.
  3. Did the workflow create duplicate side effects? Query by idempotency key, count distinct results.

Without these traces, debugging is guesswork. With them, debugging is a query.

Security Boundaries in Multi-Step Workflows

Agent workflows cross trust boundaries. An agent reads from a user database, calls an external API, and writes to a billing system. Each step has different access requirements.

Principle of least privilege:

  • Step 1 (read user data): Read-only credentials for user database. No access to billing system.
  • Step 2 (call external API): API key scoped to specific endpoints. No access to user database.
  • Step 3 (write to billing): Write credentials for billing system. No access to user database or external API.

The orchestrator must not hold all credentials in one place. Each step receives only the credentials it needs. If step two is compromised, the attacker cannot access the user database or billing system.

Credential injection at runtime:

def execute_node(node_id: str, action: str, params: dict, credential_store):
    # Fetch only the credentials needed for this action
    creds = credential_store.get_credentials_for_action(action)
    
    # Execute with scoped credentials
    result = perform_action(action, params, creds)
    
    # Clear credentials from memory
    del creds
    
    return result

The credential store is a separate service. It maps actions to credentials and enforces access policies. The orchestrator never sees the raw credentials. It receives short-lived tokens that expire after the step completes.

Deployment Shape

Agent orchestrators run in two modes: synchronous and asynchronous.

Synchronous mode:

  • Agent receives request, executes workflow, returns result.
  • Works for workflows that complete in seconds.
  • Fails for workflows that take minutes or hours.

Asynchronous mode:

  • Agent receives request, queues workflow, returns job ID.
  • Worker pool executes workflows from queue.
  • Client polls for result or receives webhook when complete.

The deployment shape depends on workflow duration. If 95% of workflows complete in under five seconds, synchronous mode is fine. If 5% take longer, those workflows need asynchronous mode.

Hybrid approach:

  • Start workflow in synchronous mode.
  • If workflow does not complete in five seconds, move to queue and return job ID.
  • Client switches from waiting for response to polling for result.

This avoids the complexity of asynchronous mode for fast workflows while handling slow workflows gracefully.

Technical Verdict

Use these patterns when:

  • Agent workflows call external APIs with side effects (payments, emails, database writes).
  • Workflows span multiple steps and can fail partway through.
  • Retry semantics matter (you cannot afford duplicate charges or missed steps).
  • You need to debug failures across multiple systems.

Avoid these patterns when:

  • Workflows are single-step and idempotent by design (read-only queries, pure computation).
  • Failures are acceptable (logging, analytics, non-critical notifications).
  • You are prototyping and do not need production reliability yet.

The primitives are not optional for production agent systems. Idempotency keys prevent duplicate side effects. Reconciliation loops repair inconsistent state. Dead-letter queues surface persistent failures. Graph engineering makes control flow inspectable. These are the same patterns microservices teams learned the hard way. Agent orchestration is learning them again.

Tags

agentic-ai orchestration infrastructure

Primary Source

dev.to