Reexecutando uma requisição

Alguns limitadores podem levar apenas alguns segundos para liberar, como por exemplo.

  • Máximo de 3 requisições por segundo

Isso quer dizer que ao passar o segundo, teremos mais 10 requisições para fazer. Em termos de tempo, faz sentido aguardar o bloqueio. Adicionar esse tempo a requisição não impacta de forma tão negativa quanto receber uma mensagem de erro.

Vamos primeiro criar nosso cenário de erro.

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

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

  describe "all_hot_coffees/0" do
    # ...

    test "recovery of too much requests", %{bypass: bypass} do
      Bypass.expect_once(bypass, "GET", "/coffee/hot", fn conn ->
        Plug.Conn.resp(conn, 429, "")
      end)

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

      assert {:ok, %Response{status: status}} = Client.all_hot_coffees(opts)

      assert status == 200
    end
  end
end

A ideia desse teste é conseguir se recuperar de um 429 utilizando a reexecução da requisição, esperando 1 segundos para acabar a penalidade. Obviamente, se rodar esse teste, receberemos um status 429.

Para resolver esse problema, iremos utilizar um middleware do tesla, chamado Tesla.Middleware.Retry onde passaremos os seguintes argumentos:

  • delay -> Tempo de espera até próxima tentativa

  • max_retries -> Máximo de tentativas até retornar um erro

  • max_delay -> Máximo de tempo de espera

  • should_retry -> regra condicional para definir se uma requisição deve ou não reexecutar

Vamos lá. Não estamos mais usando a macro do Tesla, isso quer dizer, que não conseguiremos utilizar tiretamente o plug. Isso é bom, pode parecer mais trabalhoso, mas é bom. Para isso, precisamos definir os middlewares diretamente no Tesla.Client passando os middlewares, que queremos. Até agora utilizamos o Tesla.get/1, passando por parâmetro o caminho completo de nosso endpoint. Porém, existe a função Tesla.get/2, onde o primeiro argumento é o Client e o segundo o endpoint. precisamos do client para configurar o middleware, usaremos ele.

lib/integrations/coffee/client.ex
defmodule CoffeeShop.Integrations.Coffee.Client do
  alias CoffeeShop.Integrations.Coffee.Response

  def all_hot_coffees(opts \\ []) do
    base_url = Keyword.get(opts, :base_url, "https://api.sampleapis.com")

    middlewares = [
      {Tesla.Middleware.Retry,
       delay: 1000,
       max_retries: 3,
       max_delay: 2_000,
       should_retry: fn
         {:ok, %{status: status}} when status in [429] -> true
         {:ok, _} -> false
         {:error, _} -> false
       end}
    ]

    middlewares
    |> Tesla.client()
    |> Tesla.get("#{base_url}/coffee/hot")
    |> Response.build()
  end
end

Conseguimos adicionar o middleware, mesmo tendo ficado meio cheio a função. Resolveremos isso depois. Agora você pode rodar seu teste.

Algo que devemos notar é a configuração should_retry. É onde criamos a regra para rodar o retry. Ali temos a regra

  • {:ok, _} -> false # Se nossa resposta for um :ok ele não deve rodar o retry

  • {:error, _} -> false # Se for um :error não deve rodar (esse erro vem do Tesla, conexão estabelecida por alguma razão, seria um bom ponto para retry, mas não estamos vendo isso agora

  • {:ok, %{status: status}} when status in [429] -> true # Esse estava no topo, mas coloquei por ultimo aqui para explicar melhor

Pegamos por pattern matching o status vindo do Tesla. Verificamos se esse status está dentro da lista ao fazer status in [429] caso esteja, queremos que faça o retry. Esta dentro de uma lista devido a poder colocar mais status ali. Um erro 500 seria legal, assim, alguns erros podem ser sanados sem nem transparecer para o usuário. Porem, estou me atentando apenas ao nosso inimigo 429.

Vamos rodar o teste.

mix test test/coffee_shop/integrations/coffee/client_test.exs
> mix test
15:35:22.169 [error] #PID<0.285.0> running Bypass.Plug (connection #PID<0.283.0>, stream id 2) terminated
Server: localhost:3000 (http)
Request: GET /coffee/hot
** (exit) an exception was raised:
    ** (RuntimeError) route error
        (bypass 2.1.0) lib/bypass/plug.ex:28: Bypass.Plug.call/2
        (plug_cowboy 2.7.1) lib/plug/cowboy/handler.ex:11: Plug.Cowboy.Handler.init/2
        (cowboy 2.12.0) /home/iago-effting/code/study/book/coffee_shop/deps/cowboy/src/cowboy_handler.erl
