Blog

Upload grande que falha nos 99%: envio retomável em partes com URL pré-assinada

Uma plataforma de cursos online tinha um painel onde os instrutores enviavam as videoaulas, arquivos de 2 a 6 GB gravados em casa. Durante meses, o suporte recebeu o mesmo chamado com palavras diferentes: o envio travou em 99%. Quando o time finalmente mediu, 31% das tentativas acima de 2 GB falhavam, a média era de 2,4 tentativas por aula publicada e um instrutor tinha tentado nove vezes o mesmo arquivo em um sábado à noite, cada vez recomeçando do zero. O código não tinha nenhum erro aparente: um formulário com campo de arquivo, um endpoint com multer gravando em disco e um PutObject para o S3 no final. Funcionava perfeitamente em homologação, com vídeos de 50 MB na rede do escritório. O defeito era de arquitetura: o arquivo inteiro atravessava a API em uma única requisição, e qualquer interrupção, em qualquer ponto de um envio de quarenta minutos, jogava tudo fora. Este artigo explica por que o upload grande falha justamente no fim, como tirar a API do caminho dos bytes com URLs pré-assinadas, como dividir o envio em partes com o multipart upload do S3, como retomar de onde parou depois de uma queda de rede ou de uma aba fechada, quais detalhes quebram essa solução em produção e como provar com um teste automatizado que a retomada realmente funciona.

2026-10-02 / Arquitetura / 17 min

01

Por que o upload grande falha justamente nos 99%

A versão que quase todo sistema começa usando é esta: o navegador envia o arquivo em um POST multipart/form-data, a API recebe, grava em disco e depois manda para o armazenamento de objetos. Para arquivos pequenos, é simples e suficiente. Para arquivos grandes, ela concentra três problemas no mesmo lugar.

// Versao que falha nos 99%: o arquivo inteiro atravessa a API
import multer from 'multer';
import { createReadStream } from 'node:fs';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({});
const upload = multer({ dest: '/tmp/uploads', limits: { fileSize: 8 * 1024 ** 3 } });

app.post('/aulas/:id/video', upload.single('video'), async (req, res) => {
  // Quando esta linha roda, o navegador ja enviou 100% dos bytes e a barra
  // de progresso marca 99%. Agora a API precisa reenviar os mesmos 4 GB ao S3
  // antes de responder, e o balanceador corta a conexao ociosa em 60 segundos.
  await s3.send(new PutObjectCommand({
    Bucket: process.env.BUCKET,
    Key: `aulas/${req.params.id}.mp4`,
    Body: createReadStream(req.file.path),
    ContentLength: req.file.size,
  }));
  res.json({ ok: true });
});

O primeiro é o que explica os 99%. A barra de progresso do navegador, alimentada pelo evento upload.onprogress, mede bytes entregues à pilha de rede, não bytes processados pelo servidor. Ela chega ao fim quando o último byte sai da máquina do usuário, mas a resposta só vem depois que a API reenvia os mesmos gigabytes ao S3. Nesse intervalo, a conexão entre o navegador e o balanceador fica parada, sem tráfego em nenhum sentido. Na plataforma de cursos, o balanceador tinha timeout de ociosidade de 60 segundos e reenviar 4 GB ao S3 levava de dois a quatro minutos. O usuário via 99%, depois um erro, e o vídeo às vezes até chegava ao bucket, mas sem registro no banco, porque a requisição tinha sido cortada antes do final do handler.

O segundo é a probabilidade. Um envio único precisa que a conexão sobreviva do primeiro ao último byte. Se a chance de uma interrupção em um minuto qualquer é pequena, como uma troca de Wi-Fi, um notebook que hiberna ou um proxy corporativo que derruba conexões longas, ela se acumula com a duração. A tabela usa uma chance de 2% de interrupção por minuto e uma conexão de upload de 5 Mbit/s, valores comuns em internet residencial.

ArquivoTempo de envio a 5 Mbit/sChance de terminar em um envio únicoPerda máxima por queda com partes de 8 MiB e 4 em paralelo
100 MBCerca de 3 minutos95%32 MiB
1 GBCerca de 27 minutos58%32 MiB
2 GBCerca de 55 minutos33%32 MiB
5 GBCerca de 2 horas e 16 minutos6%32 MiB

