HTTP Range em Zig: 206, Content-Range e Download Retomável

Para implementar HTTP Range em Zig, aceite inicialmente um único intervalo no formato bytes=início-fim, valide-o contra o tamanho atual do arquivo e responda com 206 Partial Content, Content-Range, Accept-Ranges: bytes e o Content-Length da parte. Depois, faça seek até o byte inicial e copie somente a quantidade autorizada em blocos fixos. Se o início estiver além do fim do arquivo, responda 416 Range Not Satisfiable com Content-Range: bytes */tamanho.

Esse mecanismo permite retomar downloads interrompidos, avançar em vídeos e áudios e buscar trechos de artefatos grandes sem transferir o recurso inteiro. Porém, Range não resolve sozinho a consistência: combine-o com um ETag forte e If-Range para evitar juntar bytes de versões diferentes do mesmo arquivo.

Resposta rápida

SituaçãoResposta recomendada
Requisição sem Range200 OK com o arquivo completo
Intervalo válido206 Partial Content
Início além do tamanho416 Range Not Satisfiable
Parte servidaContent-Range: bytes início-fim/total
Capacidade anunciadaAccept-Ranges: bytes
Tamanho do corpo parcialContent-Length: fim - início + 1
Recurso mudou e If-Range não bateignorar Range e enviar 200 completo
Arquivo grandeseek + cópia limitada em chunks
Vários intervalosadiar até haver necessidade real
Teste básicocurl -H 'Range: bytes=0-99'

Se o seu objetivo é baixar um arquivo do lado cliente, veja download HTTP com checksum e escrita atômica. Para montar o serviço completo, comece pelo guia de servidor HTTP em Zig para produção e aplique ETag, If-None-Match e cache condicional.

Como uma requisição Range funciona

Um cliente que já possui os primeiros 1.000 bytes pode pedir o restante assim:

GET /releases/app-linux-x86_64.tar.zst HTTP/1.1
Host: downloads.exemplo.com.br
Range: bytes=1000-

Se o arquivo tem 50.000 bytes, o servidor responde:

HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Type: application/zstd
Content-Range: bytes 1000-49999/50000
Content-Length: 49000
ETag: "sha256:abc123..."

...bytes 1000 até 49999...

Os offsets são inclusivos. Portanto, a quantidade enviada é:

comprimento = fim - início + 1

Esquecer o + 1 é um erro clássico. O intervalo bytes=0-0 solicita exatamente um byte, não zero bytes. O intervalo bytes=0-99 solicita 100 bytes.

Há três formas simples que um parser inicial precisa reconhecer:

Range: bytes=0-499
Range: bytes=500-
Range: bytes=-500

A primeira pede um intervalo fechado. A segunda pede do offset 500 até o fim. A terceira pede os últimos 500 bytes e é chamada de suffix range. Um header também pode pedir vários intervalos, como bytes=0-99,200-299, mas isso produz uma resposta multipart/byteranges mais complexa. Não é necessário começar por ela.

Modele o intervalo normalizado

É mais seguro separar o texto recebido do intervalo que será usado para abrir e transmitir o arquivo. Depois da validação, o restante do handler deve lidar apenas com offsets concretos:

const ByteRange = struct {
    start: u64,
    end: u64, // inclusivo

    pub fn len(self: ByteRange) u64 {
        return self.end - self.start + 1;
    }
};

const RangeError = error{
    InvalidUnit,
    InvalidSyntax,
    MultipleRangesUnsupported,
    Unsatisfiable,
    Overflow,
};

A função de normalização recebe o valor do header e o tamanho atual do recurso. Ela nunca deve produzir start > end, end >= file_size ou uma soma que estoure o tipo inteiro.

Uma implementação didática pode seguir este desenho:

