DEV Community

Artur Plysiuk
Artur Plysiuk

Posted on • Edited on

You're probably underusing middleware for HTTP response handling

When you wrap an external API in Elixir, most of what a function like Acme.fetch_user/1 does is response handling: validate the status, parse the body, translate failures. That code almost always lives after the Tesla.get call, in a case or a handle_response/1 at the end of a |> pipeline.

That's the wrong place. A middleware can return {:error, ...} from Tesla.run, which makes Tesla's telemetry event record the request as failed. A function called after Tesla.get can't do that. It only sees the result. Failures it detects don't reach telemetry, so your observability stack undercounts them.

Code samples use Tesla. Same shapes apply to Req. Pick whichever you're using and translate as you go.

Real APIs don't read their own docs

Real APIs are dirtier than tutorial examples. A few I've run into:

  • I've seen well-known, large providers respond with bodies that fail to parse. Yup, broken JSON
  • A field documented as a boolean returns the JSON string "true" for true and, the JSON string "No" for false.
  • A field documented as a string returns "N/A", or just "", to mean NULL.

Your API client translates external bytes into something your application can rely on, and that parsing can fail. Malformed JSON already makes Tesla return {:error, ...}. A malformed value inside a well-formed JSON is the same failure, one layer up.

The pattern that fails

Here is a fetch_user/1 written in a pattern I see a lot. handle_response/1 is a shared helper: every endpoint in the module funnels through it. fetch_user, create_user, delete_invoice all end with |> handle_response(): one place for the wrap, one place for error translation. Here is just one endpoint to keep the example small.

def fetch_user(id) do
  case client() |> Tesla.get("/users/#{id}") |> handle_response() do
    {:ok, body} -> {:ok, decode_user(body)}
    {:error, :not_found} -> {:error, :not_found}
    _ -> {:error, :service_unavailable}
  end
end

defp handle_response({:ok, %Tesla.Env{status: status, body: body}})
     when status in 200..299 do
  {:ok, body}
end

defp handle_response({:ok, %Tesla.Env{status: 404}}) do
  {:error, :not_found}
end

defp handle_response({:ok, %Tesla.Env{status: status, body: body}}) do
  Logger.error("Acme API returned unexpected status #{status}: #{inspect(body)}")
  {:error, {:unexpected_status, status, body}}
end

defp handle_response(result), do: result

defp decode_user(body) do
  {:ok, created_at, _} = DateTime.from_iso8601(body["created_at"])

  %{
    id: body["id"],
    first_name: body["first_name"],
    last_name: body["last_name"],
    email: body["email"],
    created_at: created_at
  }
end
Enter fullscreen mode Exit fullscreen mode

When the API returns a 500, handle_response/1 produces {:error, {:unexpected_status, 500, body}}, the case falls through to _ -> {:error, :service_unavailable}, and the caller gets a clean tagged tuple. The function itself is doing nothing wrong.

Tesla considers the request successful. The middleware chain returned {:ok, env}. The standard [:tesla, :request, :stop] telemetry event records it as :ok. Tesla.Middleware.Retry, whose default should_retry matches {:error, _}, sees {:ok, env} and doesn't fire. The 500 only becomes an "error" inside handle_response/1's return value and the surrounding case. That's application code, after the request is done.

The Logger.error inside handle_response/1 is a patch for this. Telemetry doesn't catch the 5xx, so someone adds a log line by hand. The error reaches the logs but skips the telemetry pipeline, and every API client module grows its own version.

Body parsing has the same hole. decode_user/1 returns the user map directly and can't signal a parse error. If the API renames email to email_address, body["email"] is nil and the caller gets a user with nil in the email field. If body["created_at"] is missing or malformed, DateTime.from_iso8601/1 returns {:error, _} and the {:ok, created_at, _} = ... match raises a MatchError. Phoenix or Oban catches the exception, but Tesla still considers the request successful and the telemetry event still says :ok.

Middleware can fail the request

When a middleware returns {:error, ...} from Tesla.run, the request short-circuits. The Telemetry middleware emits [:tesla, :request, :stop] with error: reason in the metadata, and Tesla.get returns {:error, ...} (or Tesla.get! raises a Tesla.Error). A handle_response/1 chained after the call only sees the result, so any failure it spots stays out of telemetry.