O terceiro é o custo da falha. Sem retomada, cada tentativa recomeça do byte zero, e quem mais precisa tentar de novo é justamente quem tem a rede mais instável e o arquivo maior. Além disso, a API passa a carregar o peso de todos os uploads: conexões abertas por dezenas de minutos, disco temporário que enche e banda de saída paga duas vezes, uma para receber e outra para reenviar. A correção não é aumentar timeouts. É mudar a forma do envio em dois movimentos: tirar a API do caminho dos bytes e dividir o arquivo em partes que podem falhar e ser reenviadas de forma independente.

02

Tirar a API do caminho dos bytes com URL pré-assinada

Uma URL pré-assinada é uma autorização temporária para uma única operação em um único objeto, assinada com as credenciais do servidor. A API decide quem pode enviar o quê, gera a URL e devolve ao navegador, que faz o PUT direto no S3. A API continua sendo dona das regras, como autenticação, tamanho máximo, quota e nome do objeto, mas não toca em nenhum byte do arquivo. O balanceador e os pods da aplicação deixam de ver conexões de quarenta minutos.

Uma URL pré-assinada para um PutObject simples já resolve o problema dos 99% e tira a carga da API, mas ainda é um envio único: a queda no minuto 38 continua jogando tudo fora. O multipart upload resolve a outra metade. O arquivo é dividido em partes numeradas, cada parte é um PUT independente com sua própria URL assinada, e o S3 guarda as partes recebidas até que alguém mande concluir ou abortar. Uma parte que falha é reenviada sozinha, e partes diferentes podem subir em paralelo.

Antes: os bytes atravessam a API
  Navegador ──4 GB──> Balanceador ──4 GB──> API (disco) ──4 GB──> S3
                      timeout 60s ocioso    reenvia tudo antes de responder
  Uma queda em qualquer ponto = recomeçar do byte zero

Depois: a API só assina, os bytes vão direto ao armazenamento
  1. Navegador ──POST /uploads {tamanho}──────────> API ──CreateMultipartUpload──> S3
                <──{id, tamanhoParte, totalPartes}──
  2. Para cada parte (4 em paralelo):
       Navegador ──POST /uploads/:id/partes [n]──> API (assina, 15 min)
       Navegador ──PUT parte n (8 MiB)─────────────────────────────────────────> S3
  3. Caiu a rede ou fechou a aba?
       Navegador ──GET /uploads/:id/partes──> API ──ListParts──> S3
       reenvia só as partes que faltam
  4. Navegador ──POST /uploads/:id/concluir──> API ──ListParts + Complete + Head──> S3
       a API confere quantidade, soma dos tamanhos e tamanho final
Regra do multipart upload no S3ValorConsequência no desenho
Tamanho mínimo de parte5 MiB, exceto a últimaPartes pequenas demais são recusadas na conclusão, não no envio
Tamanho máximo de parte5 GiBNunca é o limite prático: partes grandes perdem a vantagem de retomar
Quantidade de partesDe 1 a 10.000O tamanho da parte precisa crescer com o arquivo
Partes por página no ListPartsAté 1.000A listagem precisa paginar para arquivos com mais de 1.000 partes
Partes de um upload não concluídoFicam armazenadas e são cobradasExige regra de ciclo de vida para abortar o que ficou para trás
Validade da URL assinadaAté 7 dias, limitada pela credencial que assinouCom credencial temporária de role, a URL morre junto com a sessão

O tamanho da parte é uma troca entre retrabalho e quantidade de requisições. Partes de 8 MiB significam que uma queda perde no máximo 8 MiB por envio em andamento, e um arquivo de 4 GB gera 512 PUTs. Partes de 100 MiB reduzem as requisições, mas cada falha em uma rede lenta custa minutos. Para envios de navegador, 8 a 16 MiB costuma ser o ponto de equilíbrio, aumentando apenas quando o arquivo passaria de 10.000 partes.