fn normalizeRange(value: []const u8, file_size: u64) RangeError!ByteRange {
    if (file_size == 0) return RangeError.Unsatisfiable;
    if (!std.mem.startsWith(u8, value, "bytes=")) return RangeError.InvalidUnit;

    const spec = std.mem.trim(u8, value["bytes=".len..], " \t");
    if (std.mem.indexOfScalar(u8, spec, ',') != null)
        return RangeError.MultipleRangesUnsupported;

    const dash = std.mem.indexOfScalar(u8, spec, '-') orelse
        return RangeError.InvalidSyntax;

    const left = std.mem.trim(u8, spec[0..dash], " \t");
    const right = std.mem.trim(u8, spec[dash + 1 ..], " \t");

    if (left.len == 0) {
        const suffix = std.fmt.parseUnsigned(u64, right, 10) catch
            return RangeError.InvalidSyntax;
        if (suffix == 0) return RangeError.Unsatisfiable;

        const count = @min(suffix, file_size);
        return .{ .start = file_size - count, .end = file_size - 1 };
    }

    const start = std.fmt.parseUnsigned(u64, left, 10) catch
        return RangeError.InvalidSyntax;
    if (start >= file_size) return RangeError.Unsatisfiable;

    const requested_end = if (right.len == 0)
        file_size - 1
    else
        std.fmt.parseUnsigned(u64, right, 10) catch
            return RangeError.InvalidSyntax;

    if (requested_end < start) return RangeError.Unsatisfiable;

    return .{
        .start = start,
        .end = @min(requested_end, file_size - 1),
    };
}

A API exata de parsing e resposta HTTP pode variar entre versões do Zig anteriores ao 1.0. Confira zig version e as assinaturas instaladas de std.http.Server. O contrato do protocolo, os limites e os testes continuam válidos mesmo quando os nomes da stdlib mudam.

Escolha uma política para headers inválidos

O protocolo distingue um intervalo impossível de uma sintaxe que o servidor não entende. Na prática, você precisa de uma política consistente:

  • sem header Range: envie 200 completo;
  • intervalo único válido: envie 206;
  • intervalo válido, mas fora do recurso: envie 416;
  • unidade desconhecida ou sintaxe inválida: ignore o header e envie 200, ou responda erro conforme o contrato da aplicação;
  • múltiplos ranges não suportados: ignore e envie 200, ou rejeite explicitamente se clientes e documentação concordarem.

Ignorar um Range malformado e servir a representação completa é interoperável em muitos cenários, mas pode gastar muita banda. Para endpoints de artefatos gigantes, uma rejeição clara pode ser operacionalmente melhor. O importante é não deixar uma falha de parsing virar offset incorreto, alocação descontrolada ou leitura fora do arquivo.

Monte corretamente a resposta 206

Suponha um arquivo de 10.000 bytes e o pedido bytes=2000-2999. A resposta precisa informar tanto a parte quanto o total:

HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Range: bytes 2000-2999/10000
Content-Length: 1000
Content-Type: application/octet-stream

Não reutilize o tamanho total no Content-Length: ele descreve o corpo desta resposta, que tem 1.000 bytes. Também não envie 206 sem Content-Range, pois o cliente precisa saber onde encaixar a parte.

Uma função pequena ajuda a formatar o valor sem espalhar aritmética pelo handler:

fn formatContentRange(
    buffer: []u8,
    range: ByteRange,
    total: u64,
) ![]const u8 {
    return std.fmt.bufPrint(
        buffer,
        "bytes {d}-{d}/{d}",
        .{ range.start, range.end, total },
    );
}

Para 416, o formato muda:

HTTP/1.1 416 Range Not Satisfiable
Content-Range: bytes */10000
Content-Length: 0

Esse total permite que um downloader descubra que seu arquivo temporário local ficou maior que o recurso atual e decida recomeçar.

Transmita sem carregar a parte inteira

Depois de validar o intervalo, abra o arquivo, faça seek e copie exatamente range.len() bytes. Um loop com orçamento restante evita ultrapassar o final autorizado mesmo se o reader puder fornecer mais dados:

fn copyFileRange(file: *std.fs.File, writer: anytype, range: ByteRange) !void {
    try file.seekTo(range.start);

    var buffer: [32 * 1024]u8 = undefined;
    var remaining = range.len();

    while (remaining > 0) {
        const wanted: usize = @intCast(@min(remaining, buffer.len));
        const n = try file.read(buffer[0..wanted]);
        if (n == 0) return error.UnexpectedEndOfFile;

        try writer.writeAll(buffer[0..n]);
        remaining -= n;
    }
}

O uso de memória fica em aproximadamente 32 KiB por transmissão, independentemente de o cliente pedir 1 MiB ou 20 GiB. Escolha o buffer por benchmark; 16, 32 ou 64 KiB são pontos de partida, não verdades universais.

Abra e examine o arquivo com cuidado para evitar uma corrida entre a leitura dos metadados e a transmissão. Quando possível, obtenha o tamanho do mesmo descritor que será usado para ler. Não derive o caminho diretamente de uma URL sem normalização, pois sequências como ../ podem escapar do diretório permitido. O guia de upload seguro em Zig discute a mesma fronteira pelo lado da entrada.

Use ETag e If-Range para consistência

Imagine que o cliente baixou os bytes 0-999 da versão A. Antes da retomada, o servidor publica a versão B, com conteúdo diferente. Se o cliente pedir apenas bytes=1000-, poderá concatenar o prefixo de A com o restante de B e produzir um arquivo corrompido.

A solução é guardar um validador forte recebido anteriormente e enviá-lo em If-Range:

GET /release.tar.zst HTTP/1.1
Range: bytes=1000-
If-Range: "sha256:abc123..."

O servidor compara o If-Range com o ETag atual:

  • se corresponder, atende o intervalo com 206;
  • se não corresponder, ignora Range e envia a representação completa com 200;
  • o cliente substitui o temporário antigo em vez de anexar bytes incompatíveis.

Para downloads que precisam de integridade, o ETag pode vir de um hash do artefato ou de outro identificador forte e estável da versão. Evite um validador fraco para montagem byte a byte. Além disso, publique um checksum separado quando o arquivo é distribuído como release; o cliente deve verificar o resultado final antes de renomear o .part para o destino definitivo.

If-Range é diferente de If-None-Match. O primeiro decide entre uma parte e o corpo completo. O segundo pode resultar em 304 Not Modified, sem corpo. Mantenha essas duas decisões separadas no código.

Não implemente multipart/byteranges cedo demais

Um cliente pode enviar:

Range: bytes=0-99,1000-1099

Atender isso corretamente exige uma resposta multipart/byteranges, em que cada parte tem seu próprio Content-Range e o corpo usa um boundary. O Content-Length total fica mais trabalhoso, e uma lista enorme de pequenos intervalos pode aumentar CPU, seeks, overhead de headers e complexidade de testes.

Para uma primeira versão de um servidor de artefatos, suportar apenas um intervalo costuma cobrir download retomável e muitos players. Documente a limitação, limite o tamanho do header e adicione múltiplos ranges somente quando métricas ou clientes reais exigirem.

Se implementar depois, imponha pelo menos:

  • quantidade máxima de intervalos;
  • soma máxima de bytes solicitados;
  • normalização e, se adequado, união de intervalos sobrepostos;
  • limite de tempo por resposta;
  • proteção contra headers gigantes;
  • testes de boundaries e conteúdo binário.

Cabeçalhos e decisões que não podem se contradizer

Uma resposta parcial precisa continuar descrevendo o mesmo recurso. Preserve Content-Type, Content-Disposition quando houver download com nome sugerido, ETag, Last-Modified e políticas de cache coerentes.

