Proposal: Plug.ProblemDetail — RFC 9457 standard error format for HTTP APIs

Hi everyone,

I’ve been implementing RFC 9457 (Problem Details for HTTP APIs) in a Phoenix API project and wanted to share the pattern and discuss whether it makes sense to have first-class support in Phoenix/Plug. I searched the forum, Hex, and GitHub — there are zero existing packages or discussions about this in the Elixir ecosystem, while other major frameworks have already adopted it.

What is RFC 9457?

RFC 9457 (formerly RFC 7807) defines a standard JSON format for HTTP API error responses. Instead of every API inventing its own error shape, you return application/problem+json with a well-known structure:

{
  "type": "https://example.com/problems/user-not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "User with ID 42 was not found.",
  "instance": "/api/users/42"
}

The five standard fields are:

Field Required Purpose
type Yes* URI identifying the problem type
title No Short human-readable summary
status Yes HTTP status code
detail No Human-readable explanation for this instance
instance No URI identifying this specific occurrence

* Defaults to "about:blank" when omitted.

The spec also allows extension members — any additional fields you need (validation errors, trace IDs, etc.) are flattened as top-level JSON keys.

Who already uses it?

  • Spring BootProblemDetail class, opt-in via spring.mvc.problemdetails=true (docs)
  • ASP.NETProblemDetails class, built into Microsoft.AspNetCore.Mvc
  • FastAPI — via community packages
  • Gorfc7807 package
  • Rustproblem_details crate
  • Kotlinkotlin-rfc9457-problem-details

Elixir has zero. No Hex package, no framework support, no forum discussion.

The current state in Phoenix

Phoenix generates ErrorJSON with ad-hoc error shapes:

# Default generated ErrorJSON
def render("404.json", _assigns) do
  %{errors: %{detail: "Not Found"}}
end

Every Phoenix API project reinvents its own error format. Some use %{errors: ...}, others %{error: ...}, others %{message: ...}. Clients consuming multiple Phoenix APIs need to handle each one differently.

Proposal: opt-in Plug.ProblemDetail struct

The idea is simple — a struct that lives in Plug, available to anyone who wants to use it. No breaking changes, no migration, fully opt-in. Like Spring Boot: it’s there, you use it if you want.

The struct (~50 lines)

defmodule Plug.ProblemDetail do
  @type t :: %__MODULE__{
          status: pos_integer(),
          type: String.t() | nil,
          title: String.t() | nil,
          detail: String.t() | nil,
          instance: String.t() | nil,
          properties: map()
        }

  @enforce_keys [:status]
  defstruct [:type, :title, :status, :detail, :instance, properties: %{}]

  @spec new(pos_integer()) :: t()
  def new(status) when is_integer(status) and status > 0 do
    %__MODULE__{status: status, title: Plug.Conn.Status.reason_phrase(status)}
  end

  @spec new(pos_integer(), keyword()) :: t()
  def new(status, opts) when is_integer(status) and status > 0 and is_list(opts) do
    %__MODULE__{
      status: status,
      type: Keyword.get(opts, :type),
      title: Keyword.get(opts, :title, Plug.Conn.Status.reason_phrase(status)),
      detail: Keyword.get(opts, :detail),
      instance: Keyword.get(opts, :instance)
    }
  end

  def put_type(%__MODULE__{} = pd, type) when is_binary(type), do: %{pd | type: type}
  def put_title(%__MODULE__{} = pd, title) when is_binary(title), do: %{pd | title: title}
  def put_detail(%__MODULE__{} = pd, detail) when is_binary(detail), do: %{pd | detail: detail}
  def put_instance(%__MODULE__{} = pd, inst) when is_binary(inst), do: %{pd | instance: inst}

  def put_extension(%__MODULE__{} = pd, key, value) do
    %{pd | properties: Map.put(pd.properties, to_string(key), value)}
  end
end

Notice it reuses Plug.Conn.Status.reason_phrase/1 — no duplicate maps needed.

JSON encoding

Implements JSON.Encoder (OTP 27+) to produce spec-compliant output:

defimpl JSON.Encoder, for: Plug.ProblemDetail do
  def encode(%Plug.ProblemDetail{} = pd, encoder) do
    standard =
      %{"type" => pd.type || "about:blank", "status" => pd.status}
      |> put_non_nil("title", pd.title)
      |> put_non_nil("detail", pd.detail)
      |> put_non_nil("instance", pd.instance)

    # Extensions are flattened as top-level keys per RFC 9457 §4.2
    # Standard fields win on name collision
    pd.properties
    |> Map.drop(~w(type title status detail instance))
    |> Map.merge(standard)
    |> encoder.(encoder)
  end

  defp put_non_nil(map, _key, nil), do: map
  defp put_non_nil(map, key, value), do: Map.put(map, key, value)
end

Pipe-friendly usage

The API is designed for Elixir’s pipe operator:

