> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Gateway Cross-Replica Idempotency

> Exactly-once webhook admission across every gateway replica via a Redis-backed idempotency store

Turn on `store_backend: redis` and every replica of your gateway admits the same webhook exactly once, so a duplicate delivery never runs your agent twice.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Cross-Replica Idempotency"
        W1[📥 Webhook<br/>replica A] --> R{🔐 reserve<br/>Redis SET NX PX}
        W2[📥 Same webhook<br/>replica B] --> R
        R -->|first claim| OK[✅ Admit · run once]
        R -->|duplicate| DROP[🚫 Reject]
    end

    classDef input fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff
    classDef drop fill:#F59E0B,stroke:#7C90A0,color:#fff

    class W1,W2 input
    class R process
    class OK ok
    class DROP drop
```

## Quick Start

**1. Single replica — nothing to do**

One gateway process dedups with the durable SQLite default. Nothing to set.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonai_bot.gateway import WebSocketGateway

agent = Agent(name="Support", instructions="Help users")
WebSocketGateway(agent=agent).start()   # store_backend defaults to durable sqlite
```

**2. Multiple replicas — set store\_backend: redis**

Two or more gateway processes need a shared claim so the same webhook fanned to both is admitted only once. Set it in `gateway.yaml` and make sure a Redis URL is configured — the store reuses the gateway's push `RedisConfig`.

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
gateway:
  push:
    redis:
      url: redis://localhost:6379   # reused for the idempotency store
hooks:
  idempotency:
    store_backend: redis            # cluster-wide exactly-once admission
  hooks:
    - path: gmail
      agent: assistant
```

**3. Or wire it in Python**

Pass the same `store_backend` through the gateway's hooks config where you construct it.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
from praisonaiagents import Agent
from praisonai_bot.gateway import WebSocketGateway

agent = Agent(name="Support", instructions="Help users")

gateway = WebSocketGateway(
    agent=agent,
    config={
        "push": {"redis": {"url": "redis://localhost:6379"}},
        "hooks": {"idempotency": {"store_backend": "redis"}},
    },
)
gateway.start()
```

***

## How It Works

Each replica claims the key `(platform, account, channel_id, message_id)` with a single `SET NX PX`; the first wins and the duplicate is rejected.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant A as Replica A
    participant B as Replica B
    participant Redis as Redis<br/>(key: hookidem:msg-42)

    A->>Redis: SET NX PX (token=A, inflight lease)
    Redis-->>A: OK — claim acquired (admit)
    B->>Redis: SET NX PX (token=B, inflight lease)
    Redis-->>B: nil — already claimed (reject duplicate)
    A->>Redis: record (upgrade to "recorded", 24h TTL)
    Note over A,Redis: A crashes before record?<br/>claim expires after the lease → provider retry re-runs
```

| Call | What it does | Atomicity |
| - | - | - |
| `reserve(key)` | Single `SET NX PX` — atomic claim + owner token + inflight-lease TTL (default 900s). Rejects a redelivery and a concurrent duplicate on another replica. | Native `SET NX PX` |
| `record(key)` | Owner-checked upgrade to a long-lived `recorded` value (TTL 86400s / 24h). A late `record` from a replica whose lease expired is a no-op when another replica reclaimed the key. | Lua `EVAL`, with a `WATCH`/`MULTI` fallback |
| `release(key)` | Compare-and-del on the owner token, so it never drops another replica's live claim. | Lua `EVAL`, with a `WATCH`/`MULTI` fallback |

***

## Fail-Open on Redis Outage

The store never wedges inbound delivery — it degrades to best-effort and surfaces the fact, never a silent green.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Set[⚙️ store_backend: redis] --> Build{🔍 Redis reachable<br/>at build time?}
    Build -->|Yes| Live[🔐 RedisIdempotencyStore<br/>cluster-wide exactly-once]
    Build -->|No — missing pkg / unreachable| SQ[🗄️ Durable SQLite fallback<br/>per-replica]
    Live -->|drops later| RT[⚠️ Runtime outage<br/>admits · best-effort]
    SQ --> Deg[⚠️ durability:idempotency<br/>degraded fact]
    RT --> Deg
    RT -->|recovers| Live

    classDef op fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef check fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef ok fill:#10B981,stroke:#7C90A0,color:#fff
    classDef store fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef warn fill:#8B0000,stroke:#7C90A0,color:#fff

    class Set op
    class Build check
    class Live ok
    class SQ store
    class RT,Deg warn
```

