Previous parts:

Today we will create our own HTTP/3 client stream to send some sweet-sweet data to the server.

Streaming data from the client can be used for sending large or real-time data uploads, such as file uploads, telemetry, or live feeds. With HTTP/3 and Go, you can efficiently stream request bodies without buffering the entire payload in memory.

Streaming Client Request

This is similar to streaming a request body in regular HTTP. We’ll use an io.Pipe to write data from the client as it becomes available, while the server reads the stream as it arrives.

Here is client code that creates a request and streams data to the server at regular intervals:

pipeR, pipeW := io.Pipe()
req, err := http.NewRequest("POST", "https://localhost:8080/stream", pipeR)
...

// send data to the server in a separate goroutine
// at 100ms intervals and close the request body when done,
// indicating that no more data will be sent
go func() {
  defer req.Body.Close()

  for i := 0; i < 10; i++ {
    fmt.Fprintf(pipeW, "data chunk #%d\n", i)
    time.Sleep(100 * time.Millisecond)
  }
}()

resp, err := client.Do(req)
...

The server part is quite simple: we just read the request body and dump it to the console:

mux := http.NewServeMux()
mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
  // copying the request body to stdout
  io.Copy(os.Stdout, r.Body)
})

If we run everything together, we will see the data being printed to the console as it arrives:

$ go run bodystream.go
data chunk #0
data chunk #1
data chunk #2
data chunk #3
...

You can find the complete code in bodystream.go.

Taking Over the Request Stream

We can take full control over the client’s request stream. For this, we need to go a bit lower level and build on top of the QUIC connection. This allows us to create an HTTP/3 connection, send request headers, and stream data directly over it.

First, we need to create a QUIC connection by dialing the server address:

tlsConf := &tls.Config{
  RootCAs:    certPool,
  NextProtos: []string{http3.NextProtoH3}, // advertise HTTP/3 support
}

qconn, err := quic.DialAddr(context.TODO(), "localhost:8080", tlsConf, nil)
...

Then we need to create an HTTP/3 client connection and open a request stream:

tr := &http3.Transport{}
clientConn := tr.NewClientConn(qconn)
stream, err := clientConn.OpenRequestStream(context.TODO())
...

Next, we make an HTTP request to the server. This is where the server can validate the request and allow or deny the request stream:

req, err := http.NewRequest("POST", "https://localhost:8080/stream", http.NoBody)
...
// sending request headers
if err := stream.SendRequestHeader(req); err != nil {
  ... // handle error
}
resp, err := stream.ReadResponse()
if err != nil {
  ... // handle error
}

if resp.StatusCode != http.StatusOK {
  ... // handle non-OK response
}

And finally, we can write data to the stream in a loop, simulating a client that sends data in chunks:

for i := 0; i < 10; i++ {
  fmt.Fprintf(stream, "data chunk #%d\n", i)
  time.Sleep(100 * time.Millisecond)
}

The server can then read the data directly from the HTTP/3 stream. See Overtaking HTTP/3 Stream for more details on how to do this:

mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
  // responding with 200 OK header
  w.WriteHeader(http.StatusOK)
  // taking over the HTTP/3 stream
  streamer := w.(http3.HTTPStreamer)
  http3Stream := streamer.HTTPStream()
  defer http3Stream.Close()
  // dumping the stream to stdout
  io.Copy(os.Stdout, http3Stream)
})

See requeststream.go for the full example.

Congratulations on reaching this far! You are fabulous! ✨

Recap

In this post, we learned how to stream data from a client to an HTTP/3 server in Go, both with the standard library and with direct stream control.

Next Parts:

  • Send HTTP/3 DATAGRAMs