ProblemDetail.new(422)
|> ProblemDetail.put_type("https://api.example.com/validation-error")
|> ProblemDetail.put_detail("One or more fields are invalid.")
|> ProblemDetail.put_instance(conn.request_path)
|> ProblemDetail.put_extension("errors", %{email: ["can't be blank"]})

Produces:

{
  "type": "https://api.example.com/validation-error",
  "title": "Unprocessable Content",
  "status": 422,
  "detail": "One or more fields are invalid.",
  "instance": "/api/users",
  "errors": {"email": ["can't be blank"]}
}

Usage in a FallbackController

defmodule MyAppWeb.FallbackController do
  use MyAppWeb, :controller

  def call(conn, {:error, :not_found}) do
    problem =
      Plug.ProblemDetail.new(404)
      |> Plug.ProblemDetail.put_detail("Resource not found.")
      |> Plug.ProblemDetail.put_instance(conn.request_path)

    conn
    |> put_resp_content_type("application/problem+json")
    |> put_status(404)
    |> json(problem)
  end

  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    problem =
      Plug.ProblemDetail.new(422)
      |> Plug.ProblemDetail.put_detail("Validation failed.")
      |> Plug.ProblemDetail.put_instance(conn.request_path)
      |> Plug.ProblemDetail.put_extension("errors", translate_errors(changeset))

    conn
    |> put_resp_content_type("application/problem+json")
    |> put_status(422)
    |> json(problem)
  end
end

What this is NOT

  • Not a breaking change — existing %{errors: ...} responses continue working
  • Not a framework opinion — it’s a value type in Plug, like Plug.Conn.Status
  • Not a new dependency — zero external deps, ~50 lines of code
  • Not mandatory — you use it if you want, ignore it otherwise

Open questions

1. Where should it live?

  • Plug (Plug.ProblemDetail) — since it’s an HTTP-level concern and Plug already owns Plug.Conn.Status
  • Phoenix — as part of the generated ErrorJSON pattern
  • Both — struct in Plug, usage pattern in Phoenix generators

2. Should phx.new generate with it?

Options:

  • Yes, by default — new projects get RFC 9457 out of the box
  • Yes, with a flagmix phx.new --problem-details
  • No — just document the pattern, let people adopt manually

3. Content-type helper

Should Plug include a small helper for application/problem+json, similar to how it has helpers for other content types? Something like:

def put_problem_content_type(conn) do
  put_resp_content_type(conn, "application/problem+json")
end

4. Jason vs JSON module

The JSON.Encoder protocol is OTP 27+ (Elixir 1.18+). For broader compatibility, should it also implement Jason.Encoder? Or is Elixir 1.18+ a reasonable minimum for new Plug features?

Reference implementation

I built a working example in a Phoenix API project to validate the pattern end-to-end:

drink_water on GitHub — includes:

  • ProblemDetail struct with full test suite (47 tests)
  • ErrorCatalog module for project-specific error mapping with Gettext i18n
  • FallbackController that returns application/problem+json for all error paths
  • ErrorJSON that handles both business errors and unhandled exceptions
  • Rate limiter that returns RFC 9457 responses with retry-after header

Next steps

Depending on the feedback here, I’d be happy to:

  • Submit a PR to Plug with the Plug.ProblemDetail struct
  • Submit a PR to Phoenix with documentation and/or generator changes
  • Just share the implementation as a reference for anyone interested

Looking forward to hearing your thoughts — especially from anyone who has dealt with API error standardization in Phoenix. Is this something the community would find useful, or do people prefer keeping error formats project-specific?

I’ve seen this RFC in the past and I really like the ideas.

However I’m not really seeing how what you propose would benefit from being included in phoenix or plug directly. This feels like adding a bunch of code just to format a trivial json object while not yet helping with the actual interesting portion of the rfc – providing more information on the error under the url of type. You’re still manually mapping between the actual error and the ProblemDetail struct.

Imo this would be much better suited for a third party library, hooking into exceptions either like Plug.Exception, which works either as a protocol or with an optional field on exceptions, or just plain as a protocol. This to me feels more useful than the builder struct you’re proposing, which doesn’t make things any more streamlined, it just add a name to the code.

It could additionally include useful tools for hosting error detail pages, which is where being a third party tool allows to include a bunch more code, which would feel like overhead on plug or phoenix, where users might not want to use anything around this RFC.

1 Like

Thanks for the thoughtful feedback!

You’re right — the struct itself is thin and doesn’t justify living in core. And after re-reading the RFC, the type field is just an identifier (non-resolvable URIs like tag: are explicitly allowed), so hosting error pages is a nice-to-have, not an RFC requirement.

On the protocol idea: Plug.Exception already maps exceptions → status codes. A protocol for Problem Details would essentially be Plug.Exception with more fields — not sure that alone justifies a library either.

I think the real value here might be documentation rather than code — a guide or blog post showing the pattern (struct + FallbackController + ErrorJSON) so people can adopt it in ~20 lines without adding a dependency. I’ll explore that direction.