Plug that handles Idempotency-Key HTTP headers.
A single Idempotency-Key HTTP header is required for POST and PATCH requests.
Handling of requests is based on https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
Idempotency Key
The value of the Idempotency-Key HTTP header is combined with the URI path
and hashed with sha256 to produce a request ID. The first response for that
request ID is stored and replayed on subsequent requests.
A separate sha256 hash of the request payload is stored alongside the
request ID and used to detect reuse of the same Idempotency-Key with a
different payload.
Error handling
Status codes are returned per section 2.7 of the IETF draft:
400 Bad Request- whenIdempotency-Keyis missing (IdempotencyPlug.NoHeadersError) or supplied more than once (IdempotencyPlug.MultipleHeadersError).409 Conflict- when a request with the sameIdempotency-Keyis still being processed (IdempotencyPlug.ConcurrentRequestError).422 Unprocessable Content- when theIdempotency-Keyis reused with a different payload or URI (IdempotencyPlug.RequestPayloadFingerprintMismatchError).
Additionally 500 Internal Server Error is returned when the first attempt
was unexpectedly interrupted and never cached
(IdempotencyPlug.HaltedResponseError).
By default these errors are raised and rendered by the Plug.Exception
protocol. Section 2.7 suggests application/problem+json response bodies
(RFC 9457). The :with option
can be used to format responses that way, see the example below.
Cached responses
Cached responses use the original status, body, and headers. Any response
headers already set on the conn by upstream plugs are dropped. An Expires
header is added on top. See IdempotencyPlug.RequestTracker for more on
expiration.
Authenticated requests
When authenticating users, scope the key to the user via the
:idempotency_key option to prevent cross-user cache hits:
plug IdempotencyPlug,
tracker: MyAppWeb.RequestTracker,
idempotency_key: {__MODULE__, :scope_idempotency_key}
def scope_idempotency_key(conn, key), do: {conn.assigns.current_user.id, key}Replay header
Stripe uses an Idempotent-Replayed response header to indicate that a
response is a replay of a cached response. This can be added to cached
responses with the :cached_headers option:
plug IdempotencyPlug,
tracker: MyAppWeb.RequestTracker,
cached_headers: [{"idempotent-replayed", "true"}]Options
:tracker-GenServer.server/0reference for theIdempotencyPlug.RequestTrackerGenServer, required.:idempotency_key- should be a MFA tuple callback (mfa_tuple/0) to process idempotency key. Defaults to{Elixir.IdempotencyPlug, :idempotency_key}.:request_payload- should be a MFA tuple callback (mfa_tuple/0) to shape request payload. Defaults to{Elixir.IdempotencyPlug, :request_payload}.:hash- should be a MFA tuple callback (mfa_tuple/0) to hash an Erlang term. The callback receives(type, value)wheretypeis:idempotency_keyor:request_payload. Defaults to{Elixir.IdempotencyPlug, :sha256_hash}.:with- should be one of:exceptionor MFA tuple (mfa_tuple/0). Defaults to:exception.:exception- raises an error.mfa_tuple/0- calls the MFA to process the conn with error, the connection MUST be halted.
:cached_headers- a list of response{name, value}tuple headers to add on top of the cached response.
Telemetry events
The following events are emitted by the Plug:
[:idempotency_plug, :track, :start]- dispatched before tracking a request- Measurement:
%{monotonic_time: integer(), system_time: integer()} - Metadata:
%{telemetry_span_context: term(), conn: Plug.Conn.t(), tracker: GenServer.server(), idempotency_key: binary()}
- Measurement:
[:idempotency_plug, :track, :exception]- dispatched on exceptions during request tracking- Measurement:
%{monotonic_time: integer(), duration: integer()} Metadata:
%{telemetry_span_context: term(), conn: Plug.Conn.t(), tracker: GenServer.server(), idempotency_key: binary(), kind: :throw | :error | :exit, reason: term(), stacktrace: list()}
- Measurement:
[:idempotency_plug, :track, :stop]- dispatched after successfully tracking a request- Measurement:
%{monotonic_time: integer(), duration: integer()} - Metadata:
%{telemetry_span_context: term(), conn: Plug.Conn.t(), tracker: GenServer.server(), idempotency_key: binary()}
- Measurement:
Examples
plug IdempotencyPlug,
tracker: IdempotencyPlug.RequestTracker,
idempotency_key: {__MODULE__, :scope_idempotency_key},
request_payload: {__MODULE__, :limit_request_payload},
hash: {__MODULE__, :sha512_hash},
with: {__MODULE__, :handle_error},
cached_headers: [{"idempotent-replayed", "true"}]
def scope_idempotency_key(conn, key) do
{conn.assigns.user.id, key}
end
def limit_request_payload(conn) do
Map.drop(conn.params, ["value"])
end
def sha512_hash(_type, value) do
:sha512
|> :crypto.hash(:erlang.term_to_binary(value))
|> Base.encode16()
|> String.downcase()
end
def handle_error(conn, error) do
conn
|> put_status(error.plug_status)
|> put_resp_content_type("application/problem+json")
|> json(%{
type: "https://example.com/errors/idempotency",
title: error.message,
status: error.plug_status
})
|> halt()
end
Summary
Functions
Default :idempotency_key callback.
Default :request_payload callback.
Default :hash callback.
Types
Functions
@spec idempotency_key(Plug.Conn.t(), term()) :: term()
Default :idempotency_key callback.
Returns the key unchanged.
@spec request_payload(Plug.Conn.t()) :: [{binary(), term()}]
Default :request_payload callback.
Returns request params as a sorted list so the resulting hash is deterministic.
Default :hash callback.
Returns a hex encoded sha256 hash of value. type is ignored.