Em aplicações JavaScript modernas, lidar com operações assíncronas vai muito além de usar async/await. No mundo real, precisamos de padrões robustos para lidar com falhas de rede, latência, concorrência e controle de execução. Nesta aula, vamos mergulhar em quatro padrões fundamentais: retry, timeout, cancelamento com AbortController e fila de requisições. Cada um resolve problemas comuns e, quando combinados, formam a base de aplicações resilientes e eficientes.

Vamos começar entendendo o contexto: uma operação assíncrona pode falhar por vários motivos (rede instável, servidor sobrecarregado, etc.). Um retry bem implementado pode contornar falhas transitórias. Um timeout evita que nossa aplicação fique esperando indefinidamente. O cancelamento permite que o usuário desista de uma operação e o sistema responda de forma adequada. E uma fila controla o acesso a recursos limitados, evitando sobrecarga. Ao final, você terá ferramentas reutilizáveis para aplicar em seus projetos.

Retry

O padrão de retry consiste em tentar executar uma operação assíncrona novamente após uma falha, geralmente com um atraso entre as tentativas. Isso é útil quando a falha é transitória (por exemplo, um servidor retorna 503 Temporariamente Indisponível). No entanto, é preciso cuidado: tentar repetir imediatamente pode sobrecarregar o servidor e não dar tempo para ele se recuperar. Por isso, usamos um backoff — um atraso que aumenta progressivamente a cada tentativa.

Vamos implementar uma função retry que recebe uma função assíncrona, o número máximo de tentativas e um intervalo base. Ela tentará executar a função; se falhar, espera o intervalo (multiplicado por um fator exponencial) e tenta novamente. Também devemos considerar que alguns erros não devem ser repetidos (por exemplo, um erro de validação 400). Por isso, nossa função pode aceitar uma condição para decidir se uma falha é retentável.

