Programação Por Equipe DevBras

Capa do artigo: Timeout em chamadas HTTP: o prazo que corta de verdade

Timeout em chamadas HTTP: o prazo que corta de verdade

Timeout em chamadas HTTP testado em requests, aiohttp e fetch: por que timeout=(3, 5) deixou passar 21 s, como pôr prazo total e repetir só o que pode repetir.

A API de frete de uma loja virtual não cai: ela fica lenta. Responde em 8 segundos em vez de 300 milissegundos. O checkout continua de pé, mas cada visitante que chega à etapa do frete prende uma conexão do servidor por 8 segundos. Ele atualiza a página, e agora são duas conexões presas. Com cem pessoas comprando, o pool de conexões acaba, e páginas que nem usam frete, como a busca e o login, começam a esperar na fila. A loja cai por causa do servidor de outra empresa.

O antídoto é um timeout de verdade em toda chamada que sai do seu processo: HTTP, banco, cache, fila, e-mail. Mas "de verdade" tem um detalhe que pega muita gente. Para mostrar esse detalhe, montamos uma dependência de mentira em Python e testamos contra ela os clientes mais comuns, com Python 3.13, requests 2.34, aiohttp 3.14 e Node 24.

A dependência de mentira

O servidor de teste tem três rotas. /frete espera 8 segundos antes de responder. /gotejando manda o cabeçalho na hora e depois o corpo de 22 bytes, um byte por segundo, que é o comportamento de um servidor sobrecarregado ou de um proxy engasgado. /cotacao devolve 503 nas duas primeiras chamadas e responde normalmente na terceira. Os resultados:

  • requests sem timeout, /frete: esperou os 8 s inteiros. Se o servidor nunca respondesse, esperaria para sempre. A documentação do requests diz isso: sem timeout, não há prazo.
  • requests com timeout=(3, 5), /frete: ReadTimeout em 5,0 s. Até aqui, tudo como esperado.
  • requests com timeout=(3, 5), /gotejando: respondeu, em 21 segundos. Nenhum erro.
  • fetch com AbortSignal.timeout(2500), /frete e /gotejando: cancelou nas duas em 2,5 s.

O terceiro resultado é o que importa. No requests, o segundo número da tupla é o tempo máximo entre dois pacotes recebidos, não o tempo total da resposta. Um servidor que pinga um byte a cada segundo nunca fica 5 segundos calado, então o prazo nunca estoura. O mesmo vale para muitos clientes HTTP que separam o prazo de conexão do prazo de leitura. Para uma página web, o número que importa é o total.

Prazo total no Node, com fetch

No Node e no navegador, AbortSignal.timeout cria um sinal que cancela a operação depois do prazo. Passado ao fetch, ele cobre a requisição inteira, inclusive a leitura do corpo com resposta.json(), como o teste com /gotejando confirmou. No estouro, o erro tem name igual a TimeoutError, o que separa o timeout dos outros erros de rede:

let timeoutsFrete = 0;

async function calcularFrete(cep) {
  const url = process.env.FRETE_URL + '/frete?cep=' + encodeURIComponent(cep);
  try {
    const resposta = await fetch(url, { signal: AbortSignal.timeout(2500) });
    if (!resposta.ok) throw new Error('Frete respondeu HTTP ' + resposta.status);
    return await resposta.json();
  } catch (erro) {
    if (erro.name === 'TimeoutError') {
      timeoutsFrete++;
      console.warn('Frete passou de 2,5 s', { cep, total: timeoutsFrete });
      return null;
    }
    throw erro;
  }
}

Quem chama trata o null como "frete indisponível no momento", mostra isso na tela e oferece tentar de novo. O contador é o mínimo. Em produção, mande esse número para a ferramenta de métricas, porque um aumento de timeouts é o primeiro sinal de que a dependência está degradando, e marque cada chamada com um correlation id para achar o pedido nos logs dos dois lados.

Prazo total em Python

O requests não tem opção de prazo total. A primeira versão do contorno que testamos lia a resposta com iter_content(chunk_size=1024) e conferia o relógio a cada pedaço. Não funcionou: o iter_content espera juntar 1.024 bytes, ou o corpo acabar, antes de devolver qualquer coisa, e a chamada levou os mesmos 21 s. A versão que funcionou lê com read1, que devolve o que já chegou:

