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.
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.