03

O servidor: sessão de upload, assinatura por parte e conclusão verificada

O servidor tem cinco responsabilidades: criar a sessão de upload e registrá-la no banco, assinar URLs para partes específicas, informar quais partes já chegaram, concluir conferindo o que está no S3 e abortar quando o usuário desiste. A sessão no banco guarda o UploadId do S3, a chave do objeto, o tamanho declarado e o tamanho de parte calculado, e é isso que permite retomar de outro dispositivo, de outra aba ou depois de dias.

import express from 'express';
import pg from 'pg';
import { randomUUID } from 'node:crypto';
import {
  S3Client,
  CreateMultipartUploadCommand,
  UploadPartCommand,
  ListPartsCommand,
  CompleteMultipartUploadCommand,
  AbortMultipartUploadCommand,
  HeadObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

// WHEN_REQUIRED evita que o SDK coloque na URL assinada um checksum calculado
// sobre um corpo vazio, o que faz o S3 recusar o PUT que vem do navegador.
const s3 = new S3Client({ requestChecksumCalculation: 'WHEN_REQUIRED' });
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const BUCKET = process.env.UPLOADS_BUCKET;

const MiB = 1024 * 1024;
const TAMANHO_MAX = 20 * 1024 * MiB; // 20 GiB, regra do produto
const MAX_PARTES = 10000; // limite do S3
const VALIDADE_URL = 15 * 60; // segundos

export function tamanhoDaParte(tamanhoTotal) {
  // 8 MiB por padrao; arquivos enormes pedem partes maiores para caber em
  // 10.000 partes. O S3 exige no minimo 5 MiB em todas, menos na ultima.
  const minimo = Math.ceil(tamanhoTotal / MAX_PARTES);
  return Math.max(8 * MiB, Math.ceil(minimo / MiB) * MiB);
}

const app = express(); // Express 5: erros de handlers async chegam ao middleware de erro
app.use(express.json());
// A autenticacao roda antes destas rotas e preenche req.user.

async function carregar(req, res) {
  const { rows } = await pool.query(
    "SELECT * FROM uploads WHERE id = $1 AND usuario_id = $2 AND status <> 'abortado'",
    [req.params.id, req.user.id],
  );
  if (!rows[0]) {
    res.sendStatus(404);
    return null;
  }
  return { ...rows[0], tamanho: Number(rows[0].tamanho) }; // bigint chega como string
}

// A fonte da verdade sobre o que ja foi enviado e o S3, nao o navegador.
async function partesEnviadas(upload) {
  const partes = [];
  let marcador;
  do {
    const r = await s3.send(new ListPartsCommand({
      Bucket: BUCKET,
      Key: upload.chave,
      UploadId: upload.upload_id,
      PartNumberMarker: marcador,
    }));
    for (const p of r.Parts ?? []) {
      partes.push({ numero: p.PartNumber, etag: p.ETag, tamanho: p.Size });
    }
    marcador = r.IsTruncated ? r.NextPartNumberMarker : undefined; // 1.000 por pagina
  } while (marcador);
  return partes;
}

app.post('/uploads', async (req, res) => {
  const tamanho = Number(req.body.tamanho);
  if (!Number.isSafeInteger(tamanho) || tamanho <= 0 || tamanho > TAMANHO_MAX) {
    return res.status(422).json({ erro: 'tamanho_invalido' });
  }
  // A chave e decidida pelo servidor: o nome do arquivo nunca vira caminho.
  const chave = `uploads/${req.user.id}/${randomUUID()}`;
  const { UploadId } = await s3.send(new CreateMultipartUploadCommand({
    Bucket: BUCKET,
    Key: chave,
    ContentType: 'application/octet-stream',
    Metadata: { 'nome-original': encodeURIComponent(String(req.body.nome).slice(0, 200)) },
  }));
  const tamanhoParte = tamanhoDaParte(tamanho);
  const { rows } = await pool.query(
    `INSERT INTO uploads (usuario_id, chave, upload_id, tamanho, tamanho_parte, status)
     VALUES ($1, $2, $3, $4, $5, 'enviando') RETURNING id`,
    [req.user.id, chave, UploadId, tamanho, tamanhoParte],
  );
  res.status(201).json({
    id: rows[0].id,
    tamanhoParte,
    totalPartes: Math.ceil(tamanho / tamanhoParte),
  });
});

// Assina so as partes pedidas agora, com validade curta.
app.post('/uploads/:id/partes', async (req, res) => {
  const upload = await carregar(req, res);
  if (!upload) return;
  const total = Math.ceil(upload.tamanho / upload.tamanho_parte);
  const numeros = [...new Set(req.body.numeros)].slice(0, 50);
  if (!numeros.every((n) => Number.isInteger(n) && n >= 1 && n <= total)) {
    return res.status(422).json({ erro: 'parte_invalida' });
  }
  const urls = {};
  for (const n of numeros) {
    urls[n] = await getSignedUrl(
      s3,
      new UploadPartCommand({ Bucket: BUCKET, Key: upload.chave, UploadId: upload.upload_id, PartNumber: n }),
      { expiresIn: VALIDADE_URL },
    );
  }
  res.json({ urls });
});

app.get('/uploads/:id/partes', async (req, res) => {
  const upload = await carregar(req, res);
  if (!upload) return;
  if (upload.status === 'concluido') return res.status(409).json({ erro: 'ja_concluido' });
  res.json(await partesEnviadas(upload));
});

app.post('/uploads/:id/concluir', async (req, res) => {
  const upload = await carregar(req, res);
  if (!upload) return;
  if (upload.status === 'concluido') return res.json({ chave: upload.chave }); // idempotente

  const partes = await partesEnviadas(upload);
  const total = Math.ceil(upload.tamanho / upload.tamanho_parte);
  const recebido = partes.reduce((soma, p) => soma + p.tamanho, 0);
  if (partes.length !== total || recebido !== upload.tamanho) {
    return res.status(409).json({ erro: 'partes_incompletas', recebidas: partes.map((p) => p.numero) });
  }

  await s3.send(new CompleteMultipartUploadCommand({
    Bucket: BUCKET,
    Key: upload.chave,
    UploadId: upload.upload_id,
    MultipartUpload: { Parts: partes.map((p) => ({ PartNumber: p.numero, ETag: p.etag })) },
  }));
  const { ContentLength } = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: upload.chave }));
  if (ContentLength !== upload.tamanho) throw new Error(`upload ${upload.id}: tamanho divergente`);

  await pool.query("UPDATE uploads SET status = 'concluido', concluido_em = now() WHERE id = $1", [upload.id]);
  // Daqui em diante, validacao de conteudo e processamento rodam em um job.
  res.json({ chave: upload.chave });
});

