Angular: Implementando o Reenvio Automático de Requisições com Erro (backoff strategy)

Angular: Implementando o Reenvio Automático de Requisições com Erro (backoff strategy)

Acessar dados de um backend é uma tarefa essencial para a maioria das aplicações frontend, especialmente as Single Page Application - como: React, Next, Angular, Vue, Flutter e várias outras - onde todo conteúdo dinâmico pode ser carregado de um servidor.

Dentro das condições ideais, as requisições feitas ao backend ocorrem perfeitamente, e o resultado esperado é retornado. Esse é o cenário no qual passamos a maior parte do tempo desenvolvendo, testando e validando as aplicações, o qual se dá em um ambiente controlado.

Contudo, é fundamental ter em mente que, no ambiente real de utilização de uma aplicação web, situações extremamente imprevistas podem surgir, ressaltando ainda mais a necessidade de estar preparado para lidar com o inesperado.

Mesmo com uma infraestrutura de desenvolvimento e homologação bem elaborada, é uma tarefa desafiadora simular e testar todos os possíveis cenários de falha que podem ocorrer durante a utilização de uma aplicação em produção.

Em um contexto de uso real, muita coisa pode mudar

A sua aplicação web pode ser renderizada em diversos navegadores, em inúmeras versões. Ela pode rodar em diversos dispositivos, com diferentes sistemas operacionais. Provavelmente, será utilizada em dispositivos com conexões de internet instáveis, ou mesmo em hardware de baixo desempenho. Esses fatores podem resultar em quedas de conexão e, caso essa queda ocorra durante uma requisição da aplicação, problemas surgirão.

Dependendo de como sua aplicação foi construída, isso pode impactar no estado da aplicação, fazendo com que o usuário experiencie erros ao submeter um formulário, ou ao realizar uma compra no seu site, ou qualquer outro tipo de interação que necessite de uma requisição ao servidor.

Utilizando o operador retry()

Graças à biblioteca RxJS, contamos com um operador construído exatamente para atender a essa necessidade. Podemos utilizar o operador 'retry' para 'reassinar' o observable retornado pelo HttpClient e automaticamente reenviar a última requisição que falhou.

A grande vantagem é que esse operador é facilmente configurável e nos dá espaço para criar verdadeiras estratégias (ou políticas, se preferir chamá-las assim) de reenvio de requisições com falha.

Aqui está um simples exemplo de como fica a implementação:

import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { retry } from 'rxjs';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
  constructor(public httpClient: HttpClient) { }

  ngOnInit(): void {
    this.httpClient
      .get('http://localhost:3000/books')
      .pipe(retry(3))
      .subscribe((res) => {
        console.log('Successfull response: ', res);
      });
  }
}

Para conseguirmos testar essa implementação, criamos um simples servidor com express.js contendo uma rota '/books', que retorna um erro Http 500 de maneira aleatória:

const express = require('express');
const cors = require('cors');
const app = express();
const port = 3000;

app.use(cors({
  origin: 'http://localhost:4200'
}));

app.get('/books', (req, res) => {
  const randomNum = Math.random();
  if (randomNum > 0.5) {
    res.status(500).send('Internal Server Error');
  } else {
    res.json({ message: 'Hello world!' });
  }
});

E é assim que fica o funcionamento na prática:

No exemplo acima, a primeira requisição deu erro. Então o retry entrou em ação duas vezes: na primeira tivemos erro, o que levou a um segundo reenvio, no qual tivemos sucesso, não sendo mais necessário novos reenvios. Muito simples, não é mesmo?

Adicionalmente, podemos incluir o operador 'catchError' para realizar alguma tratativa quando todas as tentativas retry falharem:

  ngOnInit(): void {
    this.httpClient
      .get('http://localhost:3000/books')
      .pipe(retry(3), catchError(this.handleError))
      .subscribe((res) => {
        console.log('Successfull response: ', res);
      });
  }

  private handleError(err: any): Observable<any> {
    console.log('All retries attempts failed:', err);
    return throwError(() => new Error(err));
  }

Adicionando Delay entre os reenvios

