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.
defmoduleCoffeeShop.Integrations.Coffee.ClientTestdouseExUnit.CasealiasCoffeeShop.Integrations.Coffee.ClientaliasCoffeeShop.Integrations.Coffee.Response describe "all_hot_coffees/0"do# ... test "recovery of too much requests", %{bypass: bypass} doBypass.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 ==200endendend
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
defmoduleCoffeeShop.Integrations.Coffee.ClientdoaliasCoffeeShop.Integrations.Coffee.Responsedefall_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,_} ->falseend} ] middlewares|>Tesla.client()|>Tesla.get("#{base_url}/coffee/hot")|>Response.build()endend
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 test15:35:22.169 [error] #PID<0.285.0> running Bypass.Plug (connection #PID<0.283.0>, stream id 2) terminatedServer: 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.erl:306: :cowboy_stream_h.execute/3 (cowboy 2.12.0) /home/iago-effting/code/study/book/coffee_shop/deps/cowboy/src/cowboy_stream_h.erl:295: :cowboy_stream_h.request_process/3 (stdlib 5.2) proc_lib.erl:241: :proc_lib.init_p_do_apply/31) test all_hot_coffees/0 recovery of too much requests (CoffeeShop.Integrations.Coffee.ClientTest) test/coffee_shop/integrations/coffee/client_test.exs:31match (=) 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)Finishedin1.0seconds (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
defmoduleCoffeeShop.Integrations.Coffee.ClientTestdouseExUnit.CasealiasCoffeeShop.Integrations.Coffee.ClientaliasCoffeeShop.Integrations.Coffee.Response describe "all_hot_coffees/0"do# ... test "too much requests", %{bypass: bypass} doBypass.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 ==200endendend
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
Couter.value/0 para obter o valor total incrementado
Vamos adicionar esse ao nosso teste.
test/integrations/coffee/client_test.exs
defmoduleCoffeeShop.Integrations.Coffee.ClientTestdouseExUnit.CasealiasCoffeeShop.Integrations.Coffee.ClientaliasCoffeeShop.Integrations.Coffee.ResponsealiasCoffeeShop.Integrations.Counter describe "all_hot_coffees/0"do test "too much requests", %{bypass: bypass} doCounter.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 ==200endendend
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
defmoduleCoffeeShop.Integrations.Coffee.ClientTestdouseExUnit.CasealiasCoffeeShop.Integrations.Coffee.ClientaliasCoffeeShop.Integrations.Coffee.ResponsealiasCoffeeShop.Integrations.Counter describe "all_hot_coffees/0"do#... test "too much requests", %{bypass: bypass} doCounter.start_link(0) response =File.read!("test/coffee_shop/integrations/coffee/fixtures/success.json")Bypass.expect(bypass,"GET","/coffee/hot",fn conn ->caseCounter.value() do0=_first_call->Counter.increment()Plug.Conn.resp(conn,429,"Too many requests")1=_first_retry_call->Plug.Conn.resp(conn,200, response)endend) opts = [ base_url: "http://localhost:3000" ] assert {:ok, %Response{status: status}} =Client.all_hot_coffees(opts) assert status ==200endendend
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
defmoduleCoffeeShop.Integrations.Coffee.ClientdoaliasCoffeeShop.Integrations.Coffee.Responsedefall_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,_} ->trueend} ]Tesla.client(middlewares)|>Tesla.get("#{base_url}/coffee/hot")|>Response.build()endend
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
defmoduleCoffeeShop.Integrations.Coffee.ClientdoaliasCoffeeShop.Integrations.Coffee.Responsedefall_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,_} ->trueend} ]Tesla.client(middlewares)|>Tesla.get("#{base_url}/coffee/hot")|>Response.build()endend
Agora basta passar a configuração por parâmetro:
test/integrations/coffee/client_test.exs
defmoduleCoffeeShop.Integrations.Coffee.ClientTestdouseExUnit.CasealiasCoffeeShop.Integrations.Coffee.ClientaliasCoffeeShop.Integrations.Coffee.ResponsealiasCoffeeShop.Integrations.Counter describe "all_hot_coffees/0"do# ... test "too much requests", %{bypass: bypass} doCounter.start_link(0)Bypass.expect(bypass,"GET","/coffee/hot",fn conn ->caseCounter.value() do0=_first_call->Counter.increment()Plug.Conn.resp(conn,429,"")1=_first_retry_call->Plug.Conn.resp(conn,200,"")endend) opts = [ base_url: "http://localhost:3000", delay_to_retry: 1 ] assert {:ok, %Response{status: status}} =Client.all_hot_coffees(opts) assert status ==200endendend
> 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
defmoduleCoffeeShop.Integrations.Coffee.ClientdoaliasCoffeeShop.Integrations.Coffee.Responsedefpnew_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,_} ->trueend} ]Tesla.client(middlewares)enddefall_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()endend
Muito melhor. Também gosto de separar a URL base para facilitar leitura.
lib/integrations/coffee/client.ex
defmoduleCoffeeShop.Integrations.Coffee.ClientdoaliasCoffeeShop.Integrations.Coffee.Responsedefpnew_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,_} ->trueend} ]Tesla.client(middlewares)enddefall_hot_coffees(opts \\ []) do base_url =Keyword.get(opts, :base_url,base_url()) opts|>new_client()|>Tesla.get("#{base_url}/coffee/hot")|>Response.build()enddefpbase_url(), do: "https://api.sampleapis.com"end