A 404 from fetch_user/1 means the user doesn't exist, return {:error, :not_found}. A 404 from POST /users means the route isn't there, which is a bug to crash on. Same status, different meaning. The middleware can't decide globally what counts as "ok"; the caller has to declare it.

Doing it in middleware

The middleware takes a per-call map of expected statuses and how to parse their bodies. Here's fetch_user/1 using it:

def fetch_user(id) do
  case Tesla.get(client(), "/users/#{id}",
         opts: [
           parse_body: %{
             200 => &decode_user/1,
             404 => fn _ -> {:ok, nil} end
           }
         ]) do
    {:ok, %Tesla.Env{status: 200, body: user}} -> {:ok, user}
    {:ok, %Tesla.Env{status: 404}} -> {:error, :not_found}
    {:error, _} -> {:error, :service_unavailable}
  end
end

defp decode_user(body) do
  with :ok <- check_required(body, ["id", "first_name", "last_name", "email", "created_at"]),
       {:ok, created_at, _} <- DateTime.from_iso8601(body["created_at"]) do
    {:ok,
     %{
       id: body["id"],
       first_name: body["first_name"],
       last_name: body["last_name"],
       email: body["email"],
       created_at: created_at
     }}
  end
end

defp check_required(body, fields) do
  case Enum.filter(fields, &is_nil(Map.get(body, &1))) do
    [] -> :ok
    missing -> {:error, {:missing_fields, missing}}
  end
end
Enter fullscreen mode Exit fullscreen mode

The middleware behind parse_body:

defmodule ParseBody do
  @behaviour Tesla.Middleware

  @impl true
  def call(env, next, _opts) do
    with {:ok, env} <- Tesla.run(env, next) do
      parsers = Keyword.fetch!(env.opts, :parse_body)

      case Map.fetch(parsers, env.status) do
        :error ->
          {:error,
           {:unexpected_status,
            status: env.status, expected: Map.keys(parsers), body: env.body}}

        {:ok, parser} ->
          case parser.(env.body) do
            {:ok, parsed} ->
              {:ok, %{env | body: parsed}}

            {:error, reason} ->
              {:error,
               {:parse_error, status: env.status, reason: reason, body: env.body}}
          end
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Parser failures short-circuit with and become failed Tesla requests, not a separate validation step the caller has to remember. They fire the same [:tesla, :request, :stop] event as any other failure, with the error in metadata.

The handle_response/1 helper is gone. The when status in 200..299 guard is gone. The Logger.error is gone too. Telemetry handles the logging now.

{:error, _} in fetch_user/1 swallows the structured errors, so the function doesn't need to unpack them.