Agora que temos o básico funcionando, podemos aprimorar a nossa implementação incluindo um intervalo de tempo entre as requisições. Essa estratégia é interessante para evitar a sobrecarga do servidor com múltiplas chamadas simultâneas. Ao adicionar um tempo de espera, você permite que o ambiente se estabilize antes de receber a próxima requisição, diminuindo a probabilidade de falhas consecutivas.

Para implementar a lógica de delay, basta substituirmos o parâmetro passado para o operador 'retry' por um objeto da interface RetryConfig. Nesse objeto, informaremos a quantidade de tentativas e o intervalo de tempo de atraso, em milissegundos.

private getWithSimpleRetry(): void {
  this.httpClient
    .get('http://localhost:3000/books')
    .pipe(
      retry({ count: 3, delay: 1000 }),
      catchError(this.handleError)
    ).subscribe((res) => {
      console.log('Successfull response: ', res);
    });
}

É possível notar na coluna 'Waterfall' do DevTools um espaço vazio entre as requisições de retry. Isso são os nossos 2 segundos de delay, na prática!

Utilizando Delay progressivo e Delay exponencial (Backoff strategy)

O período de espera entre as tentativas de reenvio é comumente conhecido como estratégia de backoff (ou política de backoff). Usaremos essa nomenclatura daqui em diante.

No exemplo anterior, nós aguardamos o mesmo intervalo de tempo entre cada requisição, ou seja, um tempo de backoff fixo.

Um tempo de backoff fixo pode ser adequado para cenários em que as falhas são raras, as tentativas de reenvio geralmente são bem-sucedidas e não há problemas de congestionamento significativos.

Entretanto, quando enfrentamos falhas recorrentes, congestionamento ou limitação de recursos, existem outras abordagens mais interessantes e que permitem uma melhor resposta e recuperação do sistema como, por exemplo, as estratégias de backoff linear e backoff exponencial.

Vamos dar uma olhada com mais detalhes em como funcionam as suas implementações:

Backoff Linear

Nessa abordagem, o atraso entre as tentativas aumenta linearmente a cada reenvio. Para isso, precisamos multiplicar o índice da tentativa atual com um valor inicial de delay.

No nosso exemplo abaixo, o delay inicial é de 1000 ms (1 segundo) e o índice da tentativa é iniciado em 1, logo, os atrasos serão: 1 * 1000 ms, 2 ** 1000 ms, 3 \ 1000 ms, 4 * 1000 ms... e assim por diante.

private getWithLinearBackoff(): void {
  this.httpClient
    .get('http://localhost:3000/books')
    .pipe(
      retry({
        count: 3,
        delay: (_error: any, retryCount: number) => timer((retryCount) * 1000),
      }),
      catchError(this.handleError)
    ).subscribe((res) => {
      console.log('Successfull response: ', res);
    });
}

No exemplo acima nos aproveitamos da interface RetryConfig, do operador retry. Sua propriedade delay pode ser tanto um número ou uma função que retorna um observable. Essa função deve possuir dois parâmetros, error e retryCount, que representam respectivamente erro retornado no retry e o índice dessa tentativa.

Quando comparamos o tempo de delay de cada tentativa em um gráfico, fica claro por que o chamamos de backoff linear.

Essa abordagem é muito interessante para atender às seguintes situações:

  • Falhas ocasionais: Se o sistema ocasionalmente enfrenta falhas temporárias e as tentativas de reenvio têm grandes chances de serem bem-sucedidas em uma nova chamada, o backoff linear pode ser uma escolha razoável. Nesse caso, um aumento linear simples do tempo de espera oferece uma abordagem direta e eficiente.

  • Minimizar o tempo total de espera: Se o tempo total de espera é uma consideração crítica e atrasos prolongados não são desejáveis, o backoff linear pode ser a opção preferida. Como o tempo de espera aumenta linearmente, o tempo total de espera para todas as tentativas é menor em comparação com outras estratégias.

Backoff Exponencial

Vimos que a estratégia linear é simples e fácil de implementar, mas pode não ser ideal para todas as situações. Existem cenários em que é recomendado o uso de estratégias de backoff mais sofisticadas, como o backoff exponencial.

Como o próprio nome indica, no backoff exponencial, o intervalo entre as tentativas aumenta exponencialmente a cada tentativa.