app.delete('/uploads/:id', async (req, res) => {
  const upload = await carregar(req, res);
  if (!upload) return;
  await s3.send(new AbortMultipartUploadCommand({
    Bucket: BUCKET,
    Key: upload.chave,
    UploadId: upload.upload_id,
  }));
  await pool.query("UPDATE uploads SET status = 'abortado' WHERE id = $1", [upload.id]);
  res.sendStatus(204);
});

Três decisões desse código importam mais do que parecem. A primeira é que a conclusão usa ListParts e não os ETags enviados pelo navegador. O cliente não é fonte confiável sobre o que foi armazenado, e a conferência de quantidade de partes e soma dos tamanhos garante que o objeto final tem exatamente o tamanho declarado na criação, o que impede que alguém crie uma sessão de 10 MB para passar pela quota e envie 10 GB. A segunda é que a conclusão é idempotente: se a resposta se perder no caminho e o navegador repetir a chamada, a segunda devolve o mesmo resultado em vez de falhar porque o UploadId já não existe.

A terceira é que as URLs são assinadas sob demanda, para poucas partes por vez e com validade de 15 minutos. Assinar todas as 512 partes na criação parece mais eficiente, mas produz URLs que expiram antes de serem usadas em conexões lentas e entrega ao navegador autorização para escrever por horas. A opção requestChecksumCalculation: WHEN_REQUIRED também merece atenção: versões recentes do AWS SDK para JavaScript passaram a incluir parâmetros de checksum nas URLs assinadas de UploadPart, calculados sobre um corpo vazio, e o S3 recusa o PUT do navegador com o conteúdo real. É o tipo de quebra que aparece depois de uma atualização de dependência, sem nenhuma mudança no seu código.

