HTTP Upload unterbrechen

In den vergangenen Wochen beschäftigte mich das Thema HTTP wieder einmal.

Und ich freue mich stets aufs Neue, wenn nach langem herumprobieren sogar meine Kollegen zu folgendem Schluss kommen (den auch ich immer favorisiere):

Wir MÜSSEN einen eigenen Client bzw. einen eigenen Server schreiben.


Wenn ein HTTP Client vom Server Daten herunterlädt und während dieses Vorganges darauf kommt, dass er die Daten nicht mehr braucht, schließt er einfach die Verbindung.
Der Server scheitert dann beim Nachpumpen weiterer Daten an einem geschlossenen Socket, bricht ab, und alles ist gut.

Doch wie sieht es meinem Upload aus?

Theoretisch genau so. Will ein Server mit einem Client nicht mehr reden, legt er auf … ähm, schließt die Verbindung und lässt den Client in einen Socket-Fehler bzw eine Exception hineinlaufen.

Aber ist das auch “gut” so?

Nun, besser wäre es, dem Client mitzuteilen, dass man aus bestimmten Gründen keine Daten mehr von ihm haben möchte, also warum nicht einen HTTP Status Code zurücksenden …
und genau hier beginnen die Probleme.

HTTP ist nur half-duplex fähig!

Wie bei den klassischen Star Trek Serien kann immer nur einer Reden, die anderen schweigen aus Respekt und warten bis sie dran sind.

Und so sind auch die meisten APIs von HTTP Implementierungen designt. Es läuft immer nach dem gleichen Schema ab.

 1  auto&& source = openSourceToBeUploaded();
 2  auto&& request = createHttpRequest(toDestinationServer);
 3  auto&& upload = request.createUploadStream();
 4  while(!source.endOfStream())
 5  {
 6    char buffer[4096];
 7    size_t bytes_received = source.read(buffer, sizeof(buffer));
 8    upload.write(buffer, bytes_received);
 9  }
10  upload.close();
11  auto&& response = sendHttpRequest(request);
12  auto&& statusCode = response.getHttpStatusCode();
13  return statusCode;

Jetzt haben aber Kollegen in meiner Firma ein Protokoll spezifiziert, wo während eines Uploads die Daten am Server validiert werden und im Falle eines Fehlers abgebrochen werden soll.

Schließlich macht es keinen Sinn 1 GB Daten hochzuladen, wenn schon nach den ersten 10 Bytes erkannt wird, dass die Upload-Daten nicht verarbeitet werden können.

In der Theorie (und eigentlich auch in der Praxis) kann man jetzt am Server bei einem Fehler das weitere Lesen des eintreffenden Uploads aussetzen und gleich einen HTTP Response zurückschicken und danach die Verbindung “irgendwie” schließen.

Unsere Implementierung in POCO macht das auch offenbar so, doch dann ergibt sich folgendes Problem:

Der vom Server (nachweislich) versendete HTTP Status Header wird am Client nicht empfangen, sondern alles läuft auf einen TCP-Connection Reset hinaus.

Das ist eigentlich auch logisch, denn wie der obige Pseudocode zeigt, befinden wir uns in einer Upload-Schleife, die bröckchenweise Daten verschifft, doch sobald die andere Seite aufhört zu lesen, wird auch das Schreiben am Client blockiert. Ein socket.send() wartet also so lange, bis der Server alles (bzw. etwas) verarbeitet hat um dann fortsetzen zu können.

Wird dann auch noch während dessen die Verbindung geschlossen, kommt es zu einem Fehler beim Senden und diese Exception schlägt dann “durch” und verhindert das Empfangen der HTTP Antwort.

Was man bräuchte, wäre eine Funktion tryReceiveResponse(), die man vor und/oder nach jedem Sendevorgang aufruft um zu Prüfen, ob schon etwas empfangen wurde.

So eine Methode existiert aber bei den gängigen HTTP Clients nicht.
An der Stelle freue ich mich, dass ich noch mit Sockets umgehen und so etwas selbst schreiben kann.

Doch auch das reicht nicht. Der Sendefehler muss ebenso abgefangen und an der Stelle noch ein Versuch gestartet werden, eine Antwort zu empfangen.