Para essa implementação, utilizaremos novamente um delay inicial de 1 segundo, porém, desta vez, o fator de multiplicação começará em 2 elevado ao índice da tentativa atual . Dessa forma, os atrasos serão 2^1 * 1000ms, 2^2 * 1000ms, 2^3 * 1000ms, 2^4 * 1000ms... e assim por diante.

private getWithExponentialBackoff(): void {
  this.httpClient
    .get('http://localhost:3000/books')
    .pipe(
      retry({
        count: 3,
        delay: (_error: any, retryCount: number) => {
          const delayTime = Math.pow(2, retryCount) * 1000;
          return timer(delayTime);
        },
      }),
      catchError(this.handleError)
    ).subscribe((res) => {
      console.log('Successfull response: ', res);
    });
}

Quebrei a implementação da função em mais duas linhas, para facilitar o entendimento.

Essa estratégia é adequada para atender situações como:

  • Congestionamento ou falhas persistentes: Se o sistema enfrentar congestionamentos frequentes ou falhas persistentes, o backoff exponencial é mais apropriado. Aumentar exponencialmente o tempo de espera ajuda a lidar com essas situações, permitindo que o sistema se recupere de maneira mais eficaz.

  • Recursos limitados: Se a infraestrutura possuir recursos limitados, como memória, largura de banda, processamento e similares, o backoff exponencial pode ser útil para evitar sobrecarregar esses recursos. O aumento exponencial do tempo de espera ajuda a reduzir a concentração de tentativas simultâneas.

Essa abordagem resulta em um comportamento bastante diferente do backoff linear e possui diversas vantagens, como maior flexibilidade para a estabilização do ambiente e a redução de congestionamentos, já que o aumento exponencial auxilia a evitar uma concentração de chamadas simultâneas. No entanto, também traz a desvantagem inevitável de um tempo total de espera maior.

Aplicando Backoff Strategy globalmente

Queremos aumentar a resiliência da nossa aplicação como um todo, e não apenas para uma única chamada. Para isso, precisamos estender a nossa estratégia de backoff para todas as requisições HTTP realizadas pela aplicação. Para alcançar esse objetivo, podemos criar um interceptor HTTP. Esse interceptor será responsável por incorporar a lógica de backoff em todas as requisições.

Vamos ver abaixo como implementar isso:

Primeiro, criaremos um arquivo chamado backoff.interceptor.ts com o seguinte código:

import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Observable, retry, timer } from "rxjs";

@Injectable()
export class BackoffInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(req)
      .pipe(
        retry({
          count: 3,
          delay: (_error: any, retryCount: number) => {
            const delayTime = Math.pow(2, retryCount) * 1000;
            return timer(delayTime);
          }
        })
      );
  }
}

Um interceptor é uma funcionalidade do Angular que permite a interceptação e o processamento de requisições e respostas HTTP. Não entrarei em mais detalhes sobre o funcionamento de um interceptor, pois esse é um recurso que merece uma publicação dedicada exclusivamente a ele.

Criado o interceptor, basta registrá-lo no módulo principal da aplicação (no nosso caso, o app.module.ts).

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
import { AppComponent } from './app.component';
import { BackoffInterceptor } from 'src/core/interceptors/backoff.interceptor';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule
  ],
  providers: [{
    provide: HTTP_INTERCEPTORS,
    useClass: BackoffInterceptor,
    multi: true
  }],
  bootstrap: [AppComponent]
})
export class AppModule { }

Feito isso, todas as requisições realizadas dentro da sua aplicação seguirão a estratégia de backoff definida no interceptor.

Atribuindo a estratégia para erros HTTP específicos

Conforme você provavelmente já deve ter notado até este ponto do artigo, nem todos os erros são relevantes para serem tratados por uma estratégia de backoff.

Alguns erros podem ser críticos ou indicar problemas irrecuperáveis, como erros de autenticação (401), erros de requisição (400), recursos não encontrados (404), entre vários outros tipos de erros que podem não ser adequados para o seu contexto. Limitar a estratégia de backoff a tipos específicos de erro garante que estamos aplicando a estratégia somente a erros transitórios ou temporários, nos quais uma nova tentativa de requisição pode ser suficiente para obter sucesso.

Vamos então editar o nosso código anterior para incluir uma verificação no código HTTP do erro e aplicar a estratégia de backoff somente a erros que estão na lista 'retryStatusCodes'.

