⚗️
Consumindo APIs com Elixir
  • Introdução
  • Sobre o autor
  • O valor desse livro
  • Introdução
    • Por que elixir?
    • Como ler este livro
    • Sobre o conteúdo do livro
  • Configurando ambiente
    • Instalando o Elixir
    • Criando um projeto
  • Construindo um cliente usando Tesla
    • Iniciando
    • Tesla
      • O que é o Tesla
      • Instalando Tesla
    • Criando o Client
    • Estruturando resposta
    • Estratégia de teste para requisições
    • Instalando Bypass
    • Mockando requisições do cliente com Bypass
    • Tratando dados da resposta
  • Problemas de API externa
    • Erro genérico
    • O que é o rate limit
    • Rate Limite de curta duração
      • Reexecutando uma requisição
    • Rate Limit de longa duração
      • Agendando uma nova tentativa de requisição
      • Configurações necessárias
      • Adicionando Ecto ao projeto
      • O que é o Oban
      • Instalando Oban
      • Criando uma requisição assíncrona
      • Configurando quantidade de tentativas no Oban
  • Compondo integrações
    • Level up
    • Marvel API
      • Criando uma conta
      • Lendo o endpoint de Comics
      • Criando o cliente da Marvel
        • Melhorando a segurança
      • Lidando com a resposta
    • Aproveitando ao máximo o Rate Limit
  • Em breve
    • WIP - Supervisor
    • WIP - OAuth
    • WIP - Cacheando requisições
Fornecido por GitBook
Nesta página
  • O problema do Delay
  • Refactoring

Isto foi útil?

  1. Problemas de API externa
  2. Rate Limite de curta duração

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.

  • 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

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, "")

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.

AnteriorRate Limite de curta duraçãoPróximoRate Limit de longa duração

Atualizado há 11 meses

Isto foi útil?

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

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 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.

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 .

Tesla.Middleware.Retry
Tesla.Client
Agent