Escala

Transcrição em lote: milhares de arquivos sem quebrar a fila

O que muda quando o lote sai de dez arquivos para dez mil: tetos de requisição, saldo reservado, cobrança duplicada e as falhas que valem retentar.

Transcrever um arquivo é um POST e um GET. Transcrever um acervo de dez mil é um problema diferente, e a diferença não está no código de transcrição: está no que acontece em volta quando tudo dispara junto.

Quatro coisas quebram nessa passagem, sempre as mesmas.

1. Os tetos de requisição

Os limites por conta, em janela deslizante de um minuto:

CaminhoTeto por minuto
POST /v1/transcripts120
POST /v1/uploads120
PUT /v1/uploads/{id}600

Um backlog destravando de uma vez encosta nesses tetos com facilidade, porque o pico de um cliente não se parece com o tráfego médio dele. Quando encosta, a resposta é 429 rate_limited com o header Retry-After trazendo a espera real em segundos.

O erro clássico aqui é tratar 429 como falha e mandar para a fila de erro. Não é falha: é agendamento. Nada foi perdido, só adiado.

async function create(body: object, key: string): Promise<{ id: string }> {
	for (let attempt = 0; attempt < 8; attempt++) {
		const res = await fetch(`${API}/v1/transcripts`, {
			method: "POST",
			headers: { ...headers, "Idempotency-Key": key },
			body: JSON.stringify(body),
		});

		if (res.status === 429) {
			const wait = Number(res.headers.get("Retry-After") ?? 5);
			await new Promise((r) => setTimeout(r, wait * 1000));
			continue;
		}

		const json = await res.json();
		if (!res.ok) throw new Error(json.error.code);
		return json.data;
	}
	throw new Error("rate_limited");
}

Dois tetos por minuto não parecem muito até você fazer a conta ao contrário: 120 criações por minuto são 7.200 por hora, e um lote de dez mil arquivos entra em menos de duas horas mesmo respeitando o teto o tempo inteiro. Precisando de mais, vale falar com a gente antes de a migração começar, não no meio dela.

2. O saldo que trava o lote

Sem crédito, novas transcrições são recusadas com 402 insufficient_balance. Nada é apagado, mas o lote para. E existe uma segunda forma disso aparecer: um áudio mais longo do que o saldo restante cobre termina em failed com error.code: "insufficient_balance", sem cobrar.

O que monitorar não é total:

{
	"data": {
		"currency": "USD",
		"total": 187.4062,
		"reserved": 40.5,
		"available": 146.9062
	}
}

reserved é o que as transcrições em andamento já seguraram. available é total menos reserved, e é o que a próxima transcrição pode gastar de fato. Num lote grande, a distância entre os dois é justamente o tamanho da fila em voo, então alarme em total dispara tarde demais.

Duas coisas evitam a parada: consultar GET /v1/balance no laço a cada algumas centenas de criações, e ligar a recarga automática no painel, que resolve o caso sem alguém de plantão às três da manhã. A tabela toda está em preços e limites.

Vale saber também que uma conta rodando só com o crédito de boas-vindas processa no máximo 2 transcrições ao mesmo tempo, e a terceira simultânea recebe 429 concurrency_limit. É proteção contra abuso do saldo gratuito, não limite de plano: a primeira recarga tira o teto. Quem testa o script de lote com a conta nova e conclui que a API é lenta está medindo esse teto.

3. Pagar duas vezes pelo mesmo áudio

Orquestrador que redistribui fila manda o mesmo arquivo mais de uma vez. Worker que morre no meio faz o item voltar para a fila. Script que você roda de novo porque a primeira metade deu errado remanda a primeira metade. Em lote, isso não é exceção: é o comportamento normal de qualquer fila com retentativa.

Cada envio é uma transcrição cobrada, a não ser que você diga o contrário. Há dois mecanismos, e eles resolvem problemas diferentes:

Idempotency-Key protege a requisição. Mesma chave e mesmo corpo devolvem a mesma transcrição, quantas vezes você repetir. É o que salva quando a conexão cai depois de a requisição chegar. Mesma chave com corpo diferente devolve 409 idempotency_conflict.

reuseIfIdentical protege o conteúdo. Envie o arquivo declarando o sha256 e crie a transcrição com esse campo: existindo uma transcrição desta conta para aquele mesmo conteúdo, com os mesmos parâmetros, a resposta é 200 com ela em vez de 201 com uma nova, e nada é cobrado. A busca alcança inclusive a que ainda está em processing, que é exatamente o caso da rajada.

Se o áudio veio de upload, use o próprio uploadId como chave de idempotência. O instinto é chavear pelo id da sua mídia, mas cada tentativa sobe o arquivo de novo, então o uploadId do corpo muda a cada uma: mesma chave, corpo diferente, 409 idempotency_conflict em toda tentativa, para sempre. Quem resolve "mandei o mesmo vídeo duas vezes" é reuseIfIdentical.

Sem sha256 no upload não há como reconhecer o conteúdo, e o pedido com reuseIfIdentical é recusado com 400 validation (reuse_needs_sha256) em vez de virar cobrança silenciosa. Os dois caminhos de envio estão em envio de arquivos.

4. As falhas, separadas por quem as resolve

Num lote de dez mil, alguns arquivos vão falhar. O que decide se o lote termina sozinho é você classificar a falha certo. Transcrição que falha não é cobrada, então retentar o que vale a pena é de graça.

error.codeRetentar?
engine_failedSim. Falhou em todas as tentativas do nosso lado; reenviar costuma passar.
insufficient_balanceSim, depois de recarregar.
audio_unreachableTalvez. A URL pode ter expirado; gere outra e reenvie.
audio_invalidNão. O áudio não pôde ser decodificado. Conserte o arquivo.
audio_too_longNão. Acima de 10 horas. Divida antes de enviar.
audio_too_largeNão. Acima de 2 GiB.
audio_mismatchNão. O áudio guardado não bate com o sha256 declarado; o envio corrompeu.

Retentar audio_invalid dez mil vezes é o jeito mais rápido de transformar um lote que terminaria em duas horas num laço infinito. A lista completa está em erros.

Vale um cuidado no upload em chunks: sem declarar expectedBytes, um chunk perdido no meio de 2 GiB é aceito em silêncio. A transcrição sai done, com texto plausível, cobrada, cobrindo só o pedaço que chegou. Em lote grande, esse é o pior erro possível, porque ele não aparece em lugar nenhum. Declare o tamanho e o digest.

Fechar a conta no fim

GET /v1/transcripts aceita since e until, e o recorte é feito no banco: fechar o custo de um dia não exige paginar a conta inteira.

curl "https://api.transcrevo.com/v1/transcripts?since=2026-07-01T00:00:00Z&until=2026-07-02T00:00:00Z&perPage=100" \
  -H "Authorization: Bearer $TRANSCREVO_API_KEY"

Um detalhe que morde na conciliação: cost vem com fração de centavo (0.0167). Se você guarda esse valor em coluna inteira, ou passa por parseInt, o corte é silencioso e some com quase todo lote de arquivo curto. Guarde decimal.

E a última linha da conta: mil horas de áudio custam US$ 30, ou US$ 50 com separação de falantes. Acima de 10.000 horas transcritas pela conta, a tarifa cai sozinha para US$ 0,02, sem contrato e sem negociação.

Todos os posts