Como evitar respostas obsoletas da API com AbortController


Caixas de pesquisa, filtros de produtos, painéis e componentes de preenchimento automático enviam frequentemente várias solicitações de API em poucos segundos. A implementação pode parecer correta durante os testes, mas surge um problema sutil quando as respostas da rede chegam em uma ordem diferente das solicitações.

Imagine que um usuário digite “javascript” em um campo de pesquisa. O aplicativo envia uma solicitação após “java” e outra após “javascript”. Se a primeira solicitação demorar mais, ela poderá chegar por último e substituir os resultados mais recentes por dados desatualizados.

Esta é uma condição de corrida. Uma maneira eficaz de evitar isso é cancelar a solicitação anterior com JavaScript. AbortController antes de iniciar um novo.

Por que o pedido de solicitação se torna não confiável

As solicitações de rede não terminam necessariamente na ordem em que foram criadas. A carga do servidor, a qualidade da conexão, o cache e a complexidade da consulta podem afetar o tempo de resposta.

Considere esta função de pesquisa básica:

async function searchArticles(query) {
  const response = await fetch(
    `/api/articles?q=${encodeURIComponent(query)}`
  );

  const articles = await response.json();
  renderResults(articles);
}

Se esta função for executada sempre que a entrada for alterada, cada solicitação permanecerá ativa até ser bem-sucedida ou falhar. Uma resposta mais antiga pode, portanto, substituir uma resposta mais relevante.

A eliminação da entrada reduz o número de solicitações, mas não resolve completamente o problema. Os usuários ainda poderão digitar novamente após o atraso na depuração enquanto uma solicitação anterior estiver em andamento.

O aplicativo precisa de uma forma de marcar a operação anterior como desnecessária.

Crie um AbortController

Um AbortController cria um sinal que pode ser passado para uma operação assíncrona. Chamando seu abort() O método altera o estado do sinal e informa às APIs suportadas para interromper seu trabalho.

A documentação do MDN para AbortController explica a interface, a disponibilidade do navegador e seu relacionamento com AbortSignal.

Para um componente de pesquisa ao vivo, mantenha o controlador atual fora da função de pesquisa:

let activeController = null;

async function searchArticles(query) {
  if (activeController) {
    activeController.abort();
  }

  activeController = new AbortController();

  const response = await fetch(
    `/api/articles?q=${encodeURIComponent(query)}`,
    {
      signal: activeController.signal
    }
  );

  const articles = await response.json();
  renderResults(articles);
}

Cada nova chamada cancela a solicitação anterior. A solicitação mais recente recebe um novo controlador e sinal.

Um controlador abortado não pode ser reutilizado. Uma vez abortado o sinal, crie um novo controlador para a próxima operação.

Lidar com o cancelamento separadamente dos erros

Abortar uma solicitação causa fetch() rejeitar sua promessa. Sem tratamento de erros, o navegador pode relatar uma rejeição não tratada.

O cancelamento é um comportamento esperado neste componente, portanto não deve ser exibido ao usuário como uma falha de rede.

let activeController = null;

async function searchArticles(query) {
  activeController?.abort();

  const controller = new AbortController();
  activeController = controller;

  try {
    setSearchStatus("loading");

    const response = await fetch(
      `/api/articles?q=${encodeURIComponent(query)}`,
      {
        signal: controller.signal
      }
    );

    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }

    const articles = await response.json();
    renderResults(articles);
    setSearchStatus("complete");
  } catch (error) {
    if (error.name === "AbortError") {
      return;
    }

    console.error(error);
    setSearchStatus("error");
  }
}

Esta distinção é importante tanto para a interface quanto para o monitoramento. Uma pesquisa cancelada não é evidência de que a API está indisponível. Relatar cada solicitação abortada como um erro de aplicativo pode produzir logs ruidosos e métricas de falha enganosas.

Verificando response.ok também é importante porque fetch() não rejeita automaticamente respostas HTTP como 404 ou 500.

Para uma introdução mais ampla à configuração de solicitações e ao tratamento de respostas, o SitePoint guia para a API Fetch cobre os principais padrões.

Conecte a função a um campo de pesquisa

A pesquisa não deve ser executada para entradas vazias, e um pequeno atraso de rejeição pode impedir que uma solicitação seja enviada após cada pressionamento de tecla.

<label for="article-search">Search articles</label>
<input
  id="article-search"
  type="search"
  autocomplete="off"
  placeholder="Enter a topic"
