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.
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.Clientpassando 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.
O teste ainda falha (o que faz sentido, a resposta das duas requisições é o status 429
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