import time, requests

def get_com_prazo_total(url, total, conectar=3):
    """Prazo total com requests: lê o que chegar e confere o relógio a cada pedaço."""
    limite = time.monotonic() + total
    with requests.get(url, stream=True, timeout=(conectar, total)) as r:
        r.raise_for_status()
        partes = []
        while parte := r.raw.read1(64 * 1024):
            partes.append(parte)
            if time.monotonic() > limite:
                raise TimeoutError(f"{url} passou de {total} s")
        return b"".join(partes)

Contra /gotejando, com prazo total de 5 s, ela levantou TimeoutError em 5,0 s. Quem pode usar código assíncrono tem uma alternativa pronta: o aiohttp aceita ClientTimeout(total=5, connect=3), que no teste cortou as duas rotas lentas em cerca de 6 s.

Nas outras linguagens, o raciocínio é o mesmo: descubra se o prazo da biblioteca é total ou por operação. No PHP com cURL, CURLOPT_CONNECTTIMEOUT é só a conexão e CURLOPT_TIMEOUT é a operação inteira. No .NET, a propriedade HttpClient.Timeout vale 100 segundos por padrão, e nenhuma pessoa espera 100 segundos olhando um checkout. Essas duas não foram testadas aqui. Não esqueça do banco e do cache: os drivers costumam ter opções separadas para o tempo de conexão e o de consulta, e uma consulta presa segura uma conexão do pool do mesmo jeito que uma API lenta segura uma thread.

Como escolher o número

  1. Meça o normal. Use o percentil alto do tempo de resposta da dependência, como o p99, e não a média.
  2. Dê folga. Um prazo um pouco acima do p99 deixa passar as chamadas lentas legítimas e corta as que travaram.
  3. Respeite o orçamento da página. Se a página precisa responder em 3 segundos e chama duas APIs em sequência, cada uma não pode ter 5 segundos.
  4. Fique abaixo do proxy. No Nginx, o proxy_read_timeout padrão é de 60 segundos. Se o seu código espera mais que isso, o visitante já recebeu um 504 e o seu processo continua trabalhando para ninguém.
  5. Decida o que acontece no estouro. Mensagem, valor padrão, versão em cache ou tentativa mais tarde. Isso é decisão de produto.

Repetir, mas só o que pode repetir

Depois de um timeout, você não sabe se a operação aconteceu do outro lado. Por isso a repetição automática só é segura em operações idempotentes, como leituras, cotações e buscas. Repetir uma cobrança ou a criação de um pedido sem proteção gera duplicata, e para essas o caminho é a chave descrita em idempotência no pagamento.

Mesmo em leituras, repita pouco e com espera crescente e um pouco de aleatoriedade, para que todos os servidores não martelem a dependência no mesmo instante em que ela tenta se recuperar:

async function comRepeticao(funcao, tentativas = 2) {
  for (let i = 0; i <= tentativas; i++) {
    try {
      return await funcao();
    } catch (erro) {
      if (i === tentativas) throw erro;
      const espera = 200 * 2 ** i + Math.random() * 100;
      await new Promise((resolver) => setTimeout(resolver, espera));
    }
  }
}

const cotacao = await comRepeticao(() =>
  fetch(process.env.COTACAO_URL + '/cotacao/USD', { signal: AbortSignal.timeout(2000) })
    .then((r) => {
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return r.json();
    })
);

Contra /cotacao, as duas primeiras tentativas receberam 503, com esperas de 245 ms e 475 ms, e a terceira trouxe a cotação, em 0,7 s no total. Cada tentativa ganha o próprio sinal de 2 segundos, porque o AbortSignal.timeout é criado dentro da função repetida. No pior caso, são três prazos de 2 s mais as esperas, ou seja, mais de 6 segundos. Se isso estoura o orçamento da página, reduza as tentativas. Se a dependência responde 429, respeite o cabeçalho de espera que ela mandar, como explica o post sobre rate limit que avisa.

Para achar as chamadas sem prazo num projeto, procure por fetch(, requests., curl_init e new HttpClient e confira uma por uma se o prazo é explícito e total.

Avalie este artigo

Referência: https://developer.mozilla.org/docs/Web/API/AbortSignal