:37: :cowboy_handler.execute/2
        (cowboy 2.12.0) /home/iago-effting/code/study/book/coffee_shop/deps/cowboy/src/cowboy_stream_h.er
l:306: :cowboy_stream_h.execute/3
        (cowboy 2.12.0) /home/iago-effting/code/study/book/coffee_shop/deps/cowboy/src/cowboy_stream_h.er
l:295: :cowboy_stream_h.request_process/3
        (stdlib 5.2) proc_lib.erl:241: :proc_lib.init_p_do_apply/3


  1) test all_hot_coffees/0 recovery of too much requests (CoffeeShop.Integrations.Coffee.ClientTest)
     test/coffee_shop/integrations/coffee/client_test.exs:31
     match (=) failed
     code:  assert {:ok, %Response{status: status}} = Client.all_hot_coffees(opts)
     left:  {:ok, %CoffeeShop.Integrations.Coffee.Response{status: status}}
     right: {
              :error,
              %CoffeeShop.Integrations.Coffee.Response{
                status: 500,
                body: %{},
                error: "Não foi possível se conectar ao serviço de cafés. Tento novamente mais tarde"
              }
            }
     stacktrace:
       test/coffee_shop/integrations/coffee/client_test.exs:40: (test)


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



Recebemos um status 500. Isso foi uma falha em nosso serviço falso. O culpado é a função do Bypass.expect_once/4 isso acontece por que o Bypass esperava apenas uma chamada dessa requisição (expect_once), mas recebemos mais do que uma, comprovando que nosso retry funcionou.

Bypass possui a função Bypass.expect/4 que funciona igual ao expect_once/4, mas sem o limitador de ser apenas uma vez. Vamos substituir ela por apenas expect/4 e o resto continua igual

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

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

  describe "all_hot_coffees/0" do
    # ...
    
    test "too much requests", %{bypass: bypass} do
      Bypass.expect(bypass, "GET", "/coffee/hot", fn conn ->
        Plug.Conn.resp(conn, 429, "")
      end)

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

      assert {:ok, %Response{status: status}} = Client.all_hot_coffees(opts)

      assert status == 200
    end
  end
end

Se rodarmos o teste novamente, iremos perceber duas coisas.

  1. O teste ainda falha (o que faz sentido, a resposta das duas requisições é o status 429

  2. O teste ficou mais lento.

Vamos resolver a primeira parte. A resposta para as duas requisições em nosso serviço falso é está sendo o 429:

Plug.Conn.resp(conn, 429, "")

Isso quer dizer, nunca receberemos o 200 dessa forma. Precisamos criar um mecanismo que entenda a quantidade de requisições feitas nesse teste. Infelizmente nem o bypass nem o Tesla nos ajudam nessa hora. Não possui uma forma simples de saber, uma vez que o conn vindo do bypass não se comunica diretamente com a resposta do mesmo. Iremos precisar de algo que rode internamente no bypass e que possamos obter o resultado de contagem fora dele. Podemos usar um Agent.

Agents são uma abstração em torno de um GenServer. GenServer mantem estado e podemos resgatar pelo PID. Usando Agente, não precisamos do PID e sim, apenas da instância. Exatamente o que precisamos. Vamos usar o exemplo que tem na documentação, é exatamente um contador =D

Criarei ele em lib/integrations/couter.ex

lib/integrations/couter.ex
defmodule CoffeeShop.Integrations.Counter do
  use Agent

  def start_link(initial_value) do
    Agent.start_link(fn -> initial_value end, name: __MODULE__)
  end

  def value do
    Agent.get(__MODULE__, & &1)
  end

  def increment do
    Agent.update(__MODULE__, &(&1 + 1))
  end
end

Podemos agora usar as funções

  • Counter.start_link/1 para iniciar nosso processo

  • Counter.increment/0 para adicionar 1 ao contador

  • Couter.value/0 para obter o valor total incrementado

Vamos adicionar esse ao nosso teste.

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

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

  describe "all_hot_coffees/0" do
    test "too much requests", %{bypass: bypass} do
      Counter.start_link(0)

      Bypass.expect(bypass, "GET", "/coffee/hot", fn conn ->
        IO.inspect(Counter.value())
        
        Counter.increment()
        Plug.Conn.resp(conn, 429, "")
      end)

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

      assert {:ok, %Response{status: status}} = Client.all_hot_coffees(opts)

      assert status == 200
    end
  end
end

Os pontos importantes ali são.

  • Iniciamos o Counter fora da chamada do Bypass, bem no inicio do teste.

  • Incrementamos toda vez que o Bypass identificar uma nova requisição

  • Adicionei também um IO.inspect/1 para vermos o valor aparecer em nosso console.

Podemos rodar o teste agora e veremos o valor sendo incrementado

mix test
0
1
2
3


  1) test all_hot_coffees/0 too much requests (CoffeeShop.Integrations.Coffee.ClientTest)
     test/integrations/coffee/client_test.exs:45
     Assertion with == failed
     code:  assert status == 200
     left:  429
     right: 200
     stacktrace:
       test/integrations/coffee/client_test.exs:60: (test)

