A retry is only safe if the step it retries is idempotent, and in response automation “not idempotent” means the second attempt disables an account twice, opens two tickets, or pages the on-call again. This guide builds the idempotency-key pattern that makes any step safe to retry, as one precise technique within playbook orchestration patterns and the broader SOAR playbook & response automation discipline.

Root-Cause Context

Response automation retries constantly — a network blip, a timeout, a redelivered message from the queue — and every retry re-invokes the step. If the step has a real-world side effect and no idempotency guard, each retry repeats that side effect: three firewall blocks where one was intended, three EDR isolate calls tripping the vendor’s abuse throttle, three notifications training the on-call to ignore the pager. The correct number of times to apply a containment action is exactly once, regardless of how many times the case is processed.

There are two routes to idempotency, and mixing them up causes subtle bugs. Some actions are naturally idempotent — “set account state to disabled” is the same whether run once or five times. Others are not — “append a comment”, “increment a counter”, “create a ticket” — and must be made idempotent with a deterministic key plus a durable record of which keys have been applied. The discipline is to identify which kind each step is, prefer natural idempotency where the vendor API offers it, and enforce it with a dedup store everywhere else.

Idempotency-key guard around a side-effecting step A step derives a deterministic idempotency key from the case, action, and target. The key is checked against a durable dedup store; if already present the step returns success without re-applying the side effect, and if absent the side effect runs and the key is recorded atomically so any later retry becomes a no-op. Derive key case ⊕ action ⊕ target Key seen? durable store Return OK no-op Apply + record atomic yes no

Prerequisites

The pattern targets Python 3.11+ and uses redis for the durable dedup store. It plugs into the parent playbook orchestration patterns engine.

pip install "redis>=5.0,<6.0"

Production-Ready Implementation

A decorator wraps any side-effecting coroutine, derives a deterministic key, and applies the action only if the key is new — recording it atomically so a retry short-circuits.

from __future__ import annotations

import functools
import hashlib
import logging
from typing import Awaitable, Callable

logger = logging.getLogger("soc.soar.idempotent")


def idempotency_key(case_id: str, action: str, target: str) -> str:
    raw = f"{case_id}|{action}|{target}"
    return "idem:" + hashlib.sha256(raw.encode()).hexdigest()[:32]


class DedupStore:
    def __init__(self, client, ttl_seconds: int = 7 * 24 * 3600) -> None:
        self._r = client                      # redis.asyncio.Redis
        self._ttl = ttl_seconds

    async def claim(self, key: str) -> bool:
        """Return True if this call claimed the key (first time); False if seen."""
        # SET NX is atomic: exactly one caller wins the claim.
        won = await self._r.set(key, "1", nx=True, ex=self._ttl)
        return bool(won)


def idempotent(action_name: str):
    """Wrap a coroutine (case_id, target, ...) -> None with an idempotency guard."""
    def deco(fn: Callable[..., Awaitable[None]]):
        @functools.wraps(fn)
        async def wrapper(store: DedupStore, case_id: str, target: str, *a, **kw):
            key = idempotency_key(case_id, action_name, target)
            if not await store.claim(key):
                logger.info("idempotent no-op action=%s target=%s", action_name, target)
                return
            try:
                await fn(store, case_id, target, *a, **kw)
            except Exception:
                # Release the claim so a genuine retry can re-attempt.
                await store._r.delete(key)     # ERR_ACTION_071 path
                raise
        return wrapper
    return deco


@idempotent("isolate_host")
async def isolate_host(store: DedupStore, case_id: str, target: str) -> None:
    # ... call the EDR isolate API here; runs at most once per (case, target).
    logger.info("isolating host=%s for case=%s", target, case_id)


async def _demo() -> None:
    import redis.asyncio as redis
    store = DedupStore(redis.Redis(decode_responses=True))
    await isolate_host(store, "C-1", "HOST-9")
    await isolate_host(store, "C-1", "HOST-9")   # second call is a no-op


if __name__ == "__main__":
    import asyncio
    asyncio.run(_demo())

The SET NX claim is the crux: it is atomic, so even two workers racing on the same case both call claim but only one wins and runs the side effect. Releasing the key on failure is deliberate — a step that failed has not really been applied, so a genuine retry must be allowed to re-attempt rather than being permanently short-circuited by a claimed-but-unapplied key.

Error-Code Reference

Code Meaning Action
ERR_ACTION_071 Step failed after claiming the key Release the claim so a retry can re-attempt; log the failure
ERR_ACTION_072 Dedup store unreachable Do not run the side effect; an unguarded action risks duplication
ERR_ACTION_073 Key claimed but no completion record within SLA Investigate a crashed worker; reconcile the target’s real state
ERR_ACTION_074 Idempotency key derived from a mutable field Fix the key inputs; keys must be stable across retries

Operational Notes

  • Prefer natural idempotency. If the vendor API offers a set-state or client-supplied-request-id operation, use it — it is simpler and needs no dedup store.
  • Derive keys from stable fields only. Case id, action name, and target; never a timestamp, attempt count, or anything that changes between retries, or every retry looks new.
  • Release on failure, keep on success. A failed step must be retryable, so delete the claim on exception; a succeeded step must not repeat, so keep the key.
  • Do not run unguarded when the store is down. If the dedup store is unreachable (ERR_ACTION_072), pause rather than risk a duplicate side effect.

Verification Checklist

FAQ

What is the difference between natural and enforced idempotency?

Natural idempotency is a property of the action itself: “set account state to disabled” produces the same result no matter how many times it runs, so no guard is needed. Enforced idempotency is added around actions that are not naturally repeatable — create, append, increment — using a deterministic key and a durable record of applied keys so retries short-circuit. Prefer natural idempotency where the API supports it, and enforce it everywhere else.

Why release the idempotency key when a step fails?

Because a failed step has not actually applied its side effect, so it must remain retryable. If you kept the key after a failure, a genuine retry would see the key as claimed and short-circuit, leaving the action permanently unapplied — the case would look remediated when it is not. Releasing the claim on exception lets the next attempt run, while keeping it on success prevents duplication.