Fechar a aba não cancela automaticamente a chamada que o seu servidor fez ao LLM. O navegador pode abandonar o stream, enquanto o backend continua aguardando tokens, mantendo uma conexão aberta ou preparando uma nova tentativa.

A correção é tratar o ciclo HTTP como parte do runtime. Quando a resposta do servidor fecha antes de terminar, aborte um AbortController, passe o sinal para o SDK ou fetch que fala com o LLM e remova os listeners quando a resposta terminar normalmente. O guia sobre cancelar um agente quando uma tool trava cobre a política geral de cancelamento; aqui o recorte é a fronteira entre cliente, servidor e upstream.

Este artigo usa Node.js e TypeScript para explicar o mecanismo. O código do adapter é ilustrativo e não foi executado contra um provedor real neste repositório. A documentação do OpenAI Node SDK confirma que um AbortSignal pode cancelar a requisição, inclusive enquanto o corpo da resposta é lido. O mesmo padrão pode ser adaptado para outro SDK que aceite um sinal cooperativo.

Diagrama mostra um cliente desconectado acionando um AbortSignal que encerra um stream LLM e executa a limpeza.

Regra curta

  • Parar de aguardar uma Promise não é o mesmo que abortar o I/O que ela espera.
  • O fechamento do response precisa chegar ao AbortSignal do upstream.
  • Uma resposta concluída normalmente não deve disparar cancelamento nem deixar listeners pendurados.
  • Cancelar a espera não desfaz um efeito externo que já foi aceito.

O que precisa ser cancelado?

Há três camadas diferentes em um endpoint de streaming:

  1. a conexão entre cliente e seu servidor;
  2. a chamada do servidor ao modelo, normalmente um stream ou uma requisição HTTP;
  3. qualquer tool, consulta ou worker que o agente tenha iniciado antes do cliente sair.

Um res.end() ou um return no handler resolve somente a primeira camada. Se a chamada do modelo continuar viva, o processo ainda consome recursos. Se uma tool já publicou uma mensagem ou gravou um registro, nenhum AbortSignal consegue garantir que o efeito desapareceu.

O runtime precisa propagar o cancelamento de forma cooperativa: o evento de desconexão aborta o sinal, e cada operação que pode esperar I/O observa esse sinal. O guia de progresso de agentes na interface trata do contrato dos eventos enviados ao navegador. A decisão de parar o trabalho deve ficar no runtime, antes do evento de encerramento chegar à UI.

Converta o fechamento HTTP em AbortSignal

O servidor deve criar um controller por requisição. Em Express, uma combinação útil é observar close no response e conferir writableEnded: close também participa do caminho normal de encerramento, então não se deve abortar uma resposta que já terminou. O exemplo oficial do OpenAI também observa o request abortado e limpa os listeners no final.

import type { Request, Response } from "express";

type Disconnect = {
  signal: AbortSignal;
  cleanup: () => void;
};

export function watchClientDisconnect(
  req: Request,
  res: Response,
): Disconnect {
  const controller = new AbortController();

  const abort = (reason: string) => {
    if (!controller.signal.aborted) {
      controller.abort(new Error(reason));
    }
  };

  const onRequestAborted = () => abort("request_aborted");
  const onResponseClose = () => {
    if (!res.writableEnded) abort("client_disconnected");
  };

  // The request event covers an aborted incoming body.
  req.once("aborted", onRequestAborted);
  // The response event covers a client leaving during the stream.
  res.once("close", onResponseClose);

  return {
    signal: controller.signal,
    cleanup() {
      req.off("aborted", onRequestAborted);
      res.off("close", onResponseClose);
    },
  };
}

O manual do Node.js marca o evento aborted do IncomingMessage como deprecated em versões atuais e recomenda o caminho de close para o request. Frameworks e adaptadores podem expor nomes diferentes. Por isso, encapsule o detalhe em uma função pequena e confira o contrato do runtime que realmente executa o endpoint.

O cleanup não é um detalhe cosmético. Sem ele, cada requisição concluída pode deixar uma função ligada a um objeto de resposta. O listener também não deve abortar a chamada depois que o servidor já executou res.end() com sucesso.

