Por que Multipart em Delphi muitas vezes só falha em produção
Um Multipart/Form-Data Upload em Delphi é rápido de montar — e depois falha em integrações reais por detalhes: Content-Type incorreto por part, uma string de boundary que aparece acidentalmente no payload, quebras de linha inadequadas, nomes de ficheiros não ASCII ou servidores que chunked transfer encoding (HTTP sem Content-Length) rejeitam. A isso somam-se problemas típicos na prática de software empresarial personalizado: ficheiros grandes (CAD, PDFs, digitalizações), redes instáveis, reverse proxies, gateways de API rígidos e requisitos administrativos para depuração.
Delphi fornece com System.Net.HttpClient uma stack utilizável, mas os exemplos do „Happy Path“ deixam de fora condições de contorno importantes. O trecho de código abaixo entra propositadamente em maior profundidade: construímos Multipart como um stream de forma determinística, calculamos corretamente o Content-Length, suportamos RFC-5987 para nomes de ficheiro e oferecemos uma opção de depuração que torna a requisição reproduzível sem que seja necessário interceptar o TLS.
Decisão arquitetural: THTTPClient em vez do Indy — e quando isso se torna problemático
THTTPClient (System.Net) usa backends diferentes consoante a plataforma (sob Windows tipicamente WinHTTP/WinINet). Isso costuma ser vantajoso em ambientes empresariais: políticas de proxy e TLS tendem a ser mais compatíveis com o sistema. O Indy é, em contrapartida, muito transparente e adaptável, mas traz seus próprios bindings de TLS e, em produção, por vezes precisa ser „mantido separadamente“ (versões do OpenSSL, conjuntos de cifras).
A abordagem aqui usa THTTPClient, porque ele já está frequentemente em uso em modernizações (REST-Client, OAuth, downloads). Se, no entanto, você precisar de controle rígido sobre os handshakes TLS, certificados de cliente em formas especiais ou cadeias de proxy muito específicas, o Indy (ou um stack HTTP dedicado) pode ser mais adequado. Isso muda pouco na construção do Multipart — mas muda no depuramento e na operação.
Multipart/Form-Data Upload em Delphi: um fluxo de bytes, sem magia
A ideia central: Multipart é, no fim das contas, apenas um fluxo de bytes. Se nós o construirmos nós mesmos, podemos:
- Escolher intencionalmente o boundary e testá-lo de forma estável
- Definir corretamente os headers por part (incl.
Content-Disposition,Content-Type) - Calcular de forma fiável o
Content-Length(importante para servidores sem suporte a chunked) - Fazer streaming de ficheiros grandes sem manter tudo na RAM
O código: Multipart-Builder com streaming e nomes de ficheiro RFC-5987
O builder abaixo gera opcionalmente um corpo totalmente baseado em memória (para uploads pequenos) ou um arquivo de spool no disco (para payloads grandes). Isso parece „oldschool“, mas é extremamente prático em produção, porque evita o uso de chunked e facilita a depuração. Spoolar significa: você pode reutilizar o mesmo corpo de request, mesmo se for preciso um retry.
unit Net-Base.Multipart;
interface
uses
System.SysUtils, System.Classes, System.Net.HttpClient, System.Net.URLClient,
System.NetEncoding, System.Hash;
type
TMultipartFormData = class
private
FBoundary: string;
FParts: TObjectList
function CRLF: TBytes;
function BytesOfAscii(const S: string): TBytes;
function Quote(const S: string): string;
function Rfc5987FileNameStar(const FileName: string): string;
function NewBoundary: string;
public
constructor Create(const ABoundary: string = “);
destructor Destroy; override;
function ContentType: string;
function Boundary: string;
procedure AddField(const Name, Value: string; const ContentType: string = ‚text/plain; charset=utf-8‘);
procedure AddFile(const FieldName, FileName, ContentType: string; const FileStream: TStream);
// Constrói o corpo completo em um stream. Se ASpoolToFile estiver vazio,
// será usado um TMemoryStream; caso contrário, um arquivo será criado.
function BuildBodyStream(out AContentLength: Int64; const ASpoolToFile: string = “): TStream;
end;
implementation
uses
System.Generics.Collections;
type
TPartKind = (pkField, pkFile);
TPart = class
public
Kind: TPartKind;
Name: string;
ContentType: string;
// Field
Value: string;
// File
FileName: string;
FileStream: TStream;
constructor Create;
end;
constructor TPart.Create;
begin
inherited Create;
end;
constructor TMultipartFormData.Create(const ABoundary: string);
begin
inherited Create;
FParts := TObjectList
if ABoundary <> “ then
FBoundary := ABoundary
else
FBoundary := NewBoundary;
end;
destructor TMultipartFormData.Destroy;
begin
FParts.Free;
inherited;
end;
function TMultipartFormData.NewBoundary: string;
var
R: TBytes;
begin
// O boundary deve ser suficientemente aleatório. Importante: sem espaços.
SetLength(R, 16);
TNetEncoding.Base64.Decode(TNetEncoding.Base64.EncodeBytesToBytes(THashSHA2.GetHashBytes(GuidToString(TGuid.NewGuid))), R);
Result := ‚—-DelphiBoundary‘ + THashSHA2.GetHashString(GuidToString(TGuid.NewGuid));
Result := Result.Replace(‚{‚,“).Replace(‚}‘,“).Replace(‚-‚,“);
end;
function TMultipartFormData.Boundary: string;
begin
Result := FBoundary;
end;
function TMultipartFormData.ContentType: string;
begin
Result := ‚multipart/form-data; boundary=‘ + FBoundary;
end;
function TMultipartFormData.CRLF: TBytes;
begin
Result := TBytes.Create($0D, $0A);
end;
function TMultipartFormData.BytesOfAscii(const S: string): TBytes;
begin
// Cabeçalhos multipart são ASCII. Para valores no corpo (p.ex. UTF-8) definimos Content-Type por parte.
Result := TEncoding.ASCII.GetBytes(S);
end;
function TMultipartFormData.Quote(const S: string): string;
begin
Result := ‚“‚ + S.Replace(‚“‚, ‚“‚) + ‚“‚;
end;
function TMultipartFormData.Rfc5987FileNameStar(const FileName: string): string;
var
Utf8: TBytes;
Enc: string;
begin
// filename*=“UTF-8“…“ é consideravelmente mais robusto para nomes de arquivo não ASCII do que apenas filename=“…“.
Utf8 := TEncoding.UTF8.GetBytes(FileName);
Enc := TNetEncoding.URL.EncodeBytesToString(Utf8);
Result := ‚filename*=‘ + ‚UTF-8““’+ Enc;
end;
procedure TMultipartFormData.AddField(const Name, Value: string; const ContentType: string);
var
P: TPart;
begin
P := TPart.Create;
P.Kind := pkField;
P.Name := Name;
P.Value := Value;
P.ContentType := ContentType;
FParts.Add(P);
end;
procedure TMultipartFormData.AddFile(const FieldName, FileName, ContentType: string; const FileStream: TStream);
var
P: TPart;
begin
if FileStream = nil then
raise EArgumentNilException.Create(‚FileStream não pode ser nil‘);
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // permitido, mas frequentemente um erro: arquivo vazio
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Owner bleibt beim Aufrufer
FParts.Add(P);
end;
function TMultipartFormData.BuildBodyStream(out AContentLength: Int64; const ASpoolToFile: string): TStream;
var
OutStream: TStream;
WriterUtf8: TBytes;
PartObj: TObject;
P: TPart;
Header: string;
Sep, EndSep: string;
B: TBytes;
procedure WriteAscii(const S: string);
begin
B := BytesOfAscii(S);
OutStream.WriteBuffer(B, Length(B));
end;
procedure WriteBytes(const Bytes: TBytes);
begin
if Length(Bytes) > 0 then
OutStream.WriteBuffer(Bytes, Length(Bytes));
end;
procedure CopyStreamFully(Src: TStream);
var
Buf: array[0..64*1024-1] of Byte;
ReadN: Integer;
begin
// Atenção: a posição do stream será consumida.
while True do
begin
ReadN := Src.Read(Buf, SizeOf(Buf));
if ReadN <= 0 then
Break;
OutStream.WriteBuffer(Buf, ReadN);
end;
end;
begin
if ASpoolToFile <> “ then
OutStream := TFileStream.Create(ASpoolToFile, fmCreate or fmShareDenyWrite)
else
OutStream := TMemoryStream.Create;
try
Sep := ‚–‚ + FBoundary + #13#10;
EndSep := ‚–‚ + FBoundary + ‚–‚ + #13#10;
for PartObj in FParts do
begin
P := TPart(PartObj);
WriteAscii(Sep);
if P.Kind = pkField then
begin
Header := ‚Content-Disposition: form-data; name=‘ + Quote(P.Name) + #13#10 +
‚Content-Type: ‚ + P.ContentType + #13#10 +
#13#10;
WriteAscii(Header);
// Corpo do campo em UTF-8, se charset=utf-8 estiver definido.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Dois parâmetros de nome de arquivo: filename (para servidores antigos) e filename* (RFC 5987)
Header := ‚Content-Disposition: form-data; name=‘ + Quote(P.Name) + ‚; ‚ +
‚filename=‘ + Quote(ExtractFileName(P.FileName)) + ‚; ‚ +
Rfc5987FileNameStar(ExtractFileName(P.FileName)) + #13#10 +
‚Content-Type: ‚ + P.ContentType + #13#10 +
‚Content-Transfer-Encoding: binary‘ + #13#10 +
#13#10;
WriteAscii(Header);
// Importante: definir a posição para o início, caso contrário apenas o RESTante será enviado.
if P.FileStream.Seek(0, soBeginning) <> 0 then
;
CopyStreamFully(P.FileStream);
WriteBytes(CRLF);
end;
end;
WriteAscii(EndSep);
AContentLength := OutStream.Size;
OutStream.Position := 0;
Result := OutStream;
except
OutStream.Free;
raise;
end;
end;
end.
O que o código faz deliberadamente de forma diferente
- Sem ‚Multipart automático‘: O controlo sobre cabeçalhos, codificações e boundary permanece com você. Isso é frequentemente decisivo em APIs REST estritas.
- Suporte RFC-5987 via
filename*: Assim que nomes de arquivo contêm caracteres acentuados (p. ex. ‚Prüfbericht.pdf‘), esse é o bug de interoperabilidade mais comum. Alguns servidores ignoramfilename*; nesse casofilenameé usado como fallback. - Spool-to-File como recurso operacional: para uploads grandes e retries, um Body-Stream reutilizável vale ouro.
- Content-Length disponível, porque o Body é gerado antecipadamente. Isso evita Chunked-Encoding, caso o sistema de destino não o aceite.
Envio da requisição: Timeouts, cabeçalhos e uma estratégia de retry adequada
O multipart por si só não resolve os problemas de integração: você precisa de timeouts, classificação de erros e, opcionalmente, retries. É importante distinguir entre idempotente e não idempotente: uploads frequentemente não são idempotentes (podem ocorrer duplicatas). Retries devem, portanto, ocorrer apenas se o servidor oferecer semântica idempotente (p. ex., Upload-ID, cabeçalho dedicado Idempotency-Key) ou se houver deduplicação no servidor.
uses
System.SysUtils, System.Classes, System.Net.HttpClient, System.Net.URLClient,
Net-Base.Multipart;
function PostMultipart(const Url: string; const Token: string; const MP: TMultipartFormData;
const SpoolFile: string = ''): IHTTPResponse;
var
Client: THTTPClient;
Body: TStream;
ContentLen: Int64;
Req: IHTTPRequest;
begin
Client := THTTPClient.Create;
try
// Timeouts: je nach Datei und Leitung realistisch setzen.
Client.ConnectionTimeout := 15000; // ms
Client.ResponseTimeout := 600000; // ms (10 min)
Body := MP.BuildBodyStream(ContentLen, SpoolFile);
try
Req := Client.GetRequest('POST', Url);
Req.SourceStream := Body;
Req.AddHeader('Content-Type', MP.ContentType);
// Manche Server oder Proxies erwarten Content-Length zwingend.
Req.AddHeader('Content-Length', ContentLen.ToString);
if Token <> '' then
Req.AddHeader('Authorization', 'Bearer ' + Token);
// Optional: wenn der Server sauber JSON liefert, kann Accept helfen.
Req.AddHeader('Accept', 'application/json');
Result := Client.Execute(Req, nil);
finally
Body.Free;
end;
finally
Client.Free;
end;
end;
Erros comuns na prática
- Posição do stream: Se o FileStream não estiver na posição 0, você só carregará o restante. No Builder é, portanto, forçado um
Seek(0). - Chunked vs. Content-Length: Alguns gateways (ou stacks de servidor mais antigos) rejeitam Chunked. Esse é um caso frequente de legacy em soluções de software próximas ao processo. Spool-to-File é então pragmático.
- CRLF: Multipart espera CRLF (
#13#10), não apenas LF. Alguns servidores são tolerantes, outros não. - Content-Type por arquivo: Se você enviar genericamente
application/octet-stream, isso muitas vezes é aceitável. Se o servidor verifica (p. ex., PDF), defina corretamente. Em Delphi você pode resolver o mapeamento de MIME por tabela própria ou funções do SO, mas não confie cegamente nas extensões de arquivo.
Depuração: dump de tráfego reproduzível sem quebra do TLS
Com HTTPS você não vê o body no proxy se não puder usar um MitM (por exemplo, certificado do Fiddler). Isso é normal em ambientes empresariais. O Builder ajuda porque você possui o body completo como stream e (no caso de arquivo de spool) também como arquivo.
Procedimento recomendado:
- Grave o body de spool em um arquivo temporário.
- Registre em log o
Content-Typeincluindo a boundary e oContent-Length. - Gere opcionalmente para Support/DevOps um
curl-Repro: aqui não é necessário reproduzir o body 1:1, mas você pode espelhar os parâmetros e o(s) arquivo(s).
Importante: Nunca registre em log tokens produtivos nem dados pessoais. Em muitas integrações de software empresarial é exatamente essa a parte relevante para compliance.
Variações: vários arquivos, campos opcionais, servidores com expectativas “estranhas”
Vários arquivos sob o mesmo nome de campo
Muitas APIs esperam files[] ou repetem o mesmo nome várias vezes. O Builder suporta isso diretamente: chame AddFile várias vezes com o mesmo FieldName. Se você usa files, files[] ou attachments é convenção do servidor.
Servidor exige exatamente „application/json“ como parte adicional
Um padrão comum: um bloco de metadados JSON mais um arquivo. Nesse caso envie o JSON como um Field-Part, mas com Content-Type: application/json; charset=utf-8. Isso não é um “form field” no sentido de UI, mas pode ser representado corretamente em multipart:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legado: servidor aceita apenas filename, não filename*
Então o fallback via filename ajuda. Se o servidor, porém, decodificar não-ASCII em filename de forma errada, frequentemente o caminho mais robusto é: ignorar o nome do arquivo no servidor e, em vez disso, enviar um campo adicional originalName no JSON.
Enquadramento para modernização e operação
Em paisagens Delphi amadurecidas, multipart frequentemente fica na periferia: uma interface para DMS, arquivo, ticketing, portal do cliente ou um interno REST-Server. É exatamente aí que surge pressão por novos requisitos de segurança (TLS, gateways, proxies) e por maiores tamanhos de arquivo.
A abordagem apresentada vale a pena especialmente quando:
- Você precisa depurar uploads de forma reproduzível (operação/administração)
- Você quer/precisa evitar chunked
- nomes de arquivo/codificações realmente aparecem na prática (umlauts, espaços, parênteses)
- retry/idempotency devem ser resolvidos conceitualmente de forma adequada
Ela vale menos quando você envia exclusivamente arquivos pequenos a um servidor tolerante e não precisa de transparência operacional. Nesse caso uma solução simples de alto nível é suficiente — até que o primeiro arquivo “estranho” venha do departamento de negócio.
Conclusão: Upload multipart estável é um problema de streaming e operação
Um upload Multipart/Form-Data bem feito em Delphi é menos uma questão de “qual componente” e mais de controle: Boundary, CRLF, nome do arquivo, Content-Type e, acima de tudo, um stream de body determinístico. Quem constrói isso corretamente desde cedo poupa tempo depois em ciclos de depuração com API-Gateways e reverse-proxies.
Limite de aplicação da abordagem: Se precisar enviar arquivos extremamente grandes (vários GB) sem spooling e sem Content-Length, o tema Streaming sem pré-cálculo torna-se relevante — então o servidor de destino e a infraestrutura devem suportar Chunked de forma confiável, e você precisará de um conceito de depuração diferente. Para muitas integrações em soluções empresariais digitais, o Builder aqui demonstrado é, contudo, exatamente o meio pragmático entre robustez, rastreabilidade e consumo de recursos controlável.
Se você depende de uma integração Delphi consolidada, na qual uploads falham esporadicamente ou apenas „em alguns arquivos“, isso geralmente indica exatamente essas condições-limite. Para suporte direcionado em análise, modernização ou esclarecimento operacional, entre em contato conosco aqui:
No contexto técnico, também desempenham um papel importante o Delphi Thttpclient e o upload de arquivos pela API REST, quando integrações, fluxos de dados e evolução precisam atuar de forma coerente.
Discutir projeto ou iniciativa de modernização com Net-Base.