lib/openagents_web/api_error.ex

58e6347eeb72 · 12 KB

defmodule OpenAgentsWeb.ApiError do
  @moduledoc """
  One error envelope for the issue-family `/api/v1` routes.

  Before this module a caller met six incompatible bodies for the same class of
  failure: `message` for a missing resource, `errors` for a rejected field,
  `error` as a bare string for one refusal and as an object for the next, plus
  the flat `code`/`message`/`request_id` triple the repository routes already
  published. A client could not read one failure and know how to read the next.

  Every refusal from a route classified `:envelope` in
  `OpenAgentsWeb.ApiRouteAuthority` now carries the same six keys:

    * `message` — the human sentence. GitHub clients already read this key, and
      its value for a missing resource is still exactly `"Not Found"`.
    * `code` — one of `codes/0`, stable across releases, for a client that has
      to branch. This is what gives a CLI distinct exit results.
    * `status` — the HTTP status, repeated in the body so a logged envelope is
      self-describing.
    * `documentation_url` — the published contract at `GET /api/v1`, which
      enumerates every code and the routes that use this envelope.
    * `request_id` — the endpoint's `x-request-id`, so a report names one
      request.
    * `errors` — field name to messages. Always present, `{}` when the failure
      is not field-level, so a client parses one shape.

  Keys are only ever added. Two refusals additionally carry the `error` key a
  measured client already reads; pass it through `:legacy` and it is merged
  beside the envelope rather than replacing it.

  Status codes are chosen by `code`, never by the caller, so two controllers
  cannot disagree about what a missing prerequisite is worth. Non-disclosure is
  preserved by construction: a private resource and an absent one both refuse
  with `not_found`, and no code in this table distinguishes them.
  """

  import Plug.Conn, only: [put_status: 2, get_resp_header: 2]

  alias Ecto.Changeset

  # One status per code. A controller names the code; it never names a status.
  @codes %{
    "unauthenticated" => {401, "Requires authentication"},
    "forbidden" => {403, "Forbidden"},
    "agent_participation_forbidden" => {403, "This agent may not participate in this repository"},
    # Capturing an issue from chat (#77). A plain `forbidden` would leave the
    # caller unable to tell "no such repository" from "no writing role in a
    # repository you can already see", and only the second is something they
    # can fix by asking an owner. It is only ever returned for a repository the
    # caller can already read, so it discloses nothing a 404 was protecting.
    "repository_write_access_required" =>
      {403, "Writing to this repository needs an owner, maintainer, or contributor role"},
    "not_found" => {404, "Not Found"},
    "label_not_on_issue" => {404, "Label does not exist on this issue"},
    "dependency_not_found" => {404, "Not a prerequisite of this issue"},
    "validation_failed" => {422, "Validation Failed"},
    "delete_failed" => {422, "The resource could not be removed"},
    # Fleet promotion (FLEETPROMOTE-001). A caller that scripts a release has to
    # tell "you may not do this" from "someone promoted first" from "those bytes
    # are not in the forge", so each is its own code with its own status.
    "not_operator" => {403, "The credential's account is not a current operator"},
    "idempotency_conflict" => {409, "That idempotency key already names different bytes"},
    "precondition_failed" => {409, "The fleet target changed before this request"},
    "unknown_commit" => {422, "Only a commit pushed to the forge is promotable"},
    # Thread admission (THREAD-001). Holding too many threads open is not a
    # malformed request and not a forbidden one: the same call succeeds once
    # the caller revokes one, so it is the rate-limit status and its own code.
    "thread_quota_reached" => {429, "This account holds the maximum number of open threads"},
    # Memory admission. Like the thread ceiling, this is neither a malformed
    # request nor a forbidden one: the same call succeeds once the account
    # removes or supersedes a memory, so it is the rate-limit status and its
    # own code rather than a field message inside a generic 422.
    "memory_quota_reached" => {429, "This account holds the maximum number of memories"},
    # Model availability (PROVIDER-002). The model is in the catalog but its
    # provider credential is not configured on this deployment, which is the
    # server's condition and not the caller's mistake: the same call succeeds
    # once the operator configures the lane, so it is the unavailable status
    # and its own code — never a silent substitution.
    "model_unavailable" => {503, "The model's provider is not configured on this deployment"},
    "thread_terminal" => {422, "This thread is terminal and its transcript is closed"},
    # The local lane (THREAD-001, issue #243). A local-lane thread is
    # transcript-only by contract: its model runs on the caller's own machine,
    # so there is no authority to mint and no amount of waiting changes that.
    # Its own code, like `thread_terminal`, because a client that asked for a
    # grant must learn the thread can never hold one, not that it should retry.
    "thread_lane_local" => {422, "This thread is on the local lane and holds no model authority"},
    # Thread disclosure (THREAD-002). Widening a transcript is a consent
    # decision, so a tier that did not take is its own code rather than a field
    # message inside the generic 422: a client that meant to publish and did
    # not should learn that from the code it branches on. It covers both an
    # unknown word and a tier of the shared vocabulary this surface has no read
    # path for, because to a caller the effect is identical — the thread stayed
    # as private as it was.
    "thread_visibility_unsupported" =>
      {422, "That transparency tier is not one a thread can be opened at"},
    # A transcript writer meets two refusals that cannot change: a closed
    # thread, and an event the server has called invalid. `thread_terminal`
    # already carries a code; these give the other refusals of the append route
    # the same property, so a client drops or splits without parsing prose.
    "event_invalid" => {422, "The event could not be recorded"},
    "event_batch_too_large" => {422, "The batch carries more events than the maximum"},
    # Spending the account's inference credit is not a rate limit: no amount of
    # waiting or revoking makes the same call succeed, so it is the payment
    # status and its own code.
    "credit_exhausted" => {402, "This account has spent its inference credit"},
    # Push receipts are read from the WAL, not from PostgreSQL, so a storage
    # that will not answer is a temporary unreadability rather than an absence.
    # Reporting it as `not_found` would tell a pusher their push is not on
    # record, which is a different and much worse claim.
    "push_record_unreadable" => {503, "The push record is temporarily unreadable"},
    # Gym run lifecycle. A harness that scripts against the lifecycle meets
    # two conflicts it must tell apart: the run it is closing was already
    # graded, and the digest it is pinning already names a different run.
    # Each carries the standing run beside the envelope so the caller reads
    # what it lost to rather than fetching again.
    "run_already_graded" => {409, "This run is already graded"},
    "recipe_digest_conflict" => {409, "That recipe digest already names another run"},
    "trace_body_too_large" => {413, "The trace body is larger than the maximum allowed size"},
    # Binding a trace to an attempt is an authority claim, so a caller that
    # names somebody else's attempt is refused rather than having the binding
    # dropped: it should not be told the trajectory was filed as evidence when
    # it was not.
    "trace_assignment_forbidden" =>
      {403, "Only the account that requested an attempt may bind a trace to it"}
  }

  @doc """
  Every stable error code and the one status it maps to.

  `OpenAgentsWeb.ApiExtensionController` publishes this table at `GET /api/v1`,
  so a client reads the codes it must handle rather than collecting them from
  runtime examples.
  """
  @spec codes() :: %{String.t() => pos_integer()}
  def codes, do: Map.new(@codes, fn {code, {status, _message}} -> {code, status} end)

  @doc "The keys every envelope carries, in the order the contract lists them."
  @spec envelope_keys() :: [String.t()]
  def envelope_keys, do: ~w(message code status documentation_url request_id errors)

  @doc """
  Refuses the request with one stable code.

  Options:

    * `:message` — replaces the code's default sentence. The code and the
      status do not change with it.
    * `:errors` — a field-to-messages map.
    * `:legacy` — a map merged beside the envelope for a client that already
      reads a key this envelope does not define.
  """
  @spec refuse(Plug.Conn.t(), String.t(), keyword()) :: Plug.Conn.t()
  def refuse(conn, code, opts \\ []) do
    {status, default_message} = fetch_code!(code)

    body =
      %{
        "message" => Keyword.get(opts, :message, default_message),
        "code" => code,
        "status" => status,
        "documentation_url" => documentation_url(),
        "request_id" => request_id(conn),
        "errors" => field_errors(Keyword.get(opts, :errors, %{}))
      }
      |> Map.merge(Keyword.get(opts, :legacy, %{}))

    conn
    |> put_status(status)
    |> Phoenix.Controller.json(body)
  end

  @doc "Refuses with `not_found`, the only refusal a private resource may make."
  @spec not_found(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
  def not_found(conn, opts \\ []), do: refuse(conn, "not_found", opts)

  @doc "Refuses with `forbidden`, for an authenticated caller without authority."
  @spec forbidden(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
  def forbidden(conn, opts \\ []), do: refuse(conn, "forbidden", opts)

  @doc "Refuses with `validation_failed` and the given field-to-messages map."
  @spec validation_failed(Plug.Conn.t(), map(), keyword()) :: Plug.Conn.t()
  def validation_failed(conn, errors, opts \\ []) do
    refuse(conn, "validation_failed", Keyword.put(opts, :errors, errors))
  end

  @doc """
  Refuses with `validation_failed`, translating a changeset's errors.

  Placeholders are interpolated, so a length violation reads
  `"should be at most 3 character(s)"` rather than leaking `%{count}`.
  """
  @spec changeset(Plug.Conn.t(), Changeset.t(), keyword()) :: Plug.Conn.t()
  def changeset(conn, %Changeset{} = changeset, opts \\ []) do
    validation_failed(conn, changeset_errors(changeset), opts)
  end

  @doc """
  A changeset's errors as the envelope's field-to-messages map.

  For a route that refuses a changeset under its own code rather than the
  generic `validation_failed`: translate here, then pass the map to `refuse/3`
  as `:errors`.
  """
  @spec changeset_errors(Changeset.t()) :: map()
  def changeset_errors(%Changeset{} = changeset) do
    Changeset.traverse_errors(changeset, &translate/1)
  end

  @doc """
  The envelope as a plain map, for a plug that refuses before a controller runs.

  A plug halts its own connection, so it needs the body without the send.
  """
  @spec envelope(Plug.Conn.t(), String.t(), keyword()) :: map()
  def envelope(conn, code, opts \\ []) do
    {status, default_message} = fetch_code!(code)

    %{
      "message" => Keyword.get(opts, :message, default_message),
      "code" => code,
      "status" => status,
      "documentation_url" => documentation_url(),
      "request_id" => request_id(conn),
      "errors" => field_errors(Keyword.get(opts, :errors, %{}))
    }
    |> Map.merge(Keyword.get(opts, :legacy, %{}))
  end

  defp fetch_code!(code) do
    case Map.fetch(@codes, code) do
      {:ok, entry} ->
        entry

      :error ->
        raise ArgumentError,
              "#{inspect(code)} is not a stable API error code. " <>
                "Add it to OpenAgentsWeb.ApiError with the status it always carries. " <>
                "Known codes: #{@codes |> Map.keys() |> Enum.sort() |> Enum.join(", ")}"
    end
  end

  # Field names arrive as atoms from changesets and as strings from hand-built
  # maps. JSON has one kind of key, so the envelope does too.
  defp field_errors(errors) when is_map(errors) do
    Map.new(errors, fn {field, messages} -> {to_string(field), messages} end)
  end

  defp request_id(conn) do
    conn |> get_resp_header("x-request-id") |> List.first()
  end

  defp documentation_url do
    OpenAgentsWeb.Endpoint.url() |> String.trim_trailing("/") |> Kernel.<>("/api/v1")
  end

  defp translate({message, options}) do
    Regex.replace(~r/%{(\w+)}/, message, fn _whole, key ->
      options |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
    end)
  end
end