Order matters when wiring it in. ParseBody goes after Tesla.Middleware.Telemetry (so Telemetry records its {:error, _} as the request's result) and before Tesla.Middleware.JSON (so the body is already decoded when ParseBody sees it):

defp client do
  Tesla.client([
    {Tesla.Middleware.BaseUrl, "https://api.acme.com"},
    {Tesla.Middleware.Telemetry, metadata: %{service: :acme}},
    ParseBody,
    Tesla.Middleware.JSON
  ])
end
Enter fullscreen mode Exit fullscreen mode

A note on Tesla.get!

If retrying or falling back on a transient outage isn't part of how the caller actually behaves, :service_unavailable is ceremony. Application code doesn't pattern-match {:error, :service_unavailable} on every Repo.get/2. We assume the database works; if it doesn't, the request crashes and the supervisor or web framework deals with it.

You can apply the same discipline to an external API. Use Tesla.get! instead of Tesla.get, drop :service_unavailable from the contract, and let any failed request become a Tesla.Error exception. Phoenix turns it into a 500. Oban retries the job. The two clauses left in fetch_user/1 collapse to:

case Tesla.get!(...) do
  %Tesla.Env{status: 200, body: user} -> {:ok, user}
  %Tesla.Env{status: 404} -> {:error, :not_found}
end
Enter fullscreen mode Exit fullscreen mode

Whether to do this depends on the API's stability and the caller's context.

When it wants to be data

Most parsers are boilerplate: required fields, ISO timestamps, nullability checks. A schema handles that better than a function. tesla_middleware_mold and Mold give you that. fetch_user/1 with a schema:

def fetch_user(id) do
  case Tesla.get(client(), "/users/#{id}", opts: [mold: %{200 => user_schema(), 404 => nil}]) do
    {:ok, %Tesla.Env{status: 200, body: user}} -> {:ok, user}
    {:ok, %Tesla.Env{status: 404}} -> {:error, :not_found}
    {:error, _} -> {:error, :service_unavailable}
  end
end

defp user_schema do
  %{
    id: :string,
    first_name: :string,
    last_name: :string,
    email: :string,
    created_at: :datetime
  }
end
Enter fullscreen mode Exit fullscreen mode

200 => schema runs Mold.parse/2. 404 => nil accepts the response without parsing. Anything else fails the request.

The function shape Mold accepts as a parser is the same one the middleware accepts (body -> {:ok, term} | {:error, reason}), so you can mix schemas with hand-written parsers in the same map, depending on which is cleaner for which response.

Nothing in this article requires tesla_middleware_mold. The hand-rolled ParseBody above is around 20 lines once you collapse the formatting; dropping it into your own codebase gives you the same telemetry behavior. Reach for the library when you also want to stop hand-writing parsers.

Top comments (5)

Collapse
 
yordisprieto profile image
Yordis Prieto • Edited

Really cool, I didn't know about mold. Recently, I pushed a lot of OpenAPI-first support to Tesla hexdocs.pm/tesla/openapi-parameter...

By now, after far too many scars, I tend to have some strong opinions for 95% of cases. I am actively working on an OpenAPI code generator, which is a bit different from my previous attempt, trying to mitigate all the anti-patterns from before. And making some people happy, since it is optimized to codegen for YOUR given application, so you can select fewer operations from it

Would mold allow me to map all the OpenAPI semantics into it? Here is what it is generating thus far

defmodule PetstoreApi.Response do
  use Tesla.OpenAPI.Response
end

defmodule PetstoreApi.Client do
  @moduledoc """
  HTTP client for the Petstore API.
  """

  @type t :: %__MODULE__{
          client: Tesla.Client.t()
        }

  @type opts() :: [
          base_url: String.t(),
          auth: PetstoreApi.Auth.t() | nil
        ]

  @enforce_keys [:client]

  defstruct [:client]

  @doc """
  Creates a new SDK client with the generated Tesla middleware stack.

  The Tesla adapter is resolved from your application's config:

      config :tesla, adapter: Tesla.Adapter.Hackney

  ## Examples

      iex> client = PetstoreApi.Client.new(base_url: "https://api.example.com")
      iex> match?(%Tesla.Client{}, client.client)
      true
  """
  @spec new(opts()) :: t()
  def new(opts) do
    middleware = build_middleware(opts)

    %__MODULE__{client: Tesla.client(middleware)}
  end

  @spec update_client(t(), (Tesla.Client.t() -> Tesla.Client.t())) :: t()
  def update_client(%__MODULE__{} = client, fun) when is_function(fun, 1) do
    %Tesla.Client{} = tesla_client = fun.(client.client)

    %{client | client: tesla_client}
  end

  @doc """
  Returns the list of available API server base URLs.
  """
  @spec servers() :: [String.t()]
  def servers, do: ["https://api.petstore.example.com/v2", "https://staging-api.petstore.example.com/v2"]

  defp build_middleware(opts) do
    [
      {Tesla.Middleware.BaseUrl, Keyword.fetch!(opts, :base_url)},
      {Tesla.Middleware.PathParams, mode: :modern},
      {Tesla.Middleware.Query, mode: :modern},
      Tesla.Middleware.JSON,
      {PetstoreApi.Auth, Keyword.get(opts, :auth)}
    ]
  end
end


defmodule PetstoreApi.Schemas.Error do
  @enforce_keys [:code, :message]
  defstruct [
    code: nil,
    details: nil,
    message: nil
  ]

  @type t :: %__MODULE__{
          code: integer(),
          details: [PetstoreApi.Schemas.ErrorItem.t()] | nil,
          message: String.t()
        }

  @type attrs() :: %{String.t() => any()}

  @spec new(attrs()) :: t()
  def new(attrs) when is_map(attrs) do
    %__MODULE__{
      code: Map.fetch!(attrs, "code"),
      details: to_details(Map.get(attrs, "details")),
      message: Map.fetch!(attrs, "message")
    }
  end

  defp to_details(nil) do
    nil
  end

  defp to_details(tags) when is_list(tags) do
    Enum.map(tags, &PetstoreApi.Schemas.ErrorItem.new/1)
  end

  defimpl Jason.Encoder do
    def encode(value, opts) do
      Jason.Encode.map(
        %{
          "code" => value.code,
          "details" => value.details,
          "message" => value.message
        },
        opts
      )
    end
  end
end

defmodule PetstoreApi.Schemas.Pet do
  @enforce_keys [:id, :name, :photo_urls]
  defstruct [
    adopted_date: nil,
    category: nil,
    id: nil,
    name: nil,
    photo_urls: nil,
    status: :available,
    tags: nil
  ]

  @type t :: %__MODULE__{
          adopted_date: String.t() | any() | nil,
          category: PetstoreApi.Schemas.Category.t() | nil,
          id: integer(),
          name: String.t(),
          photo_urls: [String.t()],
          status: :available | :pending | :sold | nil,
          tags: [PetstoreApi.Schemas.Tag.t()] | nil
        }

  @type attrs() :: %{String.t() => any()}

  @spec new(attrs()) :: t()
  def new(attrs) when is_map(attrs) do
    %__MODULE__{
      adopted_date: Map.get(attrs, "adoptedDate"),
      category: to_category(Map.get(attrs, "category")),
      id: Map.fetch!(attrs, "id"),
      name: Map.fetch!(attrs, "name"),
      photo_urls: Map.fetch!(attrs, "photoUrls"),
      status: to_status(attrs),
      tags: to_tags(Map.get(attrs, "tags"))
    }
  end

  defp to_category(nil) do
    nil
  end

  defp to_category(value) when is_map(value) do
    PetstoreApi.Schemas.Category.new(value)
  end

  defp to_status(%{"status" => nil}) do
    nil
  end

  defp to_status(%{"status" => "available"}) do
    :available
  end

  defp to_status(%{"status" => "pending"}) do
    :pending
  end

  defp to_status(%{"status" => "sold"}) do
    :sold
  end

  defp to_status(data) when not is_map_key(data, "status") do
    :available
  end

  defp to_status(data) when is_map(data) do
    nil
  end

  defp to_tags(nil) do
    nil
  end

  defp to_tags(tags) when is_list(tags) do
    Enum.map(tags, &PetstoreApi.Schemas.Tag.new/1)
  end

  defimpl Jason.Encoder do
    def encode(value, opts) do
      Jason.Encode.map(
        %{
          "adoptedDate" => value.adopted_date,
          "category" => value.category,
          "id" => value.id,
          "name" => value.name,
          "photoUrls" => value.photo_urls,
          "status" => value.status,
          "tags" => value.tags
        },
        opts
      )
    end
  end
end

defmodule PetstoreApi.Operation.UpdatePet.Path do
  alias Tesla.OpenAPI.{PathParam, PathParams, PathTemplate}

  @enforce_keys [:pet_id]
  defstruct [
    pet_id: nil
  ]

  @type attrs() :: %{
          required(:pet_id) => integer()
        }

  @type t :: %__MODULE__{
          pet_id: integer()
        }

  @path_template PathTemplate.new!("/pets/{petId}")

  @path_params PathParams.new!([
    PathParam.new!("petId", style: :simple, explode: false, allow_reserved: false)
  ])

  def path_template, do: @path_template
  def path_params, do: @path_params

  @spec new(attrs()) :: t()
  def new(attrs) when is_map(attrs) do
    %__MODULE__{
      pet_id: Map.fetch!(attrs, :pet_id)
    }
  end

  @spec to_path_params(t()) :: %{String.t() => term()}
  def to_path_params(%__MODULE__{} = path) do
    %{
      "petId" => path.pet_id
    }
  end
end


defmodule PetstoreApi.Operation.UpdatePet do
  @moduledoc """
  Update a pet

  This operation requires authentication: `api_key`.
  """

  alias PetstoreApi.Client
  alias PetstoreApi.Response
  alias PetstoreApi.Operation.UpdatePet.Path

  alias Tesla.OpenAPI
  alias Tesla.OpenAPI.{PathParams, PathTemplate}

  @operation_path Path.path_template().path

  @private OpenAPI.merge_private([
             PathTemplate.put_private(Path.path_template()),
             PathParams.put_private(Path.path_params())
           ])

  @enforce_keys [:path, :body]
  defstruct [
    path: nil,
    body: nil
  ]

  @type t :: %__MODULE__{
          path: Path.t(),
          body: PetstoreApi.Schemas.NewPet.t()
        }

  @type resp_body_200() :: PetstoreApi.Schemas.Pet.t()
  @type resp_header_200() :: Response.headers()
  @type resp_200() :: PetstoreApi.Response.t(resp_body_200(), resp_header_200())

  @type resp_body_404() :: PetstoreApi.Schemas.Error.t()
  @type resp_header_404() :: Response.headers()
  @type resp_404() :: PetstoreApi.Response.t(resp_body_404(), resp_header_404())

  @type result() :: {:ok, resp_200() | resp_404()} | {:error, term()}

  def new(attrs) when is_map(attrs) do
    %__MODULE__{
      path: Map.fetch!(attrs, :path),
      body: Map.fetch!(attrs, :body)
    }
  end

  @doc false
  def handle_operation(%Client{} = client, %__MODULE__{} = operation, opts) do
    opts = Keyword.put(opts, :path_params, Path.to_path_params(operation.path))
    request_opts = [
      method: :put,
      url: @operation_path,
      body: operation.body,
      private: @private,
      opts: opts
    ]

    case Tesla.request(client.client, request_opts) do
      {:ok, %Tesla.Env{status: 200} = env} ->
        {:ok, Response.new(env, PetstoreApi.Schemas.Pet.new(env.body))}

      {:ok, %Tesla.Env{status: 404} = env} ->
        {:ok, Response.new(env, PetstoreApi.Schemas.Error.new(env.body))}

      {:ok, env} ->
        {:ok, Response.new(env, env.body)}

      {:error, reason} ->
        {:error, reason}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode
Collapse
 
yordisprieto profile image
Yordis Prieto

Also, ideally mold would allow me to have structs, to avoid having maps everywhere, structs have name and enforce structure

Collapse
 
arturplysiuk profile image
Artur Plysiuk

Would mold allow me to map all the OpenAPI semantics into it?

I think you know more about OpenAPI semantics than I do :) But I'd say yes, since you can always pass a custom parser function as a schema.

