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.
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.
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
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
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 processoCounter.increment/0
para adicionar 1 ao contadorCouter.value/0
para obter o valor total incrementado
Vamos adicionar esse ao nosso teste.
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.
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:
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.
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:
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.
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.
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
Isto foi útil?