Por que construímos uma stack HTTP/2 customizada em vez de estender o Indy

· Análises

A pergunta que mais recebemos

Sempre que publicamos um novo recurso de HTTP/2 no sgcWebSockets — server push, ajuste da tabela dinâmica do HPACK, negociação de frame SETTINGS — alguém faz educadamente a mesma pergunta no fórum: “Por que vocês não estenderam o TIdHTTP do Indy? Ele já faz TCP, TLS e HTTP/1.1, com certeza o HTTP/2 é só mais um formato de framing por cima.”

É uma pergunta razoável. A resposta curta é que o HTTP/2 não é uma camada que se possa enxertar em um cliente HTTP/1.1 — é um transporte fundamentalmente diferente, que quebra quase todas as premissas do Indy. Este post percorre os motivos específicos pelos quais construímos nossa implementação de HTTP/2 do zero, o que isso exigiu e como influenciou o resto do sgcWebSockets.

HTTP/1.1 é texto, HTTP/2 é frames binários

A implementação HTTP do Indy, como todo cliente HTTP/1.1 clássico, lê uma linha de requisição terminada em CRLF, depois os cabeçalhos e, em seguida, um corpo com content-length ou um corpo em chunked. É um texto orientado a linhas. O transporte é uma requisição, uma resposta e, então, ou fecha ou usa pipeline (e ninguém de fato usa pipeline na prática).

O HTTP/2 é um protocolo de framing binário. Cada byte após o preâmbulo da conexão pertence a um frame: DATA, HEADERS, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE, PING, GOAWAY, WINDOW_UPDATE, CONTINUATION. Frames não estão alinhados com requisições — os cabeçalhos de uma única requisição podem se espalhar por vários frames HEADERS / CONTINUATION, o corpo pode chegar como muitos frames DATA intercalados e frames de diferentes requisições são misturados no mesmo socket. O leitor não é um parser de linhas, é uma máquina de estados sobre cabeçalhos de frame de tamanho fixo e payloads de tamanho variável.

Adaptar isso sobre TIdHTTP.ReadHeaderFromStream significaria reescrever o loop de leitura por completo. Nesse ponto, você não está estendendo o Indy — está escrevendo uma implementação paralela que por acaso compartilha uma classe de socket.

Multiplexação quebra a premissa de uma requisição por conexão

O modelo de cliente HTTP do Indy é construído em torno de uma única requisição em andamento. Você chama Get, o método bloqueia, a resposta chega, você processa. O socket TCP pertence àquela chamada durante toda a duração. O HTTP/2 inverte isso: uma única conexão multiplexa muitas streams concorrentes, cada uma identificada por um stream id. Uma conexão típica do Apple Push Notification envia milhares de requisições POST independentes pelo mesmo socket TCP, com respostas chegando fora de ordem.

Para suportar isso com o modelo bloqueante do Indy, você precisaria de (a) um cliente Indy por stream HTTP/2 — o que anula todo o propósito do HTTP/2 — ou (b) uma thread dispatcher separada que detém o socket, lê os frames, distribui-os para filas por stream e expõe uma API de futures/promises para os chamadores. A opção (b) é essencialmente escrever um novo cliente HTTP/2 e colocar o Indy embaixo como um wrapper fino de socket. Optamos por fazer isso explicitamente, com o transporte mais adequado à plataforma.

HPACK é uma máquina de estados, não uma string

Cabeçalhos HTTP/1.1 são texto nome-valor. Cabeçalhos HTTP/2 são comprimidos com HPACK (RFC 7541), que combina uma tabela estática de cabeçalhos comuns, uma tabela dinâmica que cresce ao longo da conexão e literais codificados em Huffman. Codificar um conjunto de cabeçalhos não é WriteLn(Stream, 'Content-Type: application/json') — é uma busca contra duas tabelas, uma escolha entre representações indexed/literal-with-indexing/literal-without-indexing/literal-never-indexed, compressão Huffman opcional e uma atualização da tabela dinâmica que ambos os endpoints precisam manter sincronizada.

Erre o tamanho da tabela e a conexão morre com um COMPRESSION_ERROR. Esqueça de aplicar a atualização SETTINGS para o tamanho máximo da tabela de cabeçalhos e um servidor que antes funcionava passa a rejeitar suas requisições. Não há atalho — ou você implementa HPACK por completo ou você não tem HTTP/2. Implementamos a tabela estática, a tabela dinâmica com eviction, codificação/decodificação Huffman e as quatro formas de representação em cerca de 1.200 linhas de Pascal. Esse código não existe em nenhum lugar do Indy e não teria um lar natural lá.

Controle de fluxo é por stream e por conexão

O HTTP/2 tem duas janelas em camadas: uma no nível da conexão e outra por stream. Ambas começam em 65.535 bytes e só crescem quando o par envia frames WINDOW_UPDATE. Uma implementação ingênua que escreve DATA sem checar a janela vai travar a conexão após os primeiros 64 KB. Uma implementação correta precisa de um event loop que pausa os writers quando a janela é esgotada, divide corpos grandes em chunks do tamanho da janela e retoma quando chegam atualizações.

Nada disso existe no HTTP/1.1 e, portanto, nada disso existe no Indy. É um trabalho real que precisa morar em algum lugar — colocamos no mesmo lugar que o leitor de frames e o codec HPACK.

