IdempotencyPlug (IdempotencyPlug v0.2.2)

Copy Markdown View Source

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:

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/0 reference for the IdempotencyPlug.RequestTracker GenServer, 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) where type is :idempotency_key or :request_payload. Defaults to {Elixir.IdempotencyPlug, :sha256_hash}.

  • :with - should be one of :exception or 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()}
  • [: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()}

  • [: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()}

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

mfa_tuple()

@type mfa_tuple() :: {module(), atom(), [term()]} | {module(), atom()}

Functions

idempotency_key(conn, key)

@spec idempotency_key(Plug.Conn.t(), term()) :: term()

Default :idempotency_key callback.

Returns the key unchanged.

request_payload(conn)

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

sha256_hash(type, value)

@spec sha256_hash(:idempotency_key | :request_payload, term()) :: binary()

Default :hash callback.

Returns a hex encoded sha256 hash of value. type is ignored.