Skip to content

Workflows assíncronos confiáveis em várias etapas com TypeScript

Introdução

Salvar um registro é fácil quando existe apenas uma requisição.

Um usuário pode criar um rascunho, enviar vários arquivos, sincronizar seus metadados e finalmente publicar o registro. Cada passo pode levar um tempo diferente e falhar de uma maneira diferente. A interface ainda precisa mostrar um progresso útil, a rede precisa de uma estratégia de cancelamento e o suporte precisa saber até onde a operação chegou.

Colocar todas as promises dentro de um grande try/catch esconde esses detalhes. Uma mensagem genérica como "Falha ao salvar" não informa se nenhum dado remoto existe, se há um rascunho sem arquivos ou se todos os arquivos foram enviados, mas a finalização falhou.

Neste artigo, vamos criar um pequeno executor de etapas em TypeScript que oferece a um workflow de vários passos:

  • eventos de progresso nomeados
  • erros de timeout específicos por etapa
  • comportamento abortável e não abortável explícito
  • um registro das etapas concluídas
  • injeção de dependências para testes focados
  • testes determinísticos de timeout sem esperar pelo relógio real

O exemplo usa um fluxo genérico de registro e arquivos. O mesmo padrão serve para checkout, onboarding, geração de relatórios, importações, processamento de mídia e qualquer operação que atravesse vários limites assíncronos.

Modele o workflow antes de executá-lo

Comece nomeando as etapas. Uma união de strings é simples, fácil de pesquisar e difícil de digitar incorretamente:

ts
export type SaveStage =
  | 'create_record'
  | 'upload_files'
  | 'finalize_record';

export interface StageCallbacks<
  S extends string,
  P extends object = Record<string, never>,
> {
  onStageStart?: (stage: S) => void;
  onStageComplete?: (stage: S) => void;
  onStageError?: (
    stage: S,
    error: unknown,
    partialOutputs: Readonly<P>,
  ) => void;
}

Esses eventos são mais úteis do que uma porcentagem numérica. A interface pode traduzir upload_files como "Enviando anexos", o analytics pode medir a duração por etapa e os logs podem identificar o limite que falhou.

Crie uma primitiva de timeout com semântica honesta

Existem dois significados diferentes por trás de "esta operação atingiu o timeout":

  1. Parar de esperar: seu código rejeita após um prazo, mas o trabalho subjacente pode continuar.
  2. Solicitar cancelamento: seu código rejeita e também sinaliza que a operação subjacente deve parar.

Promises do JavaScript não têm cancelamento nativo. Promise.race() pode fazer o caller parar de esperar, mas não consegue interromper a promise perdedora. AbortController só ajuda quando a API subjacente realmente observa seu signal.

O helper a seguir permite os dois comportamentos e mantém a diferença explícita:

ts
export type ScheduleTimeout = (
  callback: () => void,
  timeoutMs: number,
) => () => void;

export const systemScheduleTimeout: ScheduleTimeout = (callback, timeoutMs) => {
  const timeoutId = setTimeout(callback, timeoutMs);
  return () => clearTimeout(timeoutId);
};

export class StageTimeoutError<S extends string> extends Error {
  constructor(
    public readonly stage: S,
    public readonly timeoutMs: number,
  ) {
    super(`Stage ${stage} timed out after ${timeoutMs}ms`);
    this.name = 'StageTimeoutError';
  }
}

export async function withStageTimeout<T, S extends string>(params: {
  stage: S;
  timeoutMs: number;
  abortable: boolean;
  run: (signal?: AbortSignal) => Promise<T>;
  scheduleTimeout?: ScheduleTimeout;
}): Promise<T> {
  const {
    stage,
    timeoutMs,
    abortable,
    run,
    scheduleTimeout = systemScheduleTimeout,
  } = params;

  const controller = abortable && typeof AbortController === 'function'
    ? new AbortController()
    : null;

  let rejectTimeout: (error: StageTimeoutError<S>) => void = () => {};
  const timeoutPromise = new Promise<never>((_resolve, reject) => {
    rejectTimeout = reject;
  });

  const cancelTimeout = scheduleTimeout(() => {
    // Rejeite primeiro para que o caller receba o erro de timeout mesmo se
    // abortar fizer a promise da operação rejeitar imediatamente.
    rejectTimeout(new StageTimeoutError(stage, timeoutMs));
    controller?.abort();
  }, timeoutMs);

  try {
    return await Promise.race([
      run(controller?.signal),
      timeoutPromise,
    ]);
  } finally {
    cancelTimeout();
  }
}

Para uma etapa abortável, passe o signal até uma API como fetch:

ts
run: (signal) => fetch('/records', {
  method: 'POST',
  body: JSON.stringify(input),
  signal,
})

Não marque uma etapa como abortável apenas porque o executor consegue criar um signal. Se a operação ignorar esse signal, o timeout apenas para de esperar. O trabalho ainda pode terminar e produzir um efeito colateral mais tarde.

Timeout não é rollback

Um timeout informa que o caller parou de esperar. Ele não prova que uma escrita remota falhou. Use chaves de idempotência ou uma leitura de reconciliação antes de repetir uma etapa que pode ter chegado ao servidor.

Envolva cada etapa e preserve a conclusão parcial

O helper de timeout controla uma promise. O executor de etapas adiciona eventos de ciclo de vida e contexto da falha:

ts
export class StagedWorkflowError<
  S extends string,
  P extends object,
> extends Error {
  constructor(
    public readonly failedStage: S,
    public readonly completedStages: readonly S[],
    public readonly partialOutputs: Readonly<P>,
    public readonly cause: unknown,
  ) {
    super(`Workflow failed during ${failedStage}`);
    this.name = 'StagedWorkflowError';
  }
}

export async function runStage<
  T,
  S extends string,
  P extends object,
>(params: {
  stage: S;
  completedStages: S[];
  partialOutputs: Readonly<P>;
  timeoutMs: number;
  abortable?: boolean;
  run: (signal?: AbortSignal) => Promise<T>;
  callbacks?: StageCallbacks<S, P>;
  scheduleTimeout?: ScheduleTimeout;
}): Promise<T> {
  const {
    stage,
    completedStages,
    partialOutputs,
    timeoutMs,
    abortable = false,
    run,
    callbacks = {},
    scheduleTimeout,
  } = params;

  callbacks.onStageStart?.(stage);

  try {
    const result = await withStageTimeout({
      stage,
      timeoutMs,
      abortable,
      run,
      scheduleTimeout,
    });

    completedStages.push(stage);
    callbacks.onStageComplete?.(stage);
    return result;
  } catch (error) {
    const outputSnapshot = Object.freeze({ ...partialOutputs });
    callbacks.onStageError?.(stage, error, outputSnapshot);
    throw new StagedWorkflowError(
      stage,
      [...completedStages],
      outputSnapshot,
      error,
    );
  }
}

Uma etapa é registrada somente depois de ser resolvida. Se upload_files falhar, o erro ainda poderá mostrar que create_record foi concluída e expor o rascunho criado nessa etapa. Copiar os arrays e o objeto de resultados parciais impede que mutações posteriores no nível superior alterem o estado histórico da falha.

Os callbacks de ciclo de vida devem ser leves. Se um callback de progresso puder lançar uma exceção, isole esse comportamento dentro do callback para que um problema de renderização ou analytics não transforme uma operação remota bem-sucedida em uma falha de salvamento.

Componha o fluxo real de salvamento

A injeção de dependências mantém o orquestrador independente de um cliente HTTP, banco de dados ou SDK de armazenamento específico:

ts
interface UploadedFile {
  key: string;
  url: string;
}

interface FileToUpload {
  name: string;
  bytes: Uint8Array;
}

export interface SavePartialOutputs {
  readonly record?: { readonly id: string };
  readonly uploadedFiles?: readonly UploadedFile[];
}

interface SaveDependencies {
  createDraft: (
    input: { title: string },
    signal?: AbortSignal,
  ) => Promise<{ id: string }>;
  uploadFiles: (
    recordId: string,
    files: FileToUpload[],
  ) => Promise<UploadedFile[]>;
  finalizeRecord: (
    recordId: string,
    files: UploadedFile[],
    signal?: AbortSignal,
  ) => Promise<void>;
}

export async function saveRecordWithFiles(params: {
  title: string;
  files: FileToUpload[];
  dependencies: SaveDependencies;
  callbacks?: StageCallbacks<SaveStage, SavePartialOutputs>;
  timeoutMs?: number;
}) {
  const {
    title,
    files,
    dependencies,
    callbacks,
    timeoutMs = 20_000,
  } = params;

  const completedStages: SaveStage[] = [];
  const initialOutputs: SavePartialOutputs = {};

  const record = await runStage({
    stage: 'create_record',
    completedStages,
    partialOutputs: initialOutputs,
    timeoutMs,
    abortable: true,
    callbacks,
    run: (signal) => dependencies.createDraft({ title }, signal),
  });

  const createdOutputs: SavePartialOutputs = { record };
  const uploadedFiles = files.length > 0
    ? await runStage({
        stage: 'upload_files',
        completedStages,
        partialOutputs: createdOutputs,
        timeoutMs,
        callbacks,
        run: () => dependencies.uploadFiles(record.id, files),
      })
    : [];

  const uploadedOutputs: SavePartialOutputs = {
    record,
    uploadedFiles: [...uploadedFiles],
  };

  await runStage({
    stage: 'finalize_record',
    completedStages,
    partialOutputs: uploadedOutputs,
    timeoutMs,
    abortable: true,
    callbacks,
    run: (signal) => dependencies.finalizeRecord(
      record.id,
      uploadedFiles,
      signal,
    ),
  });

  return { record, uploadedFiles, completedStages };
}