I think I understand how you got your scars :D I just recently worked on an API integration with 4 different datetime formats (across just 4 endpoints).
Very often I'd like to shape data differently in my application. If anything maps 1-1, I consider it a happy coincidence :)

I think of structs as something that should be a bit smarter than regular maps - specifically, they should ship with a smart constructor, not just the raw %Foo{...} literal. mold likes ok/error tuple format, so struct module can just implement parse/1 function that returns data in this format. A list of pets could be parsed with:

Mold.parse([&Pet.parse/1], data)
Enter fullscreen mode Exit fullscreen mode

mold doesn't have a special syntax for structs. It is possible to transform a map into a struct with the transform key:

defmodule Pet do
  # ...
  def parse(data) do
    Mold.parse({%{id: :string, ...}, transform: &struct(Pet, &1)}, data)
  end
end
Enter fullscreen mode Exit fullscreen mode

I'm not sure whether it is worth having special syntax, because it would be convenient if it looks like

Mold.parse(%Pet{id: :string, ...}, data)
Enter fullscreen mode Exit fullscreen mode

but technically this API would create a struct that doesn't satisfy its own typespecs, and it feels awkward to me.

Thread Thread
 
yordisprieto profile image
Yordis Prieto

I am OK without special syntax, the parse stuff is good enough, I rather keep things composable than coupled, especially that this is codegen stuff, so nobody is looking at it every day.

The alternative are things like macro, which I am also avoiding to some extent, unless they truely make a perf differences. I may reach out on Slack, I would love to get some feedback on it (and strong opinions, only).

Thread Thread
 
arturplysiuk profile image
Artur Plysiuk

Sure, it would be interesting to see your experiments