Evite combinar decisões em ordem aleatória. Um pipeline previsível pode ser:

  1. autenticar e autorizar o acesso;
  2. resolver o recurso para um caminho seguro;
  3. abrir o arquivo e obter tamanho e metadados;
  4. calcular ETag ou recuperar o validador armazenado;
  5. avaliar If-Range;
  6. interpretar e normalizar Range quando permitido;
  7. escolher 200, 206 ou 416;
  8. emitir headers completos;
  9. transmitir exatamente o corpo prometido;
  10. registrar status, offsets, bytes e duração.

Se houver compressão dinâmica, seja especialmente cuidadoso. Ranges normalmente se referem aos bytes da representação selecionada. Comprimir o arquivo durante a resposta muda offsets e tamanho. Para artefatos já comprimidos, como .zip, .gz e .zst, sirva os bytes armazenados sem recompressão. Para outros recursos, é comum desativar compressão em respostas parciais ou manter variantes pré-comprimidas com validadores próprios. Leia compressão HTTP em Zig antes de misturar as duas funcionalidades.

Segurança e limites operacionais

Range economiza banda para clientes legítimos, mas também permite gerar muitas leituras pequenas. Trate o header como entrada não confiável.

Aplique limites em camadas:

  • tamanho máximo do header;
  • um intervalo por requisição na primeira versão;
  • tamanho mínimo de parte, se o produto permitir;
  • rate limiting por cliente ou credencial;
  • número máximo de conexões simultâneas;
  • timeout de escrita para clientes lentos;
  • raiz de arquivos fixa e caminhos normalizados;
  • autorização antes de revelar tamanho, ETag ou existência;
  • logs sem tokens, cookies ou URLs assinadas completas.

Para conteúdo privado, não use a resposta 416 como um oráculo que revela o tamanho de um recurso antes da autorização. Primeiro confirme que o cliente pode acessar o arquivo; só depois avalie o intervalo.

Também considere o que acontece se o arquivo for truncado durante a transmissão. O loop deve detectar EOF prematuro, encerrar a resposta e registrar a divergência. Uma estratégia de publicação atômica — escrever uma versão temporária e renomeá-la quando completa — reduz esse risco.

Observabilidade útil

Não registre cada chunk. Registre uma linha estruturada por requisição com campos que ajudem a distinguir uso normal de abuso:

method=GET status=206 resource=release-42 range_start=1048576 range_end=5242879 bytes=4194304 etag_match=true duration_ms=183

Métricas úteis incluem:

  • quantidade de respostas 200, 206 e 416;
  • bytes completos versus bytes parciais;
  • tamanho médio das partes;
  • taxa de If-Range incompatível;
  • duração e throughput das transmissões;
  • conexões encerradas antes do fim;
  • número de pedidos com múltiplos ranges rejeitados;
  • recursos mais retomados.

Uma alta repentina de 416 pode indicar bug no downloader, arquivo trocado sem controle de versão ou tentativa automatizada de sondagem. Uma alta de partes minúsculas pode justificar rate limit específico. Combine esses sinais com o guia de logs e métricas Prometheus em Zig.

Testes com curl

Crie um arquivo previsível e descubra o tamanho:

printf '0123456789abcdefghijklmnopqrstuvwxyz' > exemplo.txt
wc -c exemplo.txt

Peça os dez primeiros bytes:

curl -i \
  -H 'Range: bytes=0-9' \
  http://localhost:8080/exemplo.txt

Espere 206, Content-Range: bytes 0-9/36, Content-Length: 10 e o corpo 0123456789.

Peça do byte 10 até o fim:

curl -i \
  -H 'Range: bytes=10-' \
  http://localhost:8080/exemplo.txt

Peça os últimos seis bytes:

curl -i \
  -H 'Range: bytes=-6' \
  http://localhost:8080/exemplo.txt

Teste um intervalo impossível:

curl -i \
  -H 'Range: bytes=999-' \
  http://localhost:8080/exemplo.txt

Espere 416 e Content-Range: bytes */36.

