Net-Base Magazin

27.05.2026

Multipart/Form-Data Upload in Delphi: robuste Streams, Boundary-Kontrolle und Debugging ohne Rätselraten

Multipart/Form-Data Uploads wirken trivial, kippen aber in Delphi schnell bei Streams, Dateinamen, Content-Type, Boundary-Handling und Timeouts. Dieser Source-Schnipsel zeigt eine robuste, debugbare Implementierung mit THTTPClient – inkl. korrekt berechnetem Content-Length...

27.05.2026

Warum Multipart in Delphi oft erst im Betrieb „kaputtgeht“

Ein Multipart/Form-Data Upload in Delphi ist schnell zusammengeklickt – und scheitert dann in realen Integrationen an Details: falscher Content-Type pro Part, ein Boundary-String, der versehentlich im Payload vorkommt, unpassende Zeilenumbrüche, nicht-ASCII-Dateinamen oder Server, die chunked transfer encoding (HTTP ohne Content-Length) ablehnen. Dazu kommen typische Praxisprobleme in individueller Unternehmenssoftware: große Dateien (CAD, PDFs, Scans), schwankende Netze, Reverse-Proxies, strikte API-Gateways und Admin-Anforderungen an Debugging.

Delphi bringt mit System.Net.HttpClient einen brauchbaren Stack mit, aber die „Happy Path“-Beispiele lassen wichtige Randbedingungen offen. Der folgende Source-Schnipsel geht bewusst tiefer: Wir bauen Multipart als Stream deterministisch auf, berechnen Content-Length korrekt, unterstützen RFC-5987 für Dateinamen und liefern eine Debug-Option, die den Request reproduzierbar macht, ohne dass Sie TLS aufbrechen müssen.

Architekturentscheidung: THTTPClient statt Indy – und wann das kippt

THTTPClient (System.Net) nutzt je nach Plattform unterschiedliche Backends (unter Windows typischerweise WinHTTP/WinINet). Das ist für Unternehmensumgebungen oft vorteilhaft: Proxy- und TLS-Policies sind eher kompatibel mit dem System. Indy ist dafür sehr transparent und anpassbar, bringt aber eigene TLS-Bindings und ist im Betrieb manchmal „separat zu pflegen“ (OpenSSL-Versionen, Cipher-Suiten).

Der Ansatz hier nutzt THTTPClient, weil er in Modernisierungen häufig schon im Einsatz ist (REST-Client, OAuth, Downloads). Wenn Sie jedoch harte Kontrolle über TLS-Handshakes, Client-Zertifikate in Sonderformen oder sehr spezielle Proxy-Ketten benötigen, kann Indy (oder ein dedizierter HTTP-Stack) sinnvoll sein. Das ändert am Multipart-Aufbau wenig – aber an Debugging und Betrieb.

Multipart/Form-Data Upload in Delphi: ein Stream, keine Magie

Die Kernidee: Multipart ist am Ende nur ein Byte-Stream. Wenn wir ihn selbst aufbauen, können wir:

  • Boundary bewusst wählen und stabil testen
  • Header pro Part korrekt setzen (inkl. Content-Disposition, Content-Type)
  • Content-Length zuverlässig berechnen (wichtig für Server ohne Chunked-Support)
  • große Dateien streamen, ohne alles im RAM zu halten

Der Code: Multipart-Builder mit Streaming und RFC-5987-Dateinamen

Der Builder unten erzeugt wahlweise einen rein speicherbasierten Body (für kleine Uploads) oder eine Spool-Datei auf Disk (für große Payloads). Das wirkt „oldschool“, ist aber im Betrieb extrem praktisch, weil es Chunked vermeidet und Debugging erleichtert. Spoolen heißt: Sie können denselben Request-Body wiederverwenden, auch wenn ein Retry nötig ist.