Two failure modes, both surfaced under the `durability:idempotency` degraded owner:

| Mode | When | Reason string | Recovery |
| - | - | - | - |
| Build-time | The `redis` package is missing or Redis is unreachable at construction | `"redis idempotency backend not available (running per-replica)"` | Falls back to durable SQLite; clears when Redis is reachable on the next build / hot-reload |
| Runtime | Redis was reachable at build but dropped later | `"redis idempotency backend unreachable at runtime (dedup best-effort until it recovers)"` | Admits (fails open); **auto-clears** on the next successful Redis op |

> [!WARNING]
>
> On a build-time fallback to SQLite, if a message is fanned to two replicas that do **not** share the SQLite state file, the agent turn can run twice. Watch `health()["degraded_owners"]` and restore Redis.

***

## When to Enable

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Q{replicas > 1?}
    Q -->|Yes| Redis[⚙️ store_backend: redis<br/>cluster-wide exactly-once]
    Q -->|No| Default[🗄️ default durable sqlite]

    classDef choice fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef redis fill:#10B981,stroke:#7C90A0,color:#fff
    classDef sql fill:#189AB4,stroke:#7C90A0,color:#fff

    class Q choice
    class Redis redis
    class Default sql
```

***

## Configuration Options

The operator selects the backend and the store reuses the gateway's push `RedisConfig` — no separate idempotency Redis knob.

| Option | Where | Purpose |
| - | - | - |
| `store_backend` | `hooks.idempotency.store_backend` | Set to `redis` for cluster-wide exactly-once admission |
| `url` | `push.redis.url` | Redis connection URL, reused by the store |
| `host` / `port` / `db` / `password` | `push.redis.*` | Field-wise connection when no `url` is given |
| `prefix` | `push.redis.prefix` | Namespaces keys as `{prefix}hookidem:{key}` so environments sharing one Redis do not collide |

> **[Gateway Turn Lock](/docs/features/gateway-turn-lock.md)**
>
> The sibling shared-state knob that wires the same `RedisConfig`.

***

## Best Practices

> **Enable the turn lock and idempotency together**
>
> For a multi-replica gateway, set both `turn_lock.backend: redis` and `hooks.idempotency.store_backend: redis` — one serialises a session's turns, the other admits each webhook exactly once.

> **Reuse the gateway's push RedisConfig**
>
> The store auto-shares `push.redis`. Configure Redis once; do not duplicate the connection for idempotency.

> **Watch for durability:idempotency**
>
> A `durability:idempotency` entry in `gateway doctor` / `GET /health` means dedup fell back or is best-effort. Alert on it the same way you alert on a degraded channel.

> **Enable it before raising the replica count**
>
> Switch to `store_backend: redis` before scaling past one replica, so a fanned-out webhook is deduped from the first extra pod.

***

## Related

> **[Gateway Turn Lock](/docs/features/gateway-turn-lock.md)**
>
> Sibling shared state — a distributed lease that serialises a session's turns.

> **[Gateway State Durability](/docs/features/gateway-durability.md)**
>
> The default SQLite dedup path and the degraded surface it joins.

> **[Gateway Inbound Hooks](/docs/features/gateway-inbound-hooks.md)**
>
> Where `store_backend` is configured on the inbound hook surface.

> **[Degraded Capabilities](/docs/features/gateway-degraded-capabilities.md)**
>
> Where the `durability:idempotency` fact surfaces.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.