Como é o nosso loop de escrita

Aqui vai uma pequena fatia do writer de frames, simplificada para legibilidade. Note a checagem explícita do controle de fluxo e o loop que divide o payload em chunks limitados pela janela:

procedure TsgcHTTP2Stream.WriteData(const aData: TBytes; aEndStream: Boolean);
var
  vOffset, vChunk, vMax: Integer;
  vFlags: Byte;
begin
  vOffset := 0;
  while vOffset < Length(aData) do
  begin
    // Respect both connection and stream flow-control windows.
    vMax := Min(FConnection.SendWindow, Self.SendWindow);
    vMax := Min(vMax, FConnection.PeerSettings.MaxFrameSize);
    if vMax <= 0 then
    begin
      // Block until a WINDOW_UPDATE wakes us.
      FWindowEvent.WaitFor(INFINITE);
      Continue;
    end;
    vChunk := Min(Length(aData) - vOffset, vMax);
    vFlags := 0;
    if aEndStream and ((vOffset + vChunk) = Length(aData)) then
      vFlags := FLAG_END_STREAM;
    FConnection.SendFrame(FRAME_DATA, vFlags, Self.StreamId,
      Copy(aData, vOffset, vChunk));
    Dec(FConnection.SendWindow, vChunk);
    Dec(Self.SendWindow, vChunk);
    Inc(vOffset, vChunk);
  end;
end;

São algumas dezenas de linhas, mas capturam a essência de por que o HTTP/2 não pode ser um add-on fino. Não existe caminho de código equivalente em nenhum cliente HTTP/1.1 porque o conceito simplesmente não existe. Coloque isso sobre TIdHTTP.Get e você imediatamente descobre que toda a convenção de chamada — uma chamada bloqueante por requisição — tem o formato errado.

ALPN, negociação de settings e ciclo de vida da conexão

O HTTP/2 sobre TLS é selecionado via ALPN (Application-Layer Protocol Negotiation), que precisa ser configurado no contexto TLS antes do handshake. O IO handler OpenSSL do Indy não expôs o ALPN por anos — foi acrescentado eventualmente — mas ALPN é só o primeiro passo. Depois do handshake TLS, o cliente precisa enviar o preâmbulo de conexão de 24 bytes, em seguida um frame SETTINGS, e então esperar o SETTINGS do servidor e dar o ack. Só então a conexão está pronta para receber requisições. O SChannel no Windows exige um caminho de configuração de ALPN diferente do OpenSSL. O msquic exige ainda outro. Abstrair tudo isso de forma limpa foi mais fácil em um módulo limpo do que enfiado no código de handshake TLS existente do Indy.

Desempenho: os números

O retorno de fazer todo esse trabalho é mensurável. Em uma carga representativa — um cliente Delphi enviando um milhão de Apple Push Notifications para a APNs (apns2 em modo HTTP/2) em um servidor Windows moderno:

Aproximadamente 10 vezes de melhoria no mesmo hardware e na mesma rede, sem alterar a lógica da aplicação. Esse é o valor que a multiplexação do HTTP/2 entrega quando você constrói a stack nativamente, em vez de tentar simular com conexões HTTP/1.1 paralelas.

O que reaproveitamos do Indy

Nem tudo foi reescrito. Usamos o TIdSSLIOHandlerSocketOpenSSL OpenSSL do Indy como um dos provedores TLS subjacentes (com nosso próprio patch de ALPN), o TIdTCPClient do Indy para a camada de socket no backend padrão, e o TIdHTTPServer do Indy continua presente para fallback HTTP/1.1. A camada HTTP/2 fica por cima, possuindo o loop de leitura, o parser de frames, o HPACK, o controle de fluxo e o ciclo de vida das streams. A divisão nos permite compartilhar a infraestrutura TLS, mantendo a semântica de HTTP/2 independente do modelo de chamada bloqueante do Indy.

Lições para outros protocolos

O mesmo padrão se repete ao longo do sgcWebSockets. WebSocket é um protocolo de framing binário enxertado em um upgrade HTTP — escrevemos o framer do zero. O MQTT 5 tem sua própria codificação de inteiros de comprimento variável, tabela de propriedades e controle de fluxo — escritos do zero. O AMQP 1.0 tem um sistema de tipos completo e fluxo de credit em links — escrito do zero. O HTTP/3 é sobre QUIC e não sobre TCP — encapsulamos msquic / ngtcp2. O fio condutor é que todo protocolo moderno tem sua própria máquina de estados e fingir que é “só mais um modo do HTTP/1.1” leva a código frágil e lento.

Considerações finais

Estender o Indy é o primeiro instinto óbvio — é a biblioteca em que todo desenvolvedor Delphi já confia. Mas o HTTP/2 não é uma extensão do HTTP/1.1, é um redesenho. Multiplexação de frames, HPACK, controle de fluxo e respostas fora de ordem exigem, cada um, uma infraestrutura que simplesmente não existe em um cliente HTTP/1.1 bloqueante. Construir nativamente exigiu esforço real de engenharia, mas é a única forma de entregar o ganho de ordem de magnitude que o HTTP/2 promete — e isso estabeleceu o template para todo outro protocolo moderno que o sgcWebSockets lançou desde então.