>

<p id="search-status" aria-live="polite"></p>
<ul id="search-results"></ul>

Adicione o comportamento de entrada:

const searchInput = document.querySelector("#article-search");

let debounceTimer;

searchInput.addEventListener("input", event => {
  clearTimeout(debounceTimer);

  const query = event.target.value.trim();

  if (!query) {
    activeController?.abort();
    clearResults();
    setSearchStatus("idle");
    return;
  }

  debounceTimer = setTimeout(() => {
    searchArticles(query);
  }, 300);
});

A eliminação e o cancelamento resolvem problemas diferentes. A eliminação limita a frequência com que as solicitações são iniciadas, enquanto o cancelamento impede que uma solicitação ativa anterior atualize a interface posteriormente.

Usar ambos produz um componente mais confiável.

Evite outra condição de corrida do controlador

Há mais um detalhe a considerar. Uma solicitação mais antiga finally bloco pode modificar o estado de carregamento após o início de uma solicitação mais recente.

Por exemplo, esta implementação não é segura:

finally {
  setSearchStatus("idle");
}

Se uma solicitação cancelada chegar finally após o início da próxima solicitação, poderá remover o indicador de carregamento da nova solicitação.

Confirme se o controlador ainda pertence à solicitação mais recente antes de alterar o estado compartilhado:

async function searchArticles(query) {
  activeController?.abort();

  const controller = new AbortController();
  activeController = controller;

  try {
    setSearchStatus("loading");

    const response = await fetch(
      `/api/articles?q=${encodeURIComponent(query)}`,
      {
        signal: controller.signal
      }
    );

    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }

    const articles = await response.json();

    if (controller === activeController) {
      renderResults(articles);
      setSearchStatus("complete");
    }
  } catch (error) {
    if (error.name !== "AbortError") {
      console.error(error);

      if (controller === activeController) {
        setSearchStatus("error");
      }
    }
  } finally {
    if (controller === activeController) {
      activeController = null;
    }
  }
}

A verificação de identidade garante que apenas a operação mais recente possa atualizar o estado partilhado.

Cancelar solicitações durante a limpeza de componentes

O cancelamento também é útil ao remover um componente ou navegar para outra vista. Uma solicitação pode continuar depois que seus resultados não forem mais necessários.

Em uma estrutura, chame abort() do mecanismo de limpeza do componente. Em JavaScript simples, exponha uma função de limpeza:

function destroySearch() {
  clearTimeout(debounceTimer);
  activeController?.abort();
  activeController = null;
}

Isso reduz o trabalho desnecessário e evita a atualização de elementos de interface separados.

Lembre-se de que o cancelamento no navegador não garante que o servidor interrompa o processamento imediatamente. Diz ao cliente que a resposta não é mais necessária. As operações de servidor que realizam trabalhos caros podem exigir sua própria estratégia de cancelamento, tempo limite ou idempotência.

Adicionar um tempo limite de solicitação

O cancelamento do usuário e os tempos limite de solicitação representam condições diferentes. Um usuário pode iniciar o cancelamento digitando outra consulta, enquanto um tempo limite indica que a operação demorou muito.

Um auxiliar de tempo limite reutilizável pode abortar uma solicitação após um período definido:

function createTimeoutController(milliseconds) {
  const controller = new AbortController();

  const timeoutId = setTimeout(() => {
    controller.abort();
  }, milliseconds);

  return {
    controller,
    clear: () => clearTimeout(timeoutId)
  };
}

Use-o quando uma solicitação tiver um limite de tempo significativo:

const timeout = createTimeoutController(8000);

try {
  const response = await fetch("/api/articles", {
    signal: timeout.controller.signal
  });

  return await response.json();
} finally {
  timeout.clear();
}

Limpar o cronômetro impede que ele seja executado após a conclusão da solicitação.

Considerações finais

As condições de corrida nas solicitações do navegador são fáceis de ignorar porque dependem do tempo. Eles geralmente aparecem apenas em redes lentas, carga pesada do servidor ou interação rápida do usuário.

AbortController oferece aos desenvolvedores um mecanismo pequeno e nativo para cancelar trabalhos que não são mais relevantes. Em uma interface de pesquisa, combine-o com depuração, tratamento explícito de erros HTTP, atualizações de status acessíveis e verificações de identidade do controlador.

O resultado não é apenas menos solicitações de rede. É uma interface que exibe consistentemente a resposta associada à ação mais recente do usuário.



Source link