Why Multipart in Delphi often only fails in production
A Multipart/Form-Data upload in Delphi can be assembled quickly — and then fails in real integrations due to details: wrong Content-Type per part, a boundary string that accidentally appears in the payload, inappropriate line breaks, non-ASCII filenames, or servers that reject chunked transfer encoding (HTTP without Content-Length). On top of that come typical practical issues in bespoke enterprise software: large files (CAD, PDFs, scans), fluctuating networks, reverse proxies, strict API gateways and admin requirements for debugging.
Delphi ships a usable stack with System.Net.HttpClient, but the “Happy Path” examples leave important edge conditions unaddressed. The following source snippet deliberately goes deeper: we build multipart deterministically as a stream, calculate Content-Length correctly, support RFC-5987 for filenames and provide a debug option that makes the request reproducible without having to break TLS.
Architectural decision: THTTPClient instead of Indy — and when that becomes problematic
THTTPClient (System.Net) uses different backends depending on the platform (under Windows typically WinHTTP/WinINet). That is often advantageous in enterprise environments: proxy and TLS policies tend to be more compatible with the system. Indy, by contrast, is very transparent and adaptable, but brings its own TLS bindings and is sometimes “maintained separately” in operation (OpenSSL versions, cipher suites).
The approach here uses THTTPClient because it is often already in use during modernizations (REST client, OAuth, downloads). If, however, you need strong control over TLS handshakes, client certificates in special forms or very specific proxy chains, Indy (or a dedicated HTTP stack) can be appropriate. That changes little about multipart construction — but it affects debugging and operations.
Multipart/Form-Data upload in Delphi: a stream, no magic
The core idea: multipart is, in the end, just a byte stream. If we build it ourselves, we can:
- deliberately choose and reliably test the boundary
- set headers per part correctly (including
Content-Disposition,Content-Type) - reliably calculate
Content-Length(important for servers without chunked support) - stream large files without keeping everything in RAM
The code: multipart builder with streaming and RFC-5987 filenames
The builder below optionally produces either a purely memory-based body (for small uploads) or a spool file on disk (for large payloads). That feels “oldschool”, but is extremely practical in operation because it avoids chunked encoding and simplifies debugging. Spooling means: you can reuse the same request body even if a retry is required.
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<TObject>;
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);
// Builds the complete body into a stream. If ASpoolToFile is empty,
// a TMemoryStream is used; otherwise a file is created.
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;
bend;
constructor TMultipartFormData.Create(const ABoundary: string);
begin
inherited Create;
FParts := TObjectList<TObject>.Create(True);
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
// Boundary should be sufficiently random. Important: no spaces.
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
// Multipart headers are ASCII. For values in the body (e.g. UTF-8) we set Content-Type per part.
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''..." is significantly more robust for non-ASCII filenames than just 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 must not be nil');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // allowed, but often an error: empty file
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Owner remains with the caller
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
// Note: the stream position will be consumed.
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);
// Field body in UTF-8, if charset=utf-8 is set.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Two filename parameters: filename (for old servers) and 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);
// Important: set position to the beginning, otherwise only the remainder will be uploaded.
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.
What the code intentionally does differently
- No „automatic multipart“: Control over headers, encodings and the boundary remains with you. That is often decisive for strict REST APIs.
- RFC-5987 support via
filename*: As soon as filenames contain umlauts (e.g. „Prüfbericht.pdf“), this is the most common interoperability bug. Some servers ignorefilename*; thenfilenameis used as a fallback. - Spool-to-File as an operational feature: For large uploads and retries a reusable body stream is invaluable.
- Content-Length is available because the body is generated in advance. That avoids chunked encoding if the target system does not accept it.
Sending the request: timeouts, headers and a sensible retry strategy
Multipart alone does not solve integration problems: you need timeouts, error classification and optional retries. It is important to distinguish between idempotent and non-idempotent: uploads are often non-idempotent (duplicates possible). Retries should therefore only be performed if the server provides idempotent semantics (e.g. upload ID, dedicated Idempotency-Key header) or you have server-side deduplication.
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;
Practical pitfalls
- Stream position: If the file stream is not at position 0, you upload only the remainder. The builder therefore enforces
Seek(0). - Chunked vs. Content-Length: Some gateways (or older server stacks) reject chunked encoding. This is a common legacy case in process-integrated software solutions. Spool-to-File is pragmatic in that case.
- CRLF: Multipart expects CRLF (
#13#10), not just LF. Some servers are tolerant, others are not. - Content-Type per file: If you send
application/octet-streamas a blanket, that is often OK. If the server validates (e.g. PDF), set it correctly. In Delphi you can implement MIME mapping via your own table or OS functions, but do not rely blindly on file extensions.
Debugging: reproducible wire dump without TLS termination
With HTTPS you do not see the body in the proxy if you are not allowed to deploy a MitM (e.g. a Fiddler certificate). That is normal in enterprise environments. The Builder helps because you own the complete body as a stream and (for a spool file) also have it available as a file.
Recommended procedure:
- Write the spool body to a temporary file.
- Log
Content-Typeincluding boundary andContent-Length. - Optionally produce a
curlrepro for Support/DevOps: you do not need to reproduce the body 1:1 here, but you can mirror the parameters and file(s).
Important: Never log production tokens or personal data. In many business software integrations that is precisely the compliance-relevant part.
Variants: multiple files, optional fields, servers with „odd“ expectations
Multiple files under the same field name
Many APIs expect files[] or the same name repeated multiple times. The Builder supports this directly: call AddFile multiple times with the same FieldName. Whether you use files, files[] or attachments is purely a server convention.
Server requires exactly „application/json“ as an additional part
A common pattern: a JSON metadata block plus a file. Then send the JSON as a field part, but with Content-Type: application/json; charset=utf-8. That is not a „form field“ in the UI sense, but it maps cleanly in multipart:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: server accepts only filename, not filename*
Then a fallback using filename helps. However, if the server decodes non-ASCII in filename incorrectly, a robust approach often remains: ignore filenames server-side and instead send an additional originalName field in the JSON.
Context for modernization and operations
In established Delphi landscapes multipart is often at the periphery: an interface to DMS, archive, ticketing, a customer portal or an internal REST-server. That is exactly where pressure arises from new security requirements (TLS, gateways, proxies) and from larger file sizes.
The presented approach is particularly worthwhile when:
- You need to debug uploads reproducibly (operations/administration)
- You want/need to avoid chunked transfer encoding
- Filenames/encodings actually occur in practice (umlauts, spaces, parentheses)
- Retry/idempotency should be solved conceptually and cleanly
It is less worthwhile if you only send small files to a tolerant server and require no operational transparency. In that case a simple high-level solution is sufficient — until the first „odd“ file arrives from the business unit.
Conclusion: Stable multipart upload is a streaming and operations problem
A clean multipart/form-data upload in Delphi is less a question of „which component“ than of control: boundary, CRLF, filename, Content-Type and above all a deterministic body stream. Those who build this cleanly early save time later in debugging loops with API gateways and reverse proxies.
Limitation of the approach: If you need to upload extremely large files (several GB) without spooling and without Content-Length, the topic of streaming without precomputation becomes relevant – in that case target servers and infrastructure must reliably support Chunked, and you need a different debugging concept. For many integrations in digital enterprise solutions, however, the builder shown here is precisely the pragmatic midpoint between robustness, traceability and controllable resource consumption.
If you are tied to an evolved Delphi integration where uploads fail sporadically or only ‚for some files‘, this is usually an indicator of exactly those boundary conditions. For targeted support in analysis, modernization or operational clarification you can reach us here:
In the technical context, Delphi Thttpclient and REST API file upload also play an important role when integrations, data flows and ongoing development need to work together cleanly.
Discuss a project or modernization initiative with Net-Base.