04

O cliente: enviar em partes, tentar de novo e retomar depois de fechar a aba

No navegador, File.slice cria uma referência para um intervalo do arquivo sem ler o conteúdo para a memória, então enviar uma parte de 8 MiB de um vídeo de 6 GB custa 8 MiB, não 6 GB. O cliente abaixo envia quatro partes em paralelo, tenta de novo cada parte com backoff exponencial e jitter, espera a rede voltar antes de insistir e guarda a sessão no localStorage para retomar depois de uma recarga.

const CONCORRENCIA = 4;
const TENTATIVAS = 8;

const espera = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const voltarOnline = () =>
  navigator.onLine ? Promise.resolve() : new Promise((r) => addEventListener('online', r, { once: true }));

async function api(caminho, { method = 'GET', body } = {}) {
  const r = await fetch(caminho, {
    method,
    headers: body ? { 'Content-Type': 'application/json' } : {},
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!r.ok) throw Object.assign(new Error(`${caminho}: HTTP ${r.status}`), { status: r.status });
  return r.json();
}

// O navegador nao reabre o arquivo sozinho depois de recarregar a pagina; o
// usuario escolhe de novo e esta impressao digital reencontra a sessao.
const chaveLocal = (arquivo) => `upload:${arquivo.name}:${arquivo.size}:${arquivo.lastModified}`;

async function enviarParte(arquivo, upload, numero) {
  const inicio = (numero - 1) * upload.tamanhoParte;
  const pedaco = arquivo.slice(inicio, inicio + upload.tamanhoParte); // nao le o arquivo inteiro

  for (let tentativa = 1; ; tentativa++) {
    let status = 0;
    try {
      await voltarOnline();
      // URL nova a cada tentativa: uma parte que demorou nao falha por URL expirada.
      const { urls } = await api(`/uploads/${upload.id}/partes`, { method: 'POST', body: { numeros: [numero] } });
      const r = await fetch(urls[numero], { method: 'PUT', body: pedaco });
      if (r.ok) return;
      status = r.status;
    } catch (erro) {
      status = erro.status ?? 0; // 0: queda de rede, troca de Wi-Fi para 4G, aba suspensa
    }
    const definitivo = status >= 400 && status < 500 && ![403, 408, 429].includes(status);
    if (definitivo || tentativa === TENTATIVAS) {
      throw new Error(`parte ${numero} falhou depois de ${tentativa} tentativas (HTTP ${status})`);
    }
    await espera(Math.random() * Math.min(30000, 1000 * 2 ** tentativa)); // backoff com jitter
  }
}

export async function enviarArquivo(arquivo, aoProgredir = () => {}) {
  const chave = chaveLocal(arquivo);
  let upload = JSON.parse(localStorage.getItem(chave) ?? 'null');
  let prontas = new Set();

  if (upload) {
    try {
      const partes = await api(`/uploads/${upload.id}/partes`);
      prontas = new Set(partes.map((p) => p.numero));
    } catch {
      upload = null; // sessao abortada, expirada ou de outro usuario: recomeca
    }
  }
  if (!upload) {
    upload = await api('/uploads', {
      method: 'POST',
      body: { nome: arquivo.name, tamanho: arquivo.size },
    });
    localStorage.setItem(chave, JSON.stringify(upload));
  }

  const pendentes = [];
  for (let n = 1; n <= upload.totalPartes; n++) if (!prontas.has(n)) pendentes.push(n);
  let concluidas = upload.totalPartes - pendentes.length;
  aoProgredir(concluidas / upload.totalPartes);

  const trabalhador = async () => {
    while (pendentes.length > 0) {
      const numero = pendentes.shift();
      await enviarParte(arquivo, upload, numero);
      aoProgredir(++concluidas / upload.totalPartes);
    }
  };
  await Promise.all(Array.from({ length: CONCORRENCIA }, trabalhador));

  const resultado = await api(`/uploads/${upload.id}/concluir`, { method: 'POST' });
  localStorage.removeItem(chave);
  return resultado;
}
  • A retomada pergunta ao servidor quais partes existem, em vez de confiar em um progresso salvo localmente. Uma parte pode ter chegado ao S3 com a resposta perdida no caminho, e um progresso local diria que ela falta; o ListParts diz a verdade.
  • Cada tentativa pede uma URL nova. Isso custa uma chamada leve à API por parte, mas elimina a classe inteira de falhas por URL expirada em redes lentas ou depois de o notebook hibernar no meio do envio.
  • Erros 4xx são definitivos, com exceção de 403, que aqui significa URL expirada ou relógio do cliente adiantado, 408 e 429. Tentar de novo um 400 ou um 413 só adia a mensagem de erro e esconde o defeito.
  • O jitter no backoff evita que milhares de navegadores que perderam a conexão ao mesmo tempo, por exemplo durante uma instabilidade do provedor, voltem todos no mesmo segundo.
  • A impressão digital usa nome, tamanho e data de modificação. Não é um hash do conteúdo, porque calcular SHA-256 de 6 GB no navegador antes de começar levaria minutos; é suficiente para reencontrar a sessão quando o usuário escolhe o mesmo arquivo de novo.

A concorrência de quatro partes não é arbitrária. Em conexões residenciais, a banda de upload costuma ser o gargalo, e mais conexões em paralelo apenas dividem a mesma banda, aumentando o retrabalho em caso de queda. Em redes corporativas rápidas, seis a oito partes em paralelo podem aproveitar melhor a banda. O ideal é medir o throughput das primeiras partes e ajustar, mas um valor fixo entre três e seis já resolve a maioria dos casos.

05

Os detalhes que quebram o upload em produção

O fluxo básico funciona no primeiro dia. Os problemas aparecem em semanas, em lugares que não estão no caminho feliz.

# CORS do bucket: o navegador faz PUT direto no S3.
# Como o servidor lista as partes, o cliente nao precisa ler o ETag e nao e
# necessario expor esse cabecalho.
aws s3api put-bucket-cors --bucket "$UPLOADS_BUCKET" --cors-configuration '{
  "CORSRules": [{
    "AllowedOrigins": ["https://app.exemplo.com.br"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3600
  }]
}'

# Partes de uploads nunca concluidos sao cobradas e nao aparecem na listagem
# de objetos. Esta regra apaga o que ficou para tras depois de 7 dias.
aws s3api put-bucket-lifecycle-configuration --bucket "$UPLOADS_BUCKET" --lifecycle-configuration '{
  "Rules": [{
    "ID": "abortar-multipart-incompleto",
    "Status": "Enabled",
    "Filter": { "Prefix": "uploads/" },
    "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
  }]
}'
  • CORS. O PUT sai do domínio da aplicação para o domínio do bucket, e sem regra de CORS o navegador bloqueia a requisição antes de enviar o corpo. A regra deve listar as origens reais da aplicação, não um asterisco. Se o cliente precisar ler o ETag da resposta, o cabeçalho tem que estar em ExposeHeaders; no desenho deste artigo isso não é necessário, porque o servidor lista as partes.
  • Partes órfãs custam dinheiro. Um upload abandonado no meio não aparece na listagem de objetos do bucket, mas as partes continuam armazenadas e cobradas indefinidamente. A regra de ciclo de vida AbortIncompleteMultipartUpload é obrigatória, e o prazo dela define por quanto tempo o usuário consegue retomar.
  • Relógio do cliente não importa, relógio do servidor sim. A assinatura usa o horário de quem assina. Um servidor com relógio atrasado gera URLs que o S3 considera expiradas ou ainda não válidas, e o sintoma é um 403 intermitente que só acontece em uma das instâncias.
  • Credencial temporária encurta a validade. Se a API roda com uma role, como em ECS, EKS ou Lambda, a URL assinada deixa de valer quando a credencial temporária expira, mesmo que o expiresIn seja maior. É mais um motivo para assinar sob demanda e com validade curta.
  • Concluir não significa validar. O objeto final pode ser qualquer coisa que o usuário quis enviar. Gere a chave no servidor, grave em um prefixo de quarentena, verifique tipo real pelos primeiros bytes e passe por antivírus em um job assíncrono antes de liberar o arquivo para outros usuários ou para processamento.
  • Integridade de ponta a ponta. O TLS protege o trânsito e o S3 compara o Content-MD5 quando ele é enviado, mas nada no fluxo básico prova que o arquivo final é idêntico ao do disco do usuário. Quando isso importa, como em arquivos fiscais ou dados de saúde, use checksums por parte com o algoritmo de checksum do multipart e compare no servidor.
  • Sessões presas no banco. Uploads com status enviando há mais tempo que o prazo da regra de ciclo de vida devem ser marcados como abortados por um job periódico, senão o painel mostra envios em andamento que já não existem no S3.