// Função de retry com backoff exponencial
async function retry(fn, maxAttempts = 3, baseDelay = 1000, shouldRetry = () => true) {
  let lastError;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      if (attempt === maxAttempts || !shouldRetry(error)) break;
      const delay = baseDelay * Math.pow(2, attempt - 1); // 1s, 2s, 4s...
      console.log(`Tentativa ${attempt} falhou. Tentando novamente em ${delay}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
  throw lastError;
}

// Exemplo de uso: buscar dados de uma API com retry
async function fetchData(url) {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

// Só repete em erros 5xx (servidor)
retry(() => fetchData('https://api.exemplo.com/dados'), 3, 500, (err) => {
  return err.message.includes('5'); // simplificação
}).then(data => console.log('Dados:', data))
  .catch(err => console.error('Falha definitiva:', err));

No exemplo, a função retry tenta até 3 vezes, com atraso de 500ms, 1000ms e 2000ms. A condição shouldRetry é uma função que recebe o erro e decide se repetimos. Em um cenário real, você pode verificar o status HTTP ou um código de erro específico.

Timeout

Timeout é um padrão que impõe um limite de tempo para uma operação assíncrona. Se a operação não concluir dentro do prazo, ela é rejeitada com um erro (geralmente TimeoutError). Isso é essencial para evitar que a aplicação fique travada esperando uma resposta que pode nunca chegar.

Uma implementação comum é usar Promise.race entre a promessa original e uma promessa que rejeita após um atraso. Vamos criar uma função withTimeout que envolve uma promessa e adiciona o limite de tempo. É importante que, ao expirar, a promessa original seja cancelada (se possível) para não desperdiçar recursos — veremos como com AbortController na próxima seção.

// Função com timeout
function withTimeout(promise, ms) {
  let timeoutId;
  const timeoutPromise = new Promise((_, reject) => {
    timeoutId = setTimeout(() => {
      reject(new Error('Operação excedeu o tempo limite'));
    }, ms);
  });

  return Promise.race([promise, timeoutPromise])
    .finally(() => clearTimeout(timeoutId));
}

// Exemplo: chamada a API com timeout de 3 segundos
const request = fetch('https://api.exemplo.com/lenta')
  .then(res => res.json());

withTimeout(request, 3000)
  .then(data => console.log('Resposta:', data))
  .catch(err => console.error('Timeout ou erro:', err.message));

No código, usamos Promise.race para competir entre a promessa original e o timer. O finally garante que o timer seja limpo, mesmo se a promessa original vencer. Note que a promessa original não é cancelada; ela continua executando em segundo plano. Para cancelar de verdade, precisamos do AbortController.

Cancelamento com AbortController

O AbortController é uma API nativa do JavaScript que permite cancelar operações assíncronas, como requisições fetch, leitura de arquivos, etc. Ele funciona através de um sinal (signal) que pode ser passado para a operação. Quando abort() é chamado, a operação é cancelada e a promessa é rejeitada com um erro AbortError.

Vamos ver como usar AbortController com fetch e como combiná-lo com timeout para cancelar a requisição quando o tempo expira. Isso é uma prática recomendada para evitar que a requisição continue após o timeout.

// Exemplo de cancelamento com AbortController
async function fetchWithTimeout(url, timeoutMs) {
  const controller = new AbortController();
  const { signal } = controller;

  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal });
    clearTimeout(timeoutId); // limpa o timer se a requisição terminar antes
    return await response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new Error('Requisição cancelada (timeout ou abort manual)');
    }
    throw err;
  }
}

// Exemplo de uso
fetchWithTimeout('https://api.exemplo.com/dados', 2000)
  .then(data => console.log(data))
  .catch(err => console.error(err.message));

// Cancelamento manual
const controller = new AbortController();
const signal = controller.signal;

fetch('https://api.exemplo.com/dados', { signal })
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === 'AbortError') console.log('Cancelado pelo usuário');
    else console.error('Erro:', err);
  });

// Em algum outro lugar, o usuário clica em cancelar
setTimeout(() => controller.abort(), 1000);

No exemplo, o fetchWithTimeout cria um AbortController e passa o signal para o fetch. Se o tempo expirar, o controller aborta a requisição, fazendo o fetch rejeitar com AbortError. Capturamos esse erro e lançamos um erro mais amigável. Também mostramos como abortar manualmente, por exemplo, quando o usuário clica em um botão cancelar.

Fila de requisições

Em muitas situações, precisamos limitar o número de requisições simultâneas para não sobrecarregar um servidor ou para respeitar limites de uso de API. Uma fila de requisições controla a concorrência: as requisições são adicionadas a uma fila e executadas uma a uma (ou com um limite de concorrência). Isso é comum em aplicações que fazem muitas chamadas a uma API com rate limit.

Vamos implementar uma classe Queue que permite adicionar tarefas assíncronas e as executa com um limite de concorrência. Usaremos um array para armazenar as tarefas pendentes e um contador de tarefas ativas. Quando uma tarefa termina, processamos a próxima da fila.

class Queue {
  constructor(concurrency = 1) {
    this.concurrency = concurrency;
    this.tasks = [];
    this.activeCount = 0;
  }

  // Adiciona uma tarefa à fila e retorna uma promessa que resolve com o resultado da tarefa
  add(task) {
    return new Promise((resolve, reject) => {
      this.tasks.push({ task, resolve, reject });
      this._process();
    });
  }

  _process() {
    if (this.activeCount < this.concurrency && this.tasks.length > 0) {
      const { task, resolve, reject } = this.tasks.shift();
      this.activeCount++;

      task()
        .then(result => {
          resolve(result);
        })
        .catch(error => {
          reject(error);
        })
        .finally(() => {
          this.activeCount--;
          this._process(); // processa a próxima tarefa
        });
    }
  }
}

// Exemplo de uso: limitar a 2 requisições simultâneas
const queue = new Queue(2);

function makeRequest(url) {
  return fetch(url).then(res => res.json());
}

// Adiciona várias requisições à fila
const urls = ['url1', 'url2', 'url3', 'url4'];
urls.forEach(url => {
  queue.add(() => makeRequest(url))
    .then(data => console.log('Dados de', url, data))
    .catch(err => console.error('Erro em', url, err));
});

No código, a classe Queue gerencia as tarefas. O método add retorna uma promessa que será resolvida quando a tarefa for executada. O _process é chamado sempre que uma vaga é liberada. Isso garante que no máximo concurrency tarefas estejam ativas ao mesmo tempo. Você pode adaptar essa implementação para usar AbortController e timeouts em cada requisição.

Boas práticas e observações finais

Ao combinar esses padrões, é importante considerar:

  • Backoff com jitter: adicione um fator aleatório ao atraso para evitar que várias tentativas aconteçam ao mesmo tempo (thundering herd).
  • Retry apenas em erros transitórios: não repita em erros de validação ou autenticação.
  • Sempre cancele o timeout: quando a operação termina, limpe o timer para evitar vazamentos.
  • Use AbortController com fetch: é a forma padrão de cancelar requisições.
  • Limite a fila: defina um tamanho máximo para evitar acúmulo ilimitado de tarefas.
  • Trate erros em cada tarefa: uma tarefa que falha não deve impedir as demais.

Esses padrões são amplamente utilizados em bibliotecas como Axios, p-limit, etc. Dominá-los fará seus códigos muito mais profissionais.

Exercícios

  1. Exercício 1: Implemente uma função fetchWithRetry que usa retry com backoff exponencial e também um timeout de 5 segundos por tentativa. Use AbortController para cancelar a requisição no timeout. A função deve receber a URL e o número máximo de tentativas.
  2. Exercício 2: Crie uma função fetchWithTimeout que aceita um AbortSignal externo. Se o sinal externo for abortado, a requisição deve ser cancelada. Combine isso com um timeout interno.
  3. Exercício 3: Escreva uma classe RateLimiter que aceite um número máximo de requisições por segundo e use uma fila para garantir que as requisições sejam espaçadas. Use a classe Queue como base.
  4. Exercício 4: Dado um array de URLs, use uma fila com concorrência de 3 para fazer fetch de todas e exibir os resultados em ordem de conclusão. Adicione retry (2 tentativas) e timeout (3s) para cada requisição.
  5. Exercício 5: Implemente uma função cancelablePromise que recebe uma promessa e retorna um objeto com a promessa original e um método cancel(). O cancelamento deve rejeitar a promessa com um erro CancelError.

✓ Resposta:
async function fetchWithRetry(url, maxAttempts = 3) {
  const baseDelay = 500;
  let lastError;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 5000);

    try {
      const response = await fetch(url, { signal: controller.signal });
      clearTimeout(timeoutId);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.json();
    } catch (err) {
      clearTimeout(timeoutId);
      lastError = err;
      if (attempt === maxAttempts || err.name === 'AbortError') break;
      const delay = baseDelay * Math.pow(2, attempt - 1);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
  throw lastError;
}

✓ Resposta:
async function fetchWithTimeout(url, timeoutMs, externalSignal) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  const handleAbort = () => controller.abort();
  if (externalSignal) {
    if (externalSignal.aborted) controller.abort();
    else externalSignal.addEventListener('abort', handleAbort);
  }

  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);
    if (externalSignal) externalSignal.removeEventListener('abort', handleAbort);
    return await response.json();
  } catch (err) {
    clearTimeout(timeoutId);
    if (externalSignal) externalSignal.removeEventListener('abort', handleAbort);
    if (err.name === 'AbortError') {
      if (externalSignal && externalSignal.aborted) throw new Error('Cancelado pelo usuário');
      else throw new Error('Timeout');
    }
    throw err;
  }
}

✓ Resposta:
class RateLimiter {
  constructor(maxPerSecond) {
    this.maxPerSecond = maxPerSecond;
    this.queue = []
    this.activeCount = 0;
    this.interval = 1000 / maxPerSecond;
    this.lastExecution = 0;
  }

  add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject });
      this._process();
    });
  }

  _process() {
    if (this.queue.length === 0) return;

    const now = Date.now();
    const wait = Math.max(0, this.lastExecution + this.interval - now);

    setTimeout(() => {
      if (this.activeCount < this.maxPerSecond) { // na verdade, maxPerSecond é por segundo, mas aqui controlamos por intervalo
        const { task, resolve, reject } = this.queue.shift();
        this.activeCount++;
        this.lastExecution = Date.now();

        task()
          .then(resolve)
          .catch(reject)
          .finally(() => {
            this.activeCount--;
            this._process();
          });
      } else {
        this._process();
      }
    }, wait);
  }
}

✓ Resposta:
const urls = ['https://api1.com', 'https://api2.com', 'https://api3.com', 'https://api4.com', 'https://api5.com'];

async function fetchWithRetryAndTimeout(url) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 3000);
  try {
    let response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (err) {
    clearTimeout(timeoutId);
    // retry uma vez
    if (err.name === 'AbortError' || err.message.startsWith('HTTP')) {
      const retryController = new AbortController();
      const retryTimeout = setTimeout(() => retryController.abort(), 3000);
      try {
        let response = await fetch(url, { signal: retryController.signal });
        clearTimeout(retryTimeout);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return await response.json();
      } catch (retryErr) {
        clearTimeout(retryTimeout);
        throw retryErr;
      }
    }
    throw err;
  }
}

const queue = new Queue(3);
urls.forEach(url => {
  queue.add(() => fetchWithRetryAndTimeout(url))
    .then(data => console.log('Sucesso:', url, data))
    .catch(err => console.error('Falha:', url, err));
});

✓ Resposta:
function cancelablePromise(promise) {
  let cancelReject;
  const cancel = new Promise((_, reject) => {
    cancelReject = reject;
  });

  const wrapped = Promise.race([promise, cancel]);

  return {
    promise: wrapped,
    cancel() {
      cancelReject(new Error('CancelError'));
    }
  };
}

// Exemplo de uso
const { promise, cancel } = cancelablePromise(new Promise(resolve => setTimeout(() => resolve('ok'), 3000)));
promise.catch(err => console.error(err.message)); // CancelError após cancel()
setTimeout(() => cancel(), 1000);

Referências