Passe o sinal para o stream do LLM

O controller só tem efeito se o adapter receber o sinal. No SDK oficial do OpenAI, o sinal entra nas opções da requisição. Em um cliente baseado em fetch, ele entra no RequestInit. O nome da opção muda por biblioteca, mas a fronteira é a mesma.

import OpenAI from "openai";

const openai = new OpenAI();

async function streamAnswer(
  prompt: string,
  signal: AbortSignal,
): Promise<AsyncIterable<{ delta: string }>> {
  const stream = await openai.chat.completions.create(
    {
      model: "gpt-5.5",
      stream: true,
      messages: [{ role: "user", content: prompt }],
    },
    { signal },
  );

  return (async function* () {
    for await (const chunk of stream) {
      const delta = chunk.choices[0]?.delta?.content;
      if (delta) yield { delta };
    }
  })();
}

O tipo do stream varia conforme o SDK e a versão instalada, portanto o cast acima é apenas uma borda ilustrativa. Em código de produção, use o tipo exportado pela biblioteca e trate o erro de abort como um caminho esperado. Não transforme cancelamento do cliente em erro de aplicação nem inicie um retry automático só porque a Promise rejeitou.

Um handler mínimo precisa checar o sinal antes de iniciar a chamada, durante a iteração e antes de escrever no response:

app.post("/api/chat", async (req, res, next) => {
  const disconnect = watchClientDisconnect(req, res);

  try {
    if (disconnect.signal.aborted || res.destroyed) return;

    const stream = await streamAnswer(req.body.prompt, disconnect.signal);
    res.setHeader("Content-Type", "text/plain; charset=utf-8");

    for await (const chunk of stream) {
      if (disconnect.signal.aborted || res.destroyed) break;
      res.write(chunk.delta);
    }

    if (!disconnect.signal.aborted && !res.destroyed) res.end();
  } catch (error) {
    if (disconnect.signal.aborted) return;
    next(error);
  } finally {
    disconnect.cleanup();
  }
});

O exemplo de streaming do OpenAI Node usa a mesma ideia: observar a desconexão, passar o sinal ao stream, evitar novas escritas e limpar os listeners. A implementação real precisa ainda tratar headers já enviados e o formato de erro do framework.

Combine desconexão com deadline

O cliente não é a única razão para interromper o run. Um deadline do servidor protege o endpoint quando o cliente fica conectado, mas o upstream não progride. O Node.js oferece AbortSignal.timeout() e AbortSignal.any() para combinar os motivos:

function withDeadline(clientSignal: AbortSignal, totalMs: number) {
  const deadline = AbortSignal.timeout(totalMs);
  return AbortSignal.any([clientSignal, deadline]);
}

const signal = withDeadline(disconnect.signal, 30_000);
const stream = await streamAnswer(prompt, signal);

O manual de globals do Node.js explica que AbortSignal.any() aborta quando qualquer sinal da lista aborta e preserva a razão que causou o encerramento. Registre essa razão como client_disconnected, deadline ou server_shutdown. Uma mensagem genérica como request failed perde a informação que decide se o run pode ser retomado.

Para um agente que chama tools depois de cada resposta do modelo, passe o mesmo sinal a cada etapa. O guia sobre pausar e retomar um agente ajuda quando a intenção é continuar o trabalho depois, em vez de cancelar o run conectado à interface.

Não crie um retry fantasma

Um abort pode aparecer como rejeição do SDK, erro de stream ou APIUserAbortError. A classificação deve acontecer antes da política de retry:

Situação Estado do endpoint Próxima ação
Cliente saiu antes do primeiro token cancelled_by_client Encerrar o stream e não repetir automaticamente
Cliente saiu durante o stream cancelled_by_client Abortar o upstream e registrar a etapa interrompida
Deadline venceu com cliente conectado timed_out Encerrar ou transferir para execução durável, conforme o produto
Provider respondeu 5xx antes de efeito externo upstream_failed Retry limitado somente se a política permitir
Tool de escrita foi interrompida sem confirmação effect_uncertain Consultar o estado externo antes de repetir