Damit würde unser Code dann theoretisch etwa so aussehen:

 1  auto&& source = openSourceToBeUploaded();
 2  auto&& request = createHttpRequest(toDestinationServer);
 3  auto&& upload = request.createUploadStream();
 4  try
 5  {
 6    while(!source.endOfStream())
 7    {
 8      char buffer[4096];
 9      size_t bytes_received = source.read(buffer, sizeof(buffer));
10      auto&& response = tryReceiveResponse(request);
11      if(response)
12      {
13        auto&& statusCode = response.getHttpStatusCode();
14        return statusCode;
15      }
16      upload.write(buffer, bytes_received);
17      upload.close();
18    }
19  }
20  catch(...)
21  {
22  }
23  auto&& response = sendHttpRequest(request);
24  auto&& statusCode = response.getHttpStatusCode();
25  return statusCode;

Mit der Variante kommt man schon ein Stückchen weiter, aber perfekt ist das leider auch nicht.

Hier kommt es nämlich auf den Server an, und die meisten Implementierungen schließen ihren Empfangskanal, sobald sie eine Antwort gesendet haben. Und das führt - wie erwähnt - zu einem TCP Connection Reset am Client, doch es kann auch bewirken, dass durch das Betriebssystem Daten am Socket verworfen werden, die zwar empfangen aber noch nicht an den Usermode Client ausgeliefert wurden.

Mit anderen Worten: tryReceiveResponse liefert nicht immer ein Ergebnis.
Und daran sind der Server Schuld.

Wie könnte man es richtig lösen?

Nun, würden die Server ihre Sockets nach dem Antwort-Versand nicht schließen (bzw. receive-shutdown-nen) und würden Clients nicht mit hirnlos mit send() arbeiten, sondern per select() oder poll() nur dann Lesen und Schreiben, wenn es gerade möglich ist, dann könnten wir recht zuverlässig HTTP Upload Unterbrechungen mit “Out-of-order-HTTP-Responses” zurückweisen und die Clients könnten “freiwillig” ihren Upload einstellen und am Ende trennen beide Seiten im Einvernehmen ihre Verbindungen.

Doch leider funktionieren die Implementierungen, die ich kenne, nicht so.

“Meine” POCO-Server Lösung

Der POCO HTTP Server lässt uns nicht kontrollieren, wann der Socket geschlossen wird. Ein künstliches sleep() in der Empfangsroutine hilft auch nicht, weil POCO Nachrichten erst dann verschickt, wenn aus der benutzerdefinierten Behandlungsroutine zurückgekehrt wird (wir also die Kontrolle abgegeben haben).

Einen kleinen Trick gibt es aber:
Chunked HTTP Transfer.

Aktiviert man diesen beim Client und am Server, erhält man von POCO einen HTTP-Response Stream, in den man den Download schreiben sollte. Der HTTP Header wird zuvor jedoch abgeschlossen und zum Client geschickt.

Hier kann ein sleep() Wunder wirken. Wir wollen nämlich nur einen HTTP-Response-Header verschicken, wo der Status drinnen steht und das können wir. Nun wartet der Client aber noch auf Content, auf den wir ihn kurz warten lassen. Dann senden wir “nichts” und geben die Kontrolle im Anschluss an POCO zurück. Dieses schreibt dann eine Chunked-End-of-stream Nachricht.

An dieser Stelle kann jetzt genau das Gleiche passieren, wie oben auch: Nämlich ein Server-Socket-Close verhindert, dass diese letzte Nachricht empfangen wird. Aber wir haben zumindest den Header mit dem Status empfangen können und wissen nun, dass der Server den Upload nicht haben wollte.

Der Ablauf sieht dann so aus:

  1. Client schickt seinen Header (mit Transfer-Encoding: chunked).
  2. Server nimmt Header an und springt in die Behandlungsroutine.
  3. Client schickt stückweise den Upload und versucht parallel auch eine Antwort zu lesen.
  4. Server stellt einen Fehler fest und sendet den Response-Header mit dem HTTP Status
  5. Client empfängt den Header und bricht seine weiteren Lese und Schreibvorgänge ab.
  6. Server schließt den chunked Transfer ab (ohne Content zu senden) und trennt die Verbindung
  7. Client empfängt
    1. entweder den Chunk-Abschluss und schließt seine Verbindung
    2. alternativ einen Connection-Reset und schließt ebenso seine Verbindung

Fazit

HTTP/1 is a bitch!

Einfache Fälle sind dank vieler verfügbarer Implementierungen “einfach” zu behandeln. Aber sobald man etwas über den Tellerrand hinausblicken möchte, wird es kompliziert und ohne Eigenimplementierungen teilweise sogar unmöglich.

Aber … eben genau solche Aufgaben erfreuen mich sehr.
Da hat man als Programmierer endlich mal wieder Chance “ein Wundermittel” zu erfinden, dass es sonst nirgendwo auf der Welt gibt.

OK … es sind keine Wundermittel sondern “dirty hacks” … aber es ist trotzdem geil sie anwenden zu können.