import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Observable, retry, throwError, timer } from "rxjs";

@Injectable()
export class BackoffInterceptor implements HttpInterceptor {
  retryStatusCodes = [500, 502, 503, 504];

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(req)
      .pipe(
        retry({
          count: 3,
          delay: (err: any, retryCount: number) => {
            if (err instanceof HttpErrorResponse && this.retryStatusCodes.includes(err.status)) {
              const delayTime = Math.pow(2, retryCount) * 1000;
              return timer(delayTime);
            }
            // re-throwing erros that are not eligible for retry
            return throwError(() => err);
          }
        })
      );
  }
}

No nosso exemplo, estamos limitando a estratégia apenas para erros relacionados ao lado do servidor ou à infraestrutura. No entanto, vale ressaltar que a definição dos códigos deve considerar as especificidades do seu ambiente ou infraestrutura.

Sobre os códigos utilizados no exemplo acima:

  • 500 Internal Server Error: Erro inesperado no servidor durante o processamento da solicitação. Normalmente indica um problema do lado do servidor.

  • 502 Bad Gateway: Frequentemente encontrado em cenários de gateway ou proxy. Indica que o servidor que atua como gateway ou proxy recebeu uma resposta inválida de um servidor upstream.

  • 503 Serviço indisponível: Indica que o servidor não pode lidar com a solicitação devido a uma sobrecarga temporária ou manutenção. Isso implica que o servidor está temporariamente incapaz de processar a solicitação.

  • 504 Gateway Timeout: Este código de status indica que um servidor atuando como gateway ou proxy não recebeu uma resposta em tempo hábil de um servidor upstream ao tentar atender à solicitação.

Bonus: extraindo o código para um operador RXJS customizado

Anteriormente, vimos como implementar a estratégia de retry de forma global, aplicando-a a todas as requisições da aplicação. Embora essa abordagem tenha diversos benefícios, pode não ser a mais adequada para alguns cenários.

Digamos que você queira implementar essa estratégia de forma mais modular e reutilizável, onde você possa escolher exatamente quais chamadas usarão a estratégia e quais não.

Para isso, vamos criar um arquivo chamado retry-backoff.operator.ts com a seguinte implementação:

import { HttpErrorResponse } from '@angular/common/http';
import { timer, throwError } from 'rxjs';
import { retry } from 'rxjs/operators';

const retryStatusCodes = [500, 502, 503, 504];
const maxRetries: number = 3;
const initialDelay: number = 1000

export function retryWithBackoff() {
  return (errors: any) => {
    return errors.pipe(
      retry({
        count: maxRetries,
        delay: (err: any, retryCount: number) => {
          if (err instanceof HttpErrorResponse && retryStatusCodes.includes(err.status)) {
            const delayTime = Math.pow(2, retryCount) * initialDelay;
            return timer(delayTime);
          }
          return throwError(() => err);
        }
      }));
  };
}

Feito isso, basta importar o nosso novo operador e usá-lo para implementar a lógica de retry onde achar necessário, como nesse simples GET:

private getWithSimpleRetry(): void {
  this.httpClient
    .get('http://localhost:3000/books')
    .pipe(
      retryWithBackoff(),
      catchError(this.handleError)
    ).subscribe((res) => {
      console.log('Successfull response: ', res);
    });
}

Ou até mesmo usá-lo diretamente no HTTP Interceptor:

import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Observable, catchError, throwError } from "rxjs";
import { retryWithBackoff } from "../operators/retry-backoff.operator";

@Injectable()
export class BackoffInterceptor implements HttpInterceptor {

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(req)
      .pipe(
        retryWithBackoff(),
        catchError((err: HttpErrorResponse) => {
          // Implement your error handling logic here
          return throwError(() => err);
        })
      );
  }
}

Conclusão

Ao implementar uma política de retentativa inteligente para lidar com falhas temporárias, os desenvolvedores podem oferecer aos usuários uma experiência mais consistente e tranquila, mesmo em situações de instabilidade de rede ou sobrecarga do servidor.

A flexibilidade oferecida pelo Angular para a criação de interceptors personalizados permite que os desenvolvedores adaptem a estratégia de retry às necessidades específicas do negócio, tornando sua aplicação mais adaptável e eficiente.