06

Como provar que a retomada funciona

Um teste que envia um arquivo de 1 MB e confere que ele chegou não prova nada sobre o problema original. O que precisa ser provado é que uma interrupção no meio do envio não obriga a recomeçar, que uma recarga da página reencontra a sessão e que o objeto final é idêntico ao arquivo original. O teste usa um MinIO local como S3 compatível, um arquivo de 200 MB com conteúdo aleatório e o modo offline do Playwright para derrubar a rede no meio.

import { test, expect } from '@playwright/test';
import { createHash, randomBytes } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';

// MinIO local como S3 compativel:
//   docker run -d -p 9000:9000 -e MINIO_ROOT_USER=dev -e MINIO_ROOT_PASSWORD=devdevdev \
//     minio/minio server /data
const ARQUIVO = 'tmp/video-200mb.bin';
const s3 = new S3Client({
  endpoint: 'http://localhost:9000',
  region: 'us-east-1',
  forcePathStyle: true,
  credentials: { accessKeyId: 'dev', secretAccessKey: 'devdevdev' },
});
const sha256 = (dados) => createHash('sha256').update(dados).digest('hex');

test.beforeAll(() => {
  mkdirSync('tmp', { recursive: true });
  writeFileSync(ARQUIVO, randomBytes(200 * 1024 * 1024)); // 25 partes de 8 MiB
});