Delphi
unit NetBase.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);

    // Baut den kompletten Body in einen Stream. Wenn ASpoolToFile leer ist,
    // wird ein TMemoryStream verwendet; sonst eine Datei erzeugt.
    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<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 sollte hinreichend zufällig sein. Wichtig: keine Leerzeichen.
  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-Header sind ASCII. Für Werte im Body (z. B. UTF-8) setzen wir Content-Type pro 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''..." ist für Nicht-ASCII-Dateinamen deutlich robuster als nur 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 darf nicht nil sein');

  if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
    ; // erlaubt, aber oft ein Fehler: leere Datei

  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
    // Achtung: Streamposition wird konsumiert.
    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, sofern charset=utf-8 gesetzt ist.
        WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
        WriteBytes(WriterUtf8);
        WriteBytes(CRLF);
      end
      else
      begin
        // Zwei Dateiname-Parameter: filename (für alte Server) und 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);

        // Wichtig: Position auf Anfang setzen, sonst werden nur Reste hochgeladen.
        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.

Was der Code bewusst anders macht

  • Kein „automatisches Multipart“: Die Kontrolle über Header, Encodings und Boundary bleibt bei Ihnen. Das ist bei strikten REST-APIs oft entscheidend.
  • RFC-5987-Unterstützung über filename*: Sobald Dateinamen Umlaute enthalten (z. B. „Prüfbericht.pdf“), ist das der häufigste Interop-Bug. Manche Server ignorieren filename*, dann greift filename als Fallback.
  • Spool-to-File als Betriebsfeature: Für große Uploads und Retries ist ein wiederverwendbarer Body-Stream Gold wert.
  • Content-Length ist verfügbar, weil der Body vorab erzeugt wird. Das vermeidet Chunked-Encoding, falls das Zielsystem es nicht akzeptiert.

Request senden: Timeouts, Header und eine sinnvolle Retry-Strategie

Multipart selbst löst noch nicht die Integrationsprobleme: Sie brauchen Timeouts, Fehlerklassifikation und optional Retries. Wichtig ist die Unterscheidung zwischen idempotent und nicht idempotent: Uploads sind häufig nicht idempotent (Dubletten möglich). Retries sollten daher nur erfolgen, wenn der Server eine idempotente Semantik anbietet (z. B. Upload-ID, dedizierter Idempotency-Key Header) oder Sie serverseitig Deduplizierung haben.

Delphi
uses
  System.SysUtils, System.Classes, System.Net.HttpClient, System.Net.URLClient,
  NetBase.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;