Para simular retomada em disco:

curl -o arquivo.part \
  -H 'Range: bytes=0-999999' \
  http://localhost:8080/release.tar.zst

curl -C - -o arquivo.part \
  http://localhost:8080/release.tar.zst

Além do teste manual, cubra o normalizador com testes unitários:

EntradaTamanhoResultado
bytes=0-01000..0
bytes=0-991000..99
bytes=50-10050..99
bytes=-1010090..99
bytes=-2001000..99
bytes=99-20010099..99
bytes=100-100416
bytes=20-10100416
items=0-10100unidade não suportada
bytes=0-1,4-5100múltiplos não suportados

Inclua arquivo vazio, números maiores que u64, espaços inesperados, campos vazios e desconexão durante a escrita.

Checklist de produção

Antes de publicar um endpoint com Range, confirme:

  • o acesso ao recurso é autorizado antes de avaliar o intervalo;
  • o caminho fica preso à raiz permitida;
  • o tamanho vem do arquivo realmente aberto;
  • os offsets usam inteiros capazes de representar arquivos grandes;
  • bytes=0-0 retorna exatamente um byte;
  • o fim é limitado a file_size - 1;
  • início fora do recurso retorna 416 com bytes */total;
  • 206 inclui Content-Range, Accept-Ranges e tamanho da parte;
  • o loop transmite no máximo a parte autorizada;
  • If-Range impede mistura de versões;
  • publicação de arquivos é atômica;
  • múltiplos ranges têm política explícita;
  • timeouts, concorrência e rate limit estão definidos;
  • testes verificam headers e bytes do corpo;
  • métricas distinguem 200, 206 e 416.

Perguntas frequentes

O que um servidor Zig deve responder a um Range válido?

Ele deve responder 206 Partial Content, informar Accept-Ranges: bytes, enviar Content-Range com o intervalo efetivamente servido e o tamanho total, definir Content-Length como o tamanho da parte e transmitir somente os bytes solicitados.

Quando a resposta deve ser 416 Range Not Satisfiable?

Use 416 quando o intervalo não pode ser satisfeito, por exemplo quando o byte inicial é igual ou maior que o tamanho atual do arquivo. A resposta deve incluir Content-Range: bytes */tamanho para informar o comprimento vigente do recurso.

Range permite retomar qualquer download com segurança?

Não sozinho. Para impedir que o cliente combine partes de versões diferentes, associe a retomada a um validador forte, normalmente ETag, e use If-Range. Se o validador não corresponder, envie o arquivo completo com 200 em vez de uma parte 206.

Preciso implementar múltiplos ranges na primeira versão?

Não. Um servidor simples pode suportar apenas um intervalo e rejeitar ou ignorar pedidos multipart conforme uma política documentada. Múltiplos ranges exigem multipart/byteranges, boundaries, mais validação e proteção contra abuso.

Devo carregar a parte solicitada inteira na memória?

Não para arquivos grandes. Faça seek até o offset inicial e copie no máximo a quantidade calculada em blocos fixos. Assim, o uso de memória permanece limitado mesmo quando a parte tem centenas de megabytes.

Conclusão

Suporte a HTTP Range não exige um framework pesado, mas exige aritmética, headers e estados coerentes. Normalize um único intervalo, responda 206 com metadados exatos, use 416 quando a parte não existe e transmita por streaming com um orçamento de bytes. Em seguida, adicione ETag e If-Range para que retomada signifique continuidade do mesmo arquivo, não apenas concatenação cega.

Esse desenho combina bem com Zig: offsets, buffers, erros e limites ficam visíveis. Comece pequeno, teste cada formato de intervalo e só implemente multipart/byteranges quando houver demanda concreta. Para completar a cadeia, conecte o endpoint a graceful shutdown com SIGTERM e a health checks de readiness e liveness, evitando iniciar novos downloads durante a drenagem do serviço.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.