test('retoma de onde parou depois de queda de rede e recarga da pagina', async ({ page, context }) => {
  await page.goto('/enviar');
  await page.setInputFiles('input[type=file]', ARQUIVO);
  await expect(page.getByTestId('progresso')).toHaveText(/[4-9]\d%/, { timeout: 120_000 });

  await context.setOffline(true); // a rede cai no meio do envio
  await page.waitForTimeout(3_000);
  await context.setOffline(false);
  await page.reload(); // o usuario fecha a aba e volta

  const partesReenviadas = new Set();
  page.on('request', (r) => {
    if (r.method() === 'PUT') partesReenviadas.add(new URL(r.url()).searchParams.get('partNumber'));
  });
  await page.setInputFiles('input[type=file]', ARQUIVO);
  await expect(page.getByTestId('status')).toHaveText('concluído', { timeout: 120_000 });

  // A retomada nao pode recomecar do zero
  expect(partesReenviadas.size).toBeLessThan(25);

  // E o objeto final tem que ser identico, byte a byte, ao arquivo original
  const chave = await page.getByTestId('chave').textContent();
  const objeto = await s3.send(new GetObjectCommand({ Bucket: 'uploads', Key: chave }));
  const hash = createHash('sha256');
  for await (const pedaco of objeto.Body) hash.update(pedaco);
  expect(hash.digest('hex')).toBe(sha256(readFileSync(ARQUIVO)));
});
CenárioComo simularResultado esperado
Queda de rede no meio do enviocontext.setOffline(true) por alguns segundosPartes em andamento são repetidas; nenhuma parte concluída é reenviada
Aba fechada e reabertapage.reload() e nova seleção do mesmo arquivoO cliente reencontra a sessão e envia só as partes que faltam
URL expiradaAssinar com expiresIn de 1 segundo no ambiente de teste403 seguido de nova assinatura e sucesso na tentativa seguinte
Resposta da conclusão perdidaChamar concluir duas vezes seguidasAs duas chamadas devolvem a mesma chave, sem erro
Tamanho declarado menor que o realCriar a sessão com tamanho falso e enviar mais partesA conclusão devolve 409 e o objeto não é criado
Upload abandonadoCriar a sessão, enviar uma parte e não concluirA regra de ciclo de vida remove as partes e o job marca a sessão como abortada

