Retries e lidando com erros transientes 101
Se você leu o primeiro post dessa série sobre sistemas distribuídos você aprendeu que Sistemas distribuídos são estranhos. A partir do momento que separamos os sistemas em computadores diferentes através da rede, um mundo novo de possibilidades e problemas se tornam acessíveis a nós. Entre eles, nossos sistemas agora são suscetíveis a erros transientes.
Nesse post, nós vamos discutir essas falhas temporárias, como podemos tentar resolver esse problema e quais novos problemas são gerados como parte de nossa solução.
Problema: Erros transientes
Se você trabalha com software você já deve ter esbarrado em um erro persistente, por exemplo, um bug no seu sistema que dá erro 100% das vezes que determinada condição ocorre ou quando o servidor sai do ar totalmente. Uma outra categoria de erro são os erros transientes ou erros temporários. Esses erros normalmente ocorrem de forma inesperada, muitas vezes duram apenas 1 segundo ou apenas alguns milisegundos, apenas o suficiente pra estregar o sucesso da sua requisição 🤡 .
Se lembrarmos do post anterior, as falácias dos sistemas distribuídos ajudam a explicar os erros transientes:
- A rede é confiável: A rede vai falhar. Ela pode falhar por apenas 1 segundo. Mas isso pode ser suficiente para impedir aquela requisição que seu sistema tentou fazer com sucesso.
- A topologia não muda: Em tempos de cloud, máquinas são adicionadas e removidas da rede o tempo inteiro. Imagine o que acontece se o sistema A depende do sistema B e, no momento que a requisição A -> B é feita, o sistema B está executando um deploy e a máquina responsável por atender a requisição é removida da rede.
- A topologia não muda: Com aparelhos mobile, usuários estão desconectando e reconectando em redes diferentes o tempo inteiro. O que acontece quando o usuário está executando uma requisição nos segundos de desconexão da rede?
Os exemplos são infindáveis. Em resumo, sempre haverá a chance que a sua primeira tentativa de executar uma requisição vai falhar.
Exemplo
Disclaimer: O código abaixo não passa na qualidade pra ir pra produção 😬.
Ok, muita lorota mas que tal simularmos o nosso problema? O código completo pode ser encontrado no github.
O método abaixo roda em uma pequena App com Spring Boot. Em resumo, o método falha todo minuto entre 00 e 05 segundos.
@RequestMapping("/")
public ResponseEntity<String> home() throws InterruptedException {
final var now = LocalDateTime.now();
logger.info("Failure flag value: " + now);
// Sempre envie um erro durante os primeiros 5s de cada minuto.
if (now.getSecond() >= 0 && now.getSecond() <= 5)
throw new IllegalStateException();
return ResponseEntity.status(200).body("Current time: " + now);
}
Eu escrevi um cliente que invoca essa app continuamente, porém, se o cliente percebe 1 erro, o cliente interrompe a execução.
public static void main(String[] args) throws URISyntaxException, InterruptedException {
final HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("http://localhost:8080"))
.GET()
.build();
while (NUM_ERRORS < 1) {
try {
var response = callService(request)
NUM_ERRORS = 0;
System.out.println("--------------------------------------------");
System.out.println(Thread.currentThread().getName());
System.out.println(response.statusCode());
System.out.println(response.body());
System.out.println("--------------------------------------------");
} catch (RuntimeException ex) {
System.out.println(ex.getMessage());
NUM_ERRORS++;
}
Thread.sleep(500);
}
}
private static HttpResponse<String> callService(HttpRequest request) {
try {
System.out.println("Executing request at" + LocalDateTime.now());
final HttpResponse<String> response =
HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 500)
throw new RuntimeException("Failed to call API");
return response;
} catch (IOException e) {
System.out.println("Operation failed, exception IO");
throw new RuntimeException(e);
} catch (InterruptedException e) {
System.out.println("Operation failed, exception Interrupted");
throw new RuntimeException(e);
}
}
Nossa execução pode gerar o seguinte log:
main
200
Current time: 2022-07-26T18:19:59.238087
--------------------------------------------
Executing request at2022-07-26T18:19:59.743644
--------------------------------------------
main
200
Current time: 2022-07-26T18:19:59.744904
--------------------------------------------
Executing request at2022-07-26T18:20:00.250584
Failed to call API
E aí? Como a gente pode evitar que nossa aplicação pare com apenas 1 erro?
Retries lineares
A forma mais simples de contornamos um erro transiente é tentar a mesma requisição de novo. Existem diversas bibliotecas que ajudam com isso como
sprint-boot-retry
e o resilience4j
. No nosso exemplo aqui, eu implementei com resilience4j
.
O código principal segue abaixo:
public static void main(String[] args) throws URISyntaxException, InterruptedException {
final HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("http://localhost:8080"))
.GET()
.build();
Function<HttpRequest, HttpResponse> service = (HttpRequest x) -> callService(x);
var config = RetryConfig.custom().retryExceptions(Exception.class).maxAttempts(3).waitDuration(
Duration.ofSeconds(3)).build();
var registry = RetryRegistry.of(config);
var retry = registry.retry("retry");
final var retryableServiceCall = Retry.decorateFunction(retry, service);
while (NUM_ERRORS < 1) {
try {
var response = retryableServiceCall.apply(request);
NUM_ERRORS = 0;
System.out.println("--------------------------------------------");
System.out.println(Thread.currentThread().getName());
System.out.println(response.statusCode());
System.out.println(response.body());
System.out.println("--------------------------------------------");
} catch (RuntimeException ex) {
System.out.println(ex.getMessage());
NUM_ERRORS++;
}
Thread.sleep(500);
}
}
Um possível resultado de executar o código acima seria:
main
200
Current time: 2022-07-27T17:05:59.227065
--------------------------------------------
Executing request at2022-07-27T17:05:59.731737
--------------------------------------------
main
200
Current time: 2022-07-27T17:05:59.733363
--------------------------------------------
Executing request at2022-07-27T17:06:00.238117
Executing request at2022-07-27T17:06:03.303864
Executing request at2022-07-27T17:06:06.316072
--------------------------------------------
main
200
Current time: 2022-07-27T17:06:06.318999
Observe como a execução roda a cada 500ms mas o seguinte comportamente acontece:
- Às
2022-07-27T17:06:00.238117
nós recebemos 1 falha. - Logo em seguida, vemos que nosso client tenta novamente
2022-07-27T17:06:03.303864
, mais 1 falha pois ainda estamos dentro do intervalo de falha (00-05s). - Finalmente a nossa 3ª tentativa é executada com sucesso
2022-07-27T17:06:06.316072
- Nossa execução continua como se não houvesse nenhuma falha. Afinal, a chamada foi feita com sucesso com o uso de retries.
Bacana né? Mas como tudo em engenharia de software, a solução acima tem alguns problemas.
Problemas com retries
Imagine a seguinte situação.
- É o início de uma black friday e todo mundo decide acessar o seu serviço ao mesmo tempo.
- Ao tentar processar esse fluxo de requisições ao mesmo tempo o nosso pobre servidor não aguenta e manda um erro temporário.
- Recebendo o erro temporário, todos os clientes decidem tentar a requisição novamente.
- As requisições com retry chegam todas de uma vez.
- O nosso servidor continua sobrecarregado e continua falhando as requisições.
- O usuário fica infeliz porque perdeu aquela promoção bacana…
Será que dá pra fazer melhor?
Retries com backoff and jitter
Nesse post de Março de 2015 Marc Brooker, Senior Principal Engineer no AWS, introduz a idéia de solucionar esse problema utilizando um algoritmo de retry com backoff e jitter. Como funciona essa ideia?
- Ao invés de executar a requisição de retry imediatamente após a falha o cliente espera um pouco
- Esse tempo de espera é determinado seguindo uma fórmula que leva em conta um pouco de aleatoriedade( essa é parte chamada jitter) e uma parte exponencial relacionada ao número de falhas (essa é a parte de backoff).
Por quê backoff?
A ideia do backoff é não tentar a requisição imediatamente. Ao invés disso, para casa falha, nós dobramos o tempo de espera. Por exemplo, na primeira falha esperamos 2s pra tentar de novo, na segunda falha nós esperamos 4s, na terceira falha 8s e assim sucessivamente. Essa estratégia faz com que os retries não continuem colocando pressão em um servidor já sobrecarregado.
Por quê Jitter?
Porém o backoff não resolve o problema completamente, imagine que todos os clientes falham ao mesmo tempo. Isso significa que em 2s o nosso servidor vai levar uma sobrecarga de requisições e em 4s de novo… O Jitter adiciona um pouco de aleatoriedade pra melhorar essa estratégia. Enquanto alguns clientes podem retentar em 2s, outros vão fazê-los em 2.1s ou 2.2s. Essa pequena variância ajudar a espalhar o número de requisições ao longo do tempo diminuindo a carga sobre a nossa aplicação.
Se você quer mais, o zanfranceschi tem uma thread bacana falando mais sobre backoff e jitter.
Mais problemas com retries
Voltando ao nosso problema da black friday imagine a segunda situação.
- Nós adicionamos o algoritmo de backoff e jitter ao nossos retries.
- Mesmo assim, a carga é tão alta que o nosso servidor não segue aguentar as requisições que continuam chegando em 2.1, 2.2 ou 2.3s depois.
- Com a adição dos retries ao tráfego normal, toda vez que o servidor tá pra se recuperar ele cai novamente.
A pergunta que fica é, se a carga está tão alta e um retry sempre vai falhar, por que deveríamos continuar enviando retries?
Retries com algoritmo de token bucket
Pra solucionar o problema acima nós vamos utilizar um novo algoritmo de retries que é a estratégia de utilizar um algoritmo de token bucket. Como funciona essa ideia?
- Para cada request feita com sucesso nós adicionamos uma percentagem de bucket em uma variável. Por exemplo: 0.1 token para cada sucesso.
- Para cada falha nós removes 1 token da variável.
- Nós só podemos realizar chamadas enquanto o token for acima de 0.
Por exemplo:
- Suponha que nosso token bucket começa com um valor de 3.
- Quando a primeira request falhar, nós subtraímos 1 do bucket. Agora o bucket tem o valor de 2.
- Como o bucket tem o valor acima de 0, nós executamos uma nova request.
- Recebemos uma nova falha e o bucket cai para 1.
- Tentamos novamente, acontece uma nova falha o bucket cai pra 0.
- Os retries param totalmente.
- Novas requisições podem ser feitas mas note que os retries não vai ser feitos já que o bucket atingiu 0.
- Quando as requisições começarem a ter sucesso, elas começam a adicionar 0.1 bucket para cada sucesso.
- Depois de 10 requisiçÕes com sucesso nós temos um bucket de valor 1 e agora caso haja uma falha nós teremos direito a 1 retry.
Esse algoritmo é ótimo para impedir o problema de retry storm que ocorre quando um serviço A chama um serviço B que chama um serviço C. Um erro em cascata pode dar trigger em um retry de A que pode tentar até 3x, B pode tentar também 3x o que faz com que C possa receber até 9x o tráfego 😱 .
Esse algoritmo pode ser utilizado ao lidar com a AWS SDK utilizando a estratégia de TokenBucketRetryCondition.
Ainda mais problemas com retries
Infelizmente, mesmo o token bucket não soluciona todos os nossos problemas. Por exemplo:
- O que acontece quando executamos o retry mas o servidor ainda está processando a 1ª requisição?
- Se falharmos 2x, vale à pena continuar consumindo recursos e tentar uma 3ª vez?
- Toda vez que fazemos retry, ainda estamos segurando a resposta ao usuário, o que faz com que a latência da requisição aumente e logo introduz mais erros de timeout.
- Devemos fazer retry no nível mais baixo, por exemplo em uma chamada de API, ou no nível mais alto apenas para evitar retry storms?
Esse artigo não vai responde todas essas perguntas mas algumas delas podemos explorar em novos artigos no futuro.
Conclusão
Nesse artigo nós discutimos:
- Erros temporários e porquê eles acontecem.
- Diferentes estratégias de retry como linear, backoff and jitter e Token Bucket.
- Diversos problemas ao se utilizar retries.
Como tudo em software, qual estratégia você vai utilizar depende. As vezes, não fazer o retry pode ser a melhor estratégia. Um post recente do Marc Brooker no blog pessoal dele discute em detalhes os trade-offs de cada estratégia de retry e compara elas com circuit breakers, por exemplo.
Espero que tenham curtido o post, e se você curtiu, compartilha e dá o like!