Stolperfallen in der Praxis

  • Stream-Position: Wenn der FileStream nicht auf Position 0 steht, laden Sie nur den Rest hoch. Im Builder wird daher Seek(0) erzwungen.
  • Chunked vs. Content-Length: Einige Gateways (oder ältere Server-Stacks) lehnen Chunked ab. Das ist ein häufiger Legacy-Fall in prozessnahen Softwarelösungen. Spool-to-File ist dann pragmatisch.
  • CRLF: Multipart erwartet CRLF (#13#10), nicht nur LF. Manche Server sind tolerant, andere nicht.
  • Content-Type pro Datei: Wenn Sie pauschal application/octet-stream senden, ist das oft ok. Wenn der Server prüft (z. B. PDF), setzen Sie korrekt. In Delphi können Sie MIME-Mapping über eigene Tabelle oder OS-Funktionen lösen, aber verlassen Sie sich nicht blind auf Dateiendungen.

Debugging: reproduzierbarer Wire-Dump ohne TLS-Aufbruch

Bei HTTPS sehen Sie den Body nicht im Proxy, wenn Sie kein MitM (z. B. Fiddler-Zertifikat) einsetzen dürfen. Das ist in Unternehmensumgebungen normal. Der Builder hilft, weil Sie den kompletten Body streambasiert besitzen und (bei Spool-Datei) als Datei vorliegen haben.

Bewährtes Vorgehen:

  1. Schreiben Sie den Spool-Body in eine temporäre Datei.
  2. Loggen Sie Content-Type inkl. Boundary und Content-Length.
  3. Erzeugen Sie für Support/DevOps optional ein curl-Repro: Hier müssen Sie nicht den Body 1:1 wiedergeben, aber Sie können die Parameter und Datei(n) spiegeln.

Wichtig: Loggen Sie niemals produktive Tokens oder personenbezogene Inhalte. In vielen Business-Software-Integrationen ist genau das der compliance-relevante Teil.

Varianten: mehrere Dateien, optionale Felder, Server mit „komischen“ Erwartungen

Mehrere Dateien unter demselben Feldnamen

Viele APIs erwarten files[] oder mehrfach denselben Namen. Der Builder unterstützt das direkt: Rufen Sie AddFile mehrfach mit demselben FieldName auf. Ob Sie files, files[] oder attachments verwenden, ist reine Serverkonvention.

Server verlangt exakt „application/json“ als zusätzlichem Part

Ein verbreitetes Muster: Ein JSON-Metadatenblock plus Datei. Dann senden Sie das JSON als Field-Part, aber mit Content-Type: application/json; charset=utf-8. Das ist kein „Form Field“ im UI-Sinn, aber in Multipart sauber abbildbar:

Delphi
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');

Legacy: Server akzeptiert nur filename, nicht filename*

Dann hilft der Fallback über filename. Wenn der Server allerdings nicht-ASCII in filename falsch dekodiert, bleibt als robuster Weg häufig nur: Dateinamen serverseitig ignorieren und stattdessen ein zusätzliches Feld originalName im JSON mitsenden.

Einordnung für Modernisierung und Betrieb

In gewachsenen Delphi-Landschaften hängt Multipart oft am Rand: eine Schnittstelle zu DMS, Archiv, Ticketing, Kundenportal oder ein interner REST-Server. Genau dort entsteht Druck durch neue Sicherheitsanforderungen (TLS, Gateways, Proxies) und durch höhere Dateigrößen.

Der vorgestellte Ansatz lohnt sich besonders, wenn:

  • Sie Uploads reproduzierbar debuggen müssen (Betrieb/Administration)
  • Sie Chunked vermeiden wollen/müssen
  • Dateinamen/Encodings in der Praxis wirklich auftreten (Umlaute, Leerzeichen, Klammern)
  • Retry/Idempotency konzeptionell sauber gelöst werden soll

Er lohnt sich weniger, wenn Sie ausschließlich kleine Dateien an einen toleranten Server schicken und keinerlei Betriebstransparenz brauchen. Dann ist eine einfache High-Level-Lösung ausreichend – bis die erste „komische“ Datei aus der Fachabteilung kommt.

Fazit: Stabiler Multipart-Upload ist ein Streaming- und Betriebsproblem

Ein sauberer Multipart/Form-Data Upload in Delphi ist weniger eine Frage von „welcher Komponente“ als von Kontrolle: Boundary, CRLF, Dateiname, Content-Type und vor allem ein deterministischer Body-Stream. Wer das früh sauber baut, spart später Zeit in Debugging-Schleifen mit API-Gateways und Reverse-Proxies.

Einsatzgrenze des Ansatzes: Wenn Sie extrem große Dateien (mehrere GB) ohne Spooling und ohne Content-Length hochladen müssen, wird das Thema Streaming ohne Vorabberechnung relevant – dann müssen Zielserver und Infrastruktur Chunked zuverlässig unterstützen, und Sie brauchen ein anderes Debugging-Konzept. Für viele Integrationen in digitalen Unternehmenslösungen ist der hier gezeigte Builder jedoch genau die pragmatische Mitte aus Robustheit, Nachvollziehbarkeit und kontrollierbarem Ressourcenverbrauch.

Wenn Sie an einer gewachsenen Delphi-Integration hängen, bei der Uploads sporadisch scheitern oder nur „bei manchen Dateien“, ist das meist ein Indikator für genau diese Randbedingungen. Für gezielte Unterstützung bei Analyse, Modernisierung oder Betriebsklärung erreichen Sie uns hier:

Im fachlichen Umfeld spielen auch Delphi Thttpclient und REST API Datei-Upload eine wichtige Rolle, wenn Integrationen, Datenflüsse und Weiterentwicklung sauber zusammenspielen müssen.

Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.

Beitrag teilen

Diesen Beitrag direkt weitergeben

LinkedIn, X, XING, Facebook, WhatsApp und E-Mail sind sofort verfügbar. Für Instagram bereiten wir Link und Kurztext direkt vor.

E-Mail

Instagram oeffnet in einem neuen Tab. Link und Kurztext werden vorher in die Zwischenablage kopiert.