Aqui as etapas de rede são abortáveis porque suas dependências aceitam um signal. O adaptador de upload é intencionalmente marcado como não abortável. Seu timeout encerra a espera da interface, mas o adaptador pode continuar. Se seu SDK de armazenamento oferecer cancelamento, altere o contrato da dependência para aceitar um signal e ative essa opção.

Esse fluxo é sequencial porque as etapas posteriores precisam dos resultados anteriores. Uploads independentes ainda podem ser executados em paralelo dentro de uploadFiles, onde limites de concorrência e erros por arquivo podem ser administrados sem dificultar o entendimento do workflow principal.

Projete o estado de falha, não apenas o de sucesso

A lista de etapas concluídas é um estado operacional, não um mecanismo de rollback.

Suponha que a finalização falhe depois que o rascunho e os uploads forem concluídos. O erro e o callback recebem um snapshot tipado de partialOutputs, então o aplicativo pode manter o ID do registro e o contexto dos arquivos enviados para uma nova tentativa direcionada.

Os resultados parciais podem conter URLs assinadas ou outros dados sensíveis. Não serialize o erro completo diretamente. Um log de suporte sanitizado pode conter:

json
{
  "failedStage": "finalize_record",
  "completedStages": ["create_record", "upload_files"],
  "recordId": "record-1",
  "uploadedFileKeys": ["report.txt"]
}

Isso permite agir muito melhor do que um stack trace isolado.

Para workflows de produção, combine as etapas com proteções do servidor:

  • use uma chave de idempotência ao criar um registro
  • crie registros em um estado draft ou processing
  • torne a finalização segura para repetição
  • mantenha chaves de arquivo estáveis em vez de gerar novas a cada tentativa
  • adicione limpeza ou reconciliação para rascunhos abandonados e uploads órfãos
  • repita apenas etapas cujos efeitos colaterais sejam compreendidos

Tentativas automáticas são perigosas para operações não idempotentes. Se um timeout ocorrer depois que o servidor confirmou uma escrita, repetir a requisição sem verificar pode criar uma duplicata. Primeiro consulte pela chave de idempotência ou pelo ID estável do registro.

Transforme eventos de etapa em estado útil para a interface

Os callbacks criam um limite claro entre orquestração e apresentação. Uma tela não precisa entender as promises do workflow; ela só precisa da etapa ativa e do resultado final:

ts
let activeStage: SaveStage | null = null;
let lastPartialOutputs: Readonly<SavePartialOutputs> = {};

const callbacks: StageCallbacks<SaveStage, SavePartialOutputs> = {
  onStageStart: (stage) => {
    activeStage = stage;
    renderProgress(stage);
  },
  onStageComplete: () => {
    activeStage = null;
  },
  onStageError: (stage, error, partialOutputs) => {
    activeStage = null;
    lastPartialOutputs = partialOutputs;
    reportSaveFailure({
      stage,
      error,
      recordId: partialOutputs.record?.id,
      uploadedFileKeys: partialOutputs.uploadedFiles?.map(
        (file) => file.key,
      ),
    });
  },
};

Se a finalização falhar, lastPartialOutputs.record?.id e lastPartialOutputs.uploadedFiles estarão disponíveis para a interface de nova tentativa. Em um timeout de upload não abortável, o snapshot contém intencionalmente apenas o registro: o upload ainda em execução não produziu um resultado confirmado.

Use os nomes das etapas como estado, não como texto exibido diretamente. Uma camada de apresentação pode mapear create_record para "Preparando seu registro" e upload_files para "Enviando anexos" e localizar esses textos normalmente.

Evite prometer uma porcentagem precisa se o trabalho de cada etapa não puder ser medido. Um spinner com uma descrição honesta costuma ser melhor do que um progresso que salta de 10% para 90% ou fica parado perto do fim. Também bloqueie envios duplicados enquanto o workflow estiver ativo, mas ofereça um botão de cancelamento apenas quando cancelar tiver uma semântica definida. Fechar um modal não é o mesmo que interromper o trabalho remoto.

Teste a ordem e a conclusão parcial

As dependências injetadas transformam o caminho feliz em um teste puro de orquestração:

