Current section
Files
Jump to
Current section
Files
lib/idempotency_plug.ex
defmodule IdempotencyPlug do
@moduledoc """
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/
### Request ID
The value of the `Idempotency-Key` HTTP header is combined with an URI to
produce a unique ID for the request. This will be used to store the response
for first-time request. The ID is used to fetch this response in all
subsequent requests.
A fingerprint of the request payload is generated, and used to ensure the ID
is not reused with a different request payload.
### Error handling
- Concurrent requests will return a `409 Conflict` response.
- Mismatch of request payload fingerprint will return
`422 Unprocessable Entity` response.
- If the first-time request was unexpectedly terminated a
`500 Internal Server` is returned.
Cached responses and halted first-time requests, returns an `Expires` header
in the response.
See `IdempotencyPlug.RequestTracker` for more on expiration.
## Options
* `:handler` - the handler module to use for building the idempotent id and
error response. Defaults to `IdempotencyPlug.Handler`.
See `IdempotencyPlug.Handler` for more.
* `:tracker` - the name or pid for the `IdempotencyPlug.RequestTracker`
GenServer. Defaults to `IdempotencyPlug.RequestTracker`.
## Examples
plug IdempotencyPlug,
tracker: IdempotencyPlug.RequestTracker,
handler: IdempotencyPlug.Handler
"""
@behaviour Plug
alias Plug.Conn
alias IdempotencyPlug.{Handler, RequestTracker}
@doc false
@impl true
def init(opts) do
Keyword.merge([tracker: RequestTracker, handler: Handler], opts)
end
@doc false
@impl true
def call(%{method: method} = conn, opts) when method in ~w(POST PATCH) do
case Conn.get_req_header(conn, "idempotency-key") do
[id] -> handle_idempotent_request(conn, id, opts)
[_ | _] -> halt_error(conn, :multiple_headers, opts)
[] -> halt_error(conn, :no_headers, opts)
end
end
def call(conn, _opts), do: conn
defp handle_idempotent_request(conn, id, opts) do
tracker = Keyword.fetch!(opts, :tracker)
idempotent_id = gen_idempotent_id(conn, id, opts)
fingerprint = gen_request_payload_fingerprint(conn)
case RequestTracker.track(tracker, idempotent_id, fingerprint) do
{:processing, _node_caller, _expires} ->
halt_error(conn, :concurrent_request, opts)
{:mismatch, {:fingerprint, _fingerprint}, _expires} ->
halt_error(conn, :fingerprint_mismatch, opts)
{:cache, {:halted, _reason}, expires} ->
conn
|> put_expires_header(expires)
|> halt_error(:halted, opts)
{:cache, {:ok, response}, expires} ->
conn
|> put_expires_header(expires)
|> set_resp(response)
|> Conn.halt()
{:init, id, _expires} ->
update_response_before_send(conn, id, opts)
{:error, error} ->
raise "Couldn't track request, got: #{error}"
end
end
defp gen_idempotent_id(conn, id, opts) do
handler = Keyword.fetch!(opts, :handler)
id = handler.idempotent_id(conn, id)
sha256_checksum("#{inspect id}#{inspect conn.path_info}")
end
defp sha256_checksum(id) do
:sha256
|> :crypto.hash(id)
|> Base.encode16()
|> String.downcase()
end
defp gen_request_payload_fingerprint(conn) do
# Maps are not guaranteed to be ordered so we'll sort it here
sorted_params = conn.params |> Map.to_list() |> Enum.sort()
sha256_checksum(inspect sorted_params)
end
defp update_response_before_send(conn, id, opts) do
tracker = Keyword.fetch!(opts, :tracker)
Conn.register_before_send(conn, fn conn ->
case RequestTracker.put_response(tracker, id, conn_to_response(conn)) do
{:ok, expires} -> put_expires_header(conn, expires)
{:error, error} -> raise "Couldn't store response, got: #{inspect error}"
end
end)
end
defp halt_error(conn, error, opts) do
handler = Keyword.fetch!(opts, :handler)
conn
|> handler.resp_error(error)
|> Conn.halt()
end
defp conn_to_response(conn) do
Map.take(conn, [:resp_body, :resp_headers, :status])
end
defp set_resp(conn, %{resp_body: body, resp_headers: headers, status: status}) do
headers
|> Enum.reduce(conn, fn {key, value}, conn ->
Conn.put_resp_header(conn, key, value)
end)
|> Conn.resp(status, body)
end
defp put_expires_header(conn, expires) do
expires =
expires
|> DateTime.shift_zone!("Etc/UTC")
|> Calendar.strftime("%a, %-d %b %Y %X GMT")
Conn.put_resp_header(conn, "expires", expires)
end
end