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.

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

Isto foi útil?