.....
Finished in 4.7 seconds (0.00s async, 4.7s sync)
1 doctest, 5 tests, 1 failure

A contagem ficou de 0, 1, 2 e 3, Isso quer dizer, o 0 foi a primeira requisição e quando ele falhou começou a processar os retry, tendo 3 chances para se recuperar, ao chegar no limite máximo ele simplesmente ficou com a resposta da última tentativa. Percebe como nossos retries estão funcionando bem? Falta apenas uma logica para mockarmos o retorno de uma desses retries de forma a ser um sucesso.

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

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

  describe "all_hot_coffees/0" do
    #...
    test "too much requests", %{bypass: bypass} do
      Counter.start_link(0)
      
      response =
        File.read!("test/coffee_shop/integrations/coffee/fixtures/success.json")

      Bypass.expect(bypass, "GET", "/coffee/hot", fn conn ->
        case Counter.value() do
          0 = _first_call ->
            Counter.increment()
            Plug.Conn.resp(conn, 429, "Too many requests")

          1 = _first_retry_call ->
            Plug.Conn.resp(conn, 200, response)
        end
      end)

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

      assert {:ok, %Response{status: status}} = Client.all_hot_coffees(opts)

      assert status == 200
    end
  end
end

O case é utilziado para saber qual resposta devolvemos. Simulamos ali, uma resposta com status 429 e em seguida, com o retry, uma nova resposta com o 200. Isso garante que o retry está funcionando e que temos tratamento para 429.

mix test
> time mix test
Compiling 1 file (.ex)
......
Finished in 1.8 seconds (0.00s async, 1.8s sync)
1 doctest, 5 tests, 0 failures

Lindo não?

O problema do Delay

Temos um pequeno problema no nosso código, para ser especifico, um problema em rodar os testes. Façamos um experimento. Entre no seu cliente e altere o valor do delay e max_delay para 10_000:

lib/integrations/coffee/client.ex
defmodule CoffeeShop.Integrations.Coffee.Client do
  alias CoffeeShop.Integrations.Coffee.Response

  def all_hot_coffees(opts \\ []) do
    base_url = Keyword.get(opts, :base_url, "https://api.sampleapis.com")

    middlewares = [
      {Tesla.Middleware.Retry,
       delay: 10_000,
       max_retries: 3,
       max_delay: 10_000,
       should_retry: fn
         {:ok, %{status: status}} when status in [429] -> true
         {:ok, _} -> false
         {:error, _} -> true
       end}
    ]

    Tesla.client(middlewares)
    |> Tesla.get("#{base_url}/coffee/hot")
    |> Response.build()
  end
end

Rode esse teste e veja que o teste irá passar.

> time mix test
Compiling 1 file (.ex)
......
Finished in 18.1 seconds (0.00s async, 18.1s sync)
1 doctest, 5 tests, 0 failures

Pareceu até ter travado certo? O teste demorou 18 segundos. Nosso delay esta funcionando corretamente, inclusive o teste passa como deveria. Mas isso causa um problema de fluxo de trabalho. Imagina termos mais 10 clientes que também precisamos fazer o teste de retry. Nossa suite de teste ficaria lenta.

Precisamos de uma forma de configurar o delay em nossos testes, para não comprometer nossa performance.

Um minuto de agradecimento a nós do passado, que criamos um mecanismo de configuração. Podemos então fazer o mesmo que fizemos com a configuração da URL base.