O OpenAI Node SDK documenta cancelamento por AbortSignal e retries para falhas transitórias. O sinal deve cobrir também o período de espera de um retry, e a camada de retry deve reconhecer que o cliente não quer mais aquela resposta. Cancelar o stream não é uma autorização para executar a operação de novo em segundo plano.

Se o produto precisa continuar mesmo quando a aba fecha, não ligue esse trabalho à conexão HTTP. Grave um run durável, devolva um identificador e permita que outra conexão consulte o estado. Cancelamento de uma resposta interativa e execução assíncrona são contratos diferentes.

Verifique que o upstream realmente parou

O teste mais fraco chama controller.abort() antes de iniciar o modelo. Isso prova somente que o sinal já chega abortado. O teste útil inicia uma operação pendente, simula o fechamento do response, e verifica que a operação recebeu o abort antes de uma nova etapa começar.

function pendingRequest(signal: AbortSignal): Promise<never> {
  return new Promise((_, reject) => {
    const stop = () => reject(signal.reason ?? new Error("aborted"));

    if (signal.aborted) {
      stop();
      return;
    }

    signal.addEventListener("abort", stop, { once: true });
  });
}

const controller = new AbortController();
const upstream = pendingRequest(controller.signal);

controller.abort(new Error("client_disconnected"));

await expect(upstream).rejects.toThrow("client_disconnected");

Na suíte do endpoint, cubra também estes casos:

  • uma resposta concluída chama cleanup e não aborta o próximo trabalho;
  • um cliente que sai durante o primeiro chunk não recebe novas escritas;
  • o deadline produz timed_out, não cancelled_by_client;
  • um erro do provider sem desconexão chega ao middleware de erro;
  • uma tool de escrita interrompida fica marcada como incerta, sem retry cego.

Adicione um runId, a razão de parada, o nome da etapa e o estado do efeito ao log. Não grave prompt, token ou argumento sensível apenas para provar que o cancelamento funcionou. O guia de validação de tool calls em TypeScript mostra uma fronteira semelhante: o valor do teste está em observar a decisão e o efeito, não a exceção isolada.

Quando não cancelar é a escolha correta?

Nem todo fechamento de conexão significa que o trabalho perdeu valor. Um relatório longo, uma migração ou uma tarefa iniciada por aprovação pode continuar em um worker. Nesse caso, a conexão que entrega progresso deve ser separada do run persistido. O guia de execução durável para agentes trata dessa escolha entre fila e workflow.

Também não prometa que o cancelamento desfaz o passado. Se o LLM já produziu uma tool call e o servidor a encaminhou, o sinal pode interromper a espera, mas não pode retirar uma mensagem de um broker ou apagar uma alteração confirmada. Para esses casos, use idempotência, consulta de estado e compensação explícita.

Em fluxos longos de agente, uso o RemoteCode como ferramenta do autor para manter continuidade entre sessões. Isso não substitui o sinal de cancelamento, o deadline ou a prova de que uma operação externa terminou.

Conclusão

O caminho completo é curto, mas precisa fechar o circuito: detectar a desconexão, abortar o sinal, passá-lo ao stream do LLM, parar novas escritas, limpar listeners e registrar a razão. Se qualquer elo ignorar o sinal, o servidor pode continuar trabalhando depois que o usuário foi embora.

Comece com um adapter isolado e uma fixture que fica pendente até o sinal abortar. Depois teste o endpoint atrás do proxy que realmente usa. O resultado que importa é o runtime conseguir provar que a interface parou de mostrar tokens, que nenhuma etapa nova começou e que qualquer efeito externo incerto foi tratado como incerto.

Fontes consultadas

  • Node.js, “HTTP”, consultado em 2026-11-12, https://nodejs.org/api/http.html
  • Node.js, “Global objects: AbortController and AbortSignal”, consultado em 2026-11-12, https://nodejs.org/api/globals.html
  • OpenAI, “openai-node”, consultado em 2026-11-12, https://github.com/openai/openai-node
  • OpenAI, “Stream to client with Express”, consultado em 2026-11-12, https://github.com/openai/openai-node/blob/main/examples/chat-completions/stream-to-client-express.ts