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.
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
AbortSignaldo 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:
- a conexão entre cliente e seu servidor;
- a chamada do servidor ao modelo, normalmente um stream ou uma requisição HTTP;
- 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
cleanupe 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ãocancelled_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