Em produção, a métrica que importa não é a quantidade de uploads concluídos, mas a taxa de conclusão por sessão criada e a quantidade de partes repetidas por upload. Na plataforma de cursos, depois da mudança, a taxa de conclusão de arquivos acima de 2 GB passou de 69% para 99,4%, o tempo médio entre o início e a publicação da aula caiu porque ninguém recomeçava do zero e os pods da API deixaram de precisar de disco temporário. Os 0,6% restantes eram sessões abandonadas de propósito, e a regra de ciclo de vida cuidou delas.

FAQ

Perguntas frequentes

E se eu não usar S3?

A ideia é a mesma em qualquer armazenamento de objetos, só muda o protocolo. Google Cloud Storage oferece sessões de upload retomável: o servidor cria a sessão, o cliente envia com Content-Range e, depois de uma queda, consulta o offset aceito com um PUT vazio e Content-Range: bytes */tamanho. No Azure Blob Storage, o equivalente são os blocos de um block blob, enviados com Put Block e confirmados com Put Block List, autorizados por uma SAS. MinIO, Cloudflare R2 e outros compatíveis com S3 aceitam o código deste artigo quase sem mudanças. Se o arquivo precisa passar pelo seu próprio servidor, o protocolo aberto tus resolve a retomada sobre HTTP com implementações prontas de cliente e servidor.

Como mostrar progresso fino se o fetch não informa o progresso do envio?

Com partes de 8 MiB, o progresso por parte concluída já é suficientemente fino para a maioria das telas: um arquivo de 4 GB avança de 0,2% em 0,2%. Se for preciso atualizar durante cada parte, use XMLHttpRequest para o PUT, porque ele expõe upload.onprogress, e some os bytes em andamento de todas as partes ativas aos bytes das partes já concluídas. Lembre de descontar os bytes de uma parte que falhou, senão a barra anda para trás ou passa de 100%. Streaming de corpo no fetch existe em alguns navegadores, mas exige HTTP/2 e ainda não tem suporte amplo o bastante para ser a base de um fluxo crítico.

Vale a pena usar multipart para arquivos pequenos?

Não. Abaixo de algumas dezenas de megabytes, um PUT simples com URL pré-assinada é mais rápido, mais simples e tem chance de falha desprezível. O multipart adiciona pelo menos três chamadas à API e exige limpeza de partes órfãs. Uma regra comum é usar PUT único até 50 ou 100 MB e multipart acima disso, com o servidor decidindo na criação da sessão. O que vale para qualquer tamanho é tirar a API do caminho dos bytes, porque isso libera conexões, disco e banda da aplicação.

Upload grande não é um problema de timeout, é um problema de forma

Mandar o arquivo inteiro em uma requisição que atravessa a API funciona com arquivos de teste e falha, de forma silenciosa e repetida, para quem tem o arquivo maior e a rede pior. Aumentar timeouts só muda o lugar onde a conexão cai. A correção é mudar a forma do envio: URLs pré-assinadas para que os bytes vão direto ao armazenamento, multipart para que cada parte falhe e seja reenviada sozinha, uma sessão no servidor que torna o envio retomável de qualquer aba e uma conclusão que confere no S3 o que realmente chegou. Com CORS, ciclo de vida e validação de conteúdo resolvidos, e um teste que derruba a rede no meio, o 99% deixa de ser o lugar onde o envio morre. Posso revisar o fluxo de upload do seu sistema, implementar o envio direto e retomável e montar os testes que provam que ele sobrevive a uma rede ruim.