Redis-backed cache adapter for payment identifier idempotency.
Implements the X402.Extensions.PaymentIdentifier.Cache behaviour over a
Redix connection, making the replay-protection
claim safe across a clustered BEAM deployment: all nodes share one store,
so a replayed payment proof routed to two nodes is still claimed exactly
once. The atomic first-writer-wins claim (put_new/3) is a single
SET key value NX PX ttl command — Redis guarantees the
insert-if-absent-with-TTL combination atomically.
Requires the optional redix dependency:
{:redix, "~> 1.5"}Usage
The adapter does not own the Redis connection — you start and supervise Redix yourself (with your pooling, TLS, and reconnection policy) and hand the adapter the connection's pid or registered name:
# In your supervision tree
children = [
{Redix, {System.fetch_env!("REDIS_URL"), name: MyApp.Redis}},
...
]
# Build the adapter state and configure the gate
{:ok, cache} = X402.Extensions.PaymentIdentifier.RedisCache.new(conn: MyApp.Redis)
plug X402.Plug.PaymentGate,
payment_identifier_cache: {X402.Extensions.PaymentIdentifier.RedisCache, cache},
routes: [...]Failure semantics
The adapter honors the X402.Extensions.PaymentIdentifier.Cache contract:
put_new/3returns{:error, :already_exists}only for a live (unexpired) duplicate — Redis expires entries server-side viaPX, so an expired claim never blocks a retry.- Connection and Redis errors are returned as
{:error, reason}(theRedix.ConnectionError/Redix.Errorstruct), whichX402.Plug.PaymentGatetreats as adapter failure and fails closed — a Redis outage degrades to denying new paid requests, not to replay. - Live claims are never evicted by the adapter. Configure the Redis
server with
maxmemory-policy noevictionso Redis doesn't drop live claims either; at capacity, writes then fail with anOOMerror that fails closed like any other adapter error.
Entries are stored under namespace <> payment_id (default namespace
"x402:payment_identifier:"). Cached values are encoded as "verified"
or "rejected:" <> Base64(term); rejection reasons are decoded with
:erlang.binary_to_term/2 in :safe mode, so they must be composed of
existing atoms and data terms (which is true for every reason this
library produces).
Summary
Functions
Removes the entry for a payment identifier (DEL), releasing its claim.
Looks up a payment identifier (GET).
Builds the adapter state for a running Redix connection.
Unconditionally stores a value, resetting its TTL (SET PX).
Atomically claims a payment identifier (SET NX PX).
Types
@type t() :: %X402.Extensions.PaymentIdentifier.RedisCache{ command: module(), conn: term(), namespace: String.t(), ttl_ms: pos_integer() }
Adapter state built by new/1.
Passed as the second element of the {RedisCache, cache} adapter tuple
and handed back to every callback.
Functions
@spec delete(t(), X402.Extensions.PaymentIdentifier.Cache.key()) :: X402.Extensions.PaymentIdentifier.Cache.write_result()
Removes the entry for a payment identifier (DEL), releasing its claim.
@spec get(t(), X402.Extensions.PaymentIdentifier.Cache.key()) :: X402.Extensions.PaymentIdentifier.Cache.get_result()
Looks up a payment identifier (GET).
Returns :miss for absent or expired entries — Redis removes expired
keys server-side. A stored value that cannot be decoded is reported as
{:error, {:invalid_cache_entry, raw}} (fails closed at the gate).
Builds the adapter state for a running Redix connection.
Returns {:error, :missing_dependency} when the optional redix
dependency is unavailable and no :command module was supplied. Raises
NimbleOptions.ValidationError for invalid options (programmer error).
Options
:conn(term/0) - Required. The Redix connection: a pid or the name the connection was registered under. The adapter never starts or supervises the connection.:ttl_ms(pos_integer/0) - Time-to-live for entries in milliseconds (RedisPX). The default value is3600000.:namespace(String.t/0) - Prefix for the Redis keys holding payment identifier claims. The default value is"x402:payment_identifier:".:command- Module implementingX402.Extensions.PaymentIdentifier.RedisCache.Commandused to execute Redis commands. Defaults toRedix. Injectable for testing without a live Redis server.
Examples
{:ok, cache} =
X402.Extensions.PaymentIdentifier.RedisCache.new(
conn: MyApp.Redis,
ttl_ms: :timer.minutes(30),
namespace: "myapp:x402:"
)
@spec put( t(), X402.Extensions.PaymentIdentifier.Cache.key(), X402.Extensions.PaymentIdentifier.Cache.value() ) :: X402.Extensions.PaymentIdentifier.Cache.write_result()
Unconditionally stores a value, resetting its TTL (SET PX).
@spec put_new( t(), X402.Extensions.PaymentIdentifier.Cache.key(), X402.Extensions.PaymentIdentifier.Cache.value() ) :: X402.Extensions.PaymentIdentifier.Cache.put_new_result()
Atomically claims a payment identifier (SET NX PX).
The insert-if-absent and the TTL are one Redis command, so concurrent
claims for the same identifier — from any node — resolve to exactly one
:ok; every other caller gets {:error, :already_exists}. An expired
entry never blocks a new claim (Redis expiry is server-side), and a live
claim is never evicted by the adapter.