ts
import assert from 'node:assert/strict';
import test from 'node:test';

test('runs stages in order and reports completed work', async () => {
  const calls: string[] = [];

  const result = await saveRecordWithFiles({
    title: 'Quarterly report',
    files: [{
      name: 'report.txt',
      bytes: new TextEncoder().encode('data'),
    }],
    dependencies: {
      createDraft: async () => {
        calls.push('create');
        return { id: 'record-1' };
      },
      uploadFiles: async () => {
        calls.push('upload');
        return [{ key: 'report.txt', url: '/files/report.txt' }];
      },
      finalizeRecord: async () => {
        calls.push('finalize');
      },
    },
  });

  assert.deepEqual(calls, ['create', 'upload', 'finalize']);
  assert.deepEqual(result.completedStages, [
    'create_record',
    'upload_files',
    'finalize_record',
  ]);
});

Teste o contrato de recuperação diretamente fazendo a finalização falhar depois que as etapas anteriores forem concluídas:

ts
test('exposes partial outputs when finalization fails', async () => {
  const expectedOutputs: SavePartialOutputs = {
    record: { id: 'record-1' },
    uploadedFiles: [{
      key: 'report.txt',
      url: '/files/report.txt',
    }],
  };
  let callbackOutputs: Readonly<SavePartialOutputs> | undefined;

  const promise = saveRecordWithFiles({
    title: 'Quarterly report',
    files: [{
      name: 'report.txt',
      bytes: new TextEncoder().encode('data'),
    }],
    callbacks: {
      onStageError: (_stage, _error, partialOutputs) => {
        callbackOutputs = partialOutputs;
      },
    },
    dependencies: {
      createDraft: async () => ({ id: 'record-1' }),
      uploadFiles: async () => [{
        key: 'report.txt',
        url: '/files/report.txt',
      }],
      finalizeRecord: async () => {
        throw new Error('finalization failed');
      },
    },
  });

  await assert.rejects(promise, (error: unknown) => {
    assert.ok(error instanceof StagedWorkflowError);
    assert.equal(error.failedStage, 'finalize_record');
    assert.deepEqual(error.completedStages, [
      'create_record',
      'upload_files',
    ]);
    assert.deepEqual(error.partialOutputs, expectedOutputs);
    return true;
  });

  assert.deepEqual(callbackOutputs, expectedOutputs);
});

Adicione um teste complementar no qual uploadFiles rejeite. Verifique se failedStage é upload_files, se completedStages contém apenas create_record, se partialOutputs contém o registro mas nenhum arquivo enviado e se finalizeRecord nunca é executada.

Teste timeouts sem usar sleep

Testes que esperam 10 ou 50 milissegundos ainda podem ser instáveis em um runner de CI ocupado. Como o agendador do timeout é injetável, o teste pode dispará-lo manualmente:

ts
test('aborts an abortable stage and keeps timeout context', async () => {
  let fireTimeout: () => void = () => {};
  let wasAborted = false;

  const scheduleTimeout: ScheduleTimeout = (callback) => {
    fireTimeout = callback;
    return () => {};
  };

  const promise = runStage({
    stage: 'create_record' as SaveStage,
    completedStages: [],
    partialOutputs: {},
    timeoutMs: 20_000,
    abortable: true,
    scheduleTimeout,
    run: (signal) => new Promise((_resolve, reject) => {
      signal?.addEventListener('abort', () => {
        wasAborted = true;
        reject(new Error('request aborted'));
      });
    }),
  });

  fireTimeout();

  await assert.rejects(promise, (error: unknown) => {
    assert.ok(error instanceof StagedWorkflowError);
    assert.equal(error.failedStage, 'create_record');
    assert.deepEqual(error.completedStages, []);
    assert.ok(error.cause instanceof StageTimeoutError);
    return true;
  });

  assert.equal(wasAborted, true);
});

Nenhum relógio real avança, então o teste é rápido e determinístico. Um teste complementar para uma etapa não abortável deve confirmar que o caller recebe um timeout e documentar que o trabalho pendente não foi cancelado.

Conclusão

Um workflow confiável de várias etapas deixa seus limites visíveis. Etapas nomeadas explicam o progresso, erros de timeout identificam a operação parada, AbortSignal expressa cancelamento quando a dependência oferece suporte e o histórico de etapas concluídas junto com resultados parciais tipados torna uma falha parcial acionável.

O executor é pequeno de propósito. O trabalho de design importante está ao redor dele: semântica honesta de cancelamento, operações idempotentes no servidor, regras deliberadas de repetição e testes determinísticos. Com essas peças, um fluxo de salvamento complicado se torna uma sequência que você pode observar, explicar e recuperar.