Erro genérico

Quando nos conectamos a um serviço externo, abrimos uma brecha em nossas defesas. Podemos cair em cenários que não esperamos, como algum erro não mapeado ou até mesmo, o serviço parar de responder. Quando isso acontece, precisamos avisar nosso usuário elegantemente que algo deu errado.

Vamos a um exemplo prático. Nosso tratamento esta assim

lib/coffee_shop/integrations/coffee/response.ex
defmodule CoffeeShop.Integrations.Coffee.Response do
  defstruct status: :integer, body: %{}

  def build({:ok, %Tesla.Env{status: status, body: body}}) do
    response = %__MODULE__{
      status: status,
      body: JSON.decode!(body)
    }

    {:ok, response}
  end
end

Agora, vamos pensar. Caso algo de errado, ele vai entrar em nosso build/1, e fazer o JSON.decode!/1. Mas, e se o body, quando der erro, vir em um formato diferente? O que vai acontecer. Vamos atualizar nosso teste:

test/coffee_shop/integrations/coffee/response_test.exs
defmodule CoffeeShop.Integrations.Coffee.ResponseTest do
  use ExUnit.Case

  alias CoffeeShop.Integrations.Coffee.Response

  describe "build/1" do
    # ...

    test "build an 500 error" do
      tesla_response = {:ok, %Tesla.Env{status: 500, body: "Server exploded"}}
      
      assert {:ok, %Response{body: _body}} = Response.build(tesla_response)
    end
  end
end

Criamos um teste simples com uma resposta em formato fora do padrão. Vamos rodar esse teste.

mix test 
> mix test 
1) test build/1 build an 500 error (CoffeeShop.Integrations.Coffee.ResponseTest)
     test/coffee_shop/integrations/coffee/response_test.exs:19
     ** (JSON.Decoder.UnexpectedTokenError) Invalid JSON - unexpected token >>Server exploded<<
     code: assert {:ok, %Response{body: _body}} = Response.build(tesla_response)
     stacktrace:
       (json 1.4.1) lib/json.ex:83: JSON.decode!/1
       (coffee_shop 0.1.0) lib/coffee_shop/integrations/coffee/response.ex:7: CoffeeShop.Integrations.Cof
fee.Response.build/1
       test/coffee_shop/integrations/coffee/response_test.exs:24: (test)


Finished in 0.05 seconds (0.00s async, 0.05s sync)
2 tests, 1 failure, 1 excluded

Recebemos um erro de unexpected token. Isso ocorre porque o JSON.decode!/1 nao conseguir fazer o parse de "Server exploded". Vamos tentar fazer na mao

iex -S mix
JSON.decode!("Server exploded")
** (JSON.Decoder.UnexpectedTokenError) Invalid JSON - unexpected token >>Server exploded<<

Precisamos de uma estrutura em JSON, mas nosso serviço não trás esse formato para nós. Temos várias formas de lidar com isso. A mais simples é responder um erro genérico quando receber um status 500. Ou melhor, quando não receber o status esperado. Isso quer dizer, tudo o que não for esperado, vai cair no erro genérico, apenas para não gerar um erro incompreenssivel ou feio para o usuário.

Aqui é um bom local para mapearmos erros não conhecidos e ai sim, implementar uma solução para eles.

Entendido isso, vamos melhorar nosso teste. Nele precisamos saber que

  • Um erro ocorreu;

  • Mensagem para avisarmos o usuário.

test/coffee_shop/integrations/coffee/response_test.exs
defmodule CoffeeShop.Integrations.Coffee.ResponseTest do
  use ExUnit.Case

  alias CoffeeShop.Integrations.Coffee.Response

  describe "build/1" do
    # ...

    test "build an 500 error" do
      tesla_response = {:ok, %Tesla.Env{status: 500, body: "Server exploded"}}
      
      assert {:error, %Response{body: _body, error: error}} = Response.build(tesla_response)
      assert error == "Não foi possível se conectar ao serviço de cafés. Tento novamente mais tarde"
    end
  end
end

No teste alteramos a assertion da resposta, onde esperamos agora um {:error, %Response{}} para refletir que um erro aconteceu. Tambem adicionamos o error sendo a mensagem que ele retorna.

Vamos em nosso response.ex atualizar a construção de nossa resposta

lib/coffee_shop/integrations/coffee/response.ex
defmodule CoffeeShop.Integrations.Coffee.Response do
  defstruct status: :integer, body: :map

  @success_status [200]

  def build({:ok, %Tesla.Env{status: status, body: body}})
      when status in @success_status do
    response = %__MODULE__{
      status: status,
      body: JSON.decode!(body)
    }

    {:ok, response}
  end
end

Adicionamos o status que podem realizar o build/1. Isso quer dizer, caso um status nao esteja no @success_status, ele não entrará na função.

Feito isso, precisamos criar uma função de mesmo nome para capturar todos os cenários restantes.

lib/coffee_shop/integrations/coffee/response.ex
defmodule CoffeeShop.Integrations.Coffee.Response do
  defstruct status: :integer, body: :map, error: :string

  @success_status [200]

  def build({:ok, %Tesla.Env{status: status, body: body}})
      when status in @success_status do
    response = %__MODULE__{
      status: status,
      body: JSON.decode!(body)
    }

    {:ok, response}
  end

  def build({:ok, %Tesla.Env{status: status, body: body}}) do
    message = "Não foi possível se conectar ao serviço de cafés. Tento novamente mais tarde"

    response = %__MODULE__{
      status: status,
      error: message
    }

    {:error, response}
  end
end

Adicionamos um guard clause na primeira função, liberando apenas para status 200. Todo o restante cairá na segunda função e irá gerar um erro genérico da integração. Tabém adicionamos uma mensagem padrão e retornamos ao invés de :ok, um :error para refletir que algo deu errado.

mix test test/coffee_shop/integrations/coffee/response_test.exs
mix test test/coffee_shop/integrations/coffee/response_test.exs
..
Finished in 0.6 seconds (0.00s async, 0.6s sync)
1 doctest, 4 tests, 0 failures

Criamos um mecanismo simples de controle de status, onde podemos gerar N cenários para a resposta que vem do nosso serviço e tratar da melhor forma que quisermos.

Podemos criar outras funções com outras guard clauses para isolar melhor os tipos de resposta.

Ao rodar todos os testes, teremos uma quebra no client_test.exs, isso devido a resposta mudar para uma tupla com :error. Basta altera-la e tudo volta a funciona.

test/coffee_shop/integrations/coffee/client_test.exs
defmodule CoffeeShop.Integrations.Coffee.ClientTest do
  use ExUnit.Case

  alias CoffeeShop.Integrations.Coffee.Response
  alias CoffeeShop.Integrations.Coffee.Client

  describe "all_hot_coffees/0" do
    # ...
    test "service is crashed", %{bypass: bypass} do
      response = %{message: "Server exploded"} |> JSON.encode!()

      Bypass.expect(bypass, "GET", "/coffee/hot", fn conn ->
        Plug.Conn.resp(conn, 500, response)
      end)

      opts = [
        base_url: "http://localhost:3000"
      ]

      assert {:error, %Response{status: status, body: _body}} = Client.all_hot_coffees(opts)
      assert status == 500
    end
  end
end

Feito. Agora temos outro problema para lidar.

Atualizado