lib/coffee_shop/integrations/coffee/client.ex
defmodule CoffeeShop.Integrations.Coffee.Client do
  alias CoffeeShop.Integrations.Coffee.Response

  def all_hot_coffees(opts \\ []) do
    base_url = Keyword.get(opts, :base_url, "https://api.sampleapis.com")
    delay_to_retry = Keyword.get(opts, :delay_to_retry, 1000)

    middlewares = [
      {Tesla.Middleware.Retry,
       delay: delay_to_retry,
       max_retries: 3,
       max_delay: 3000,
       should_retry: fn
         {:ok, %{status: status}} when status in [429] -> true
         {:ok, _} -> false
         {:error, _} -> true
       end}
    ]

    Tesla.client(middlewares)
    |> Tesla.get("#{base_url}/coffee/hot")
    |> Response.build()
  end
end

Agora basta passar a configuração por parâmetro:

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

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

  describe "all_hot_coffees/0" do
    # ...

    test "too much requests", %{bypass: bypass} do
      Counter.start_link(0)

      Bypass.expect(bypass, "GET", "/coffee/hot", fn conn ->
        case Counter.value() do
          0 = _first_call ->
            Counter.increment()
            Plug.Conn.resp(conn, 429, "")

          1 = _first_retry_call ->
            Plug.Conn.resp(conn, 200, "")
        end
      end)

      opts = [
        base_url: "http://localhost:3000",
        delay_to_retry: 1
      ]

      assert {:ok, %Response{status: status}} = Client.all_hot_coffees(opts)
      assert status == 200
    end
  end
end
> time mix test
Compiling 1 file (.ex)
......
Finished in 1.0 seconds (0.00s async, 1.0s sync)
1 doctest, 5 tests, 0 failures

Teste finalizado em 1 segundo. Muito melhor.

Refactoring

Por ultimo, vamos dar uma mexida em nosso cliente e deixa-lo melhor. Nossa função de requisição ficou grande e mistura chamada com configuração. Caso eu queira criar uma nova função para requisitar outro endpoint, teremos que duplicar a configuração, isso não é bom.

Vamos resolver isso extraindo para uma função de criação de client. Vou chama-lo de new_client/1 . Ela vai ser privada para apenas utilizarmos dentro do modulo. Para configurar o cliente, basta passar as opções de configurações por parâmetro. Sua resposta é um Tesla.Client configurado que poderá ser usado em qualquer requisição.

lib/integrations/coffee/client.ex
defmodule CoffeeShop.Integrations.Coffee.Client do
  alias CoffeeShop.Integrations.Coffee.Response

  defp new_client(opts \\ []) do
    middlewares = [
      {Tesla.Middleware.Retry,
       delay: Keyword.get(opts, :delay_to_retry, 1000),
       max_retries: 3,
       max_delay: 20_000,
       should_retry: fn
         {:ok, %{status: status}} when status in [429] -> true
         {:ok, _} -> false
         {:error, _} -> true
       end}
    ]

    Tesla.client(middlewares)
  end

  def all_hot_coffees(opts \\ []) do
    base_url = Keyword.get(opts, :base_url, "https://api.sampleapis.com")

    opts
    |> new_client()
    |> Tesla.get("#{base_url}/coffee/hot")
    |> Response.build()
  end
end

Muito melhor. Também gosto de separar a URL base para facilitar leitura.

lib/integrations/coffee/client.ex
defmodule CoffeeShop.Integrations.Coffee.Client do
  alias CoffeeShop.Integrations.Coffee.Response

  defp new_client(opts \\ []) do
    middlewares = [
      {Tesla.Middleware.Retry,
       delay: Keyword.get(opts, :delay_to_retry, 1000),
       max_retries: 3,
       max_delay: 20_000,
       should_retry: fn
         {:ok, %{status: status}} when status in [429] -> true
         {:ok, _} -> false
         {:error, _} -> true
       end}
    ]

    Tesla.client(middlewares)
  end

  def all_hot_coffees(opts \\ []) do
    base_url = Keyword.get(opts, :base_url, base_url())

    opts
    |> new_client()
    |> Tesla.get("#{base_url}/coffee/hot")
    |> Response.build()
  end
  
  defp base_url(), do: "https://api.sampleapis.com"
end

Com isso, temos um código legivel e prático.

Atualizado