Not sure how to structure your Go web application?

My new book guides you through the start-to-finish build of a real world web application in Go — covering topics like how to structure your code, manage dependencies, create dynamic database-driven pages, and how to authenticate and authorize users securely.

Take a look!

How to use the http.ResponseController type

Published on:

One of my favorite things about the recent Go 1.20 release is the new http.ResponseController type, which brings with it three nice benefits:

  1. You can now override your server-wide read and write deadlines on a per request basis.
  2. The pattern for using the http.Flusher and http.Hijacker interfaces is clearer and feels less hacky. No more type assertions necessary!
  3. It makes it easier and safer to create and use custom http.ResponseWriter implementations.

The first two benefits are mentioned in the release notes, but the third one seems to have gone under the radar a bit... which is a shame, because it's very helpful!

Let's dive in a take a look.

Per-request deadlines

Go's http.Server has ReadTimeout and WriteTimeout settings, which you can use to automatically close a HTTP connection if reading a request or writing response takes longer than a fixed amount of time. These settings are server-wide and apply to all requests, irrespective of the handler or URL.

With http.ResponseController you can now use the SetReadDeadline() and SetWriteDeadline() methods to relax or tighten these settings on a per-request basis if you need too. For example:

func exampleHandler(w http.ResponseWriter, r *http.Request) {
    rc := http.NewResponseController(w)

    // Set a write deadline in 5 seconds time.
    err := rc.SetWriteDeadline(time.Now().Add(5 * time.Second))
    if err != nil {
        // Handle error
    }

    // Do something...

    // Write the response as normal.
    w.Write([]byte("Done!"))
}

This is particularly helpful in an application where you have a small number of handlers that need longer deadlines than all the others, for things like processing a file upload or carrying out a long-running operation.

A few other details to mention:

  • If you set a short server-wide deadline, and that deadline is hit before you call SetWriteDeadline() or SetReadDeadline() then they will have no effect. The server-wide deadline wins.
  • If your underlying http.ResponseWriter doesn't support setting per-request deadlines, then calling SetWriteDeadline() or SetReadDeadline() will return a http.ErrNotSupported error.
  • You can effectively remove the server-wide deadline on a per-request basis by passing a zero-valued time.Time struct to SetWriteDeadline() or SetReadDeadline(). For example:
rc := http.NewResponseController(w)
err := rc.SetWriteDeadline(time.Time{})
if err != nil {
    // Handle error
}

Flusher and Hijacker interfaces

The http.ResponseController type also makes it slightly nicer to use the 'optional' http.Flusher and http.Hijacker interfaces. For example, before Go 1.20 you would use a code pattern like this this to flush response data to the client:

func exampleHandler(w http.ResponseWriter, r *http.Request) {
    f, ok := w.(http.Flusher)
    if !ok {
        // Handle error
    }

    for i := 0; i < 5; i++ {
        fmt.Fprintf(w, "Write %d\n", i)
        f.Flush()

        time.Sleep(time.Second)
    }
}

Now you can do this:

func exampleHandler(w http.ResponseWriter, r *http.Request) {
    rc := http.NewResponseController(w)

    for i := 0; i < 5; i++ {
        fmt.Fprintf(w, "Write %d\n", i)
        err := rc.Flush()
        if err != nil {
            // Handle error
        }

        time.Sleep(time.Second)
    }
}

The pattern for hijacking a connection is similar:

func (app *application) home(w http.ResponseWriter, r *http.Request) {
    rc := http.NewResponseController(w)

    conn, bufrw, err := rc.Hijack()
    if err != nil {
        // Handle error
    }
    defer conn.Close()

    // Do something...
}

Again, if your underlying http.ResponseWriter doesn't support support flushing or hijacking, then calling Flush() or Hijack() on a http.ResponseController will also return an http.ErrNotSupported error.

Custom http.ResponseWriters

It's now also easier and safer to create and use custom http.ResponseWriter implementations that still support flushing and hijacking.

It's probably easiest to explain how this works with an example, so let's look at the code for a custom http.ResponseWriter implementation that records the HTTP status code of a response.

type statusResponseWriter struct {
    http.ResponseWriter // Embed a http.ResponseWriter
    statusCode    int
    headerWritten bool
}

func newstatusResponseWriter(w http.ResponseWriter) *statusResponseWriter {
    return &statusResponseWriter{
        ResponseWriter: w,
        statusCode:     http.StatusOK,
    }
}

func (mw *statusResponseWriter) WriteHeader(statusCode int) {
    mw.ResponseWriter.WriteHeader(statusCode)

    if !mw.headerWritten {
        mw.statusCode = statusCode
        mw.headerWritten = true
    }
}

func (mw *statusResponseWriter) Write(b []byte) (int, error) {
    mw.headerWritten = true
    return mw.ResponseWriter.Write(b)
}

func (mw *statusResponseWriter) Unwrap() http.ResponseWriter {
    return mw.ResponseWriter
}

So here we've defined a custom statusResponseWriter type, which embeds an existing http.ResponseWriter and implements custom WriteHeader() and Write() methods to support the recording of the HTTP response status code.

But the important thing to notice here is the Unwrap() method at the end, which returns the original embedded http.ResponseWriter.

When you use the new http.ResponseController type to to flush, hijack or set a deadline, it will call this Unwrap() method to access the original http.ResponseWriter. This is done recursively if necessary, so you can potentially layer multiple custom http.ResponseWriter implementations on top of each other.

Let's look at a complete example, where we use this statusResponseWriter in conjunction with some middleware to log response status codes, along with a handler that sends a 'normal' response and another that uses the new http.ResponseController type to send a flushed response.

package main

import (
    "log"
    "net/http"
    "time"
)

type statusResponseWriter struct {
    http.ResponseWriter // Embed a http.ResponseWriter
    statusCode    int
    headerWritten bool
}

func newstatusResponseWriter(w http.ResponseWriter) *statusResponseWriter {
    return &statusResponseWriter{
        ResponseWriter: w,
        statusCode:     http.StatusOK,
    }
}

func (mw *statusResponseWriter) WriteHeader(statusCode int) {
    mw.ResponseWriter.WriteHeader(statusCode)

    if !mw.headerWritten {
        mw.statusCode = statusCode
        mw.headerWritten = true
    }
}

func (mw *statusResponseWriter) Write(b []byte) (int, error) {
    mw.headerWritten = true
    return mw.ResponseWriter.Write(b)
}

func (mw *statusResponseWriter) Unwrap() http.ResponseWriter {
    return mw.ResponseWriter
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/normal", normalHandler)
    mux.HandleFunc("/flushed", flushedHandler)

    log.Print("Listening...")
    err := http.ListenAndServe(":3000", logResponse(mux))
    if err != nil {
        log.Fatal(err)
    }
}


func logResponse(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        sw := newstatusResponseWriter(w)
        next.ServeHTTP(sw, r)
        log.Printf("%s %s: status %d\n", r.Method, r.URL.Path, sw.statusCode)
    })
}

func normalHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusTeapot)
    w.Write([]byte("OK"))
}

func flushedHandler(w http.ResponseWriter, r *http.Request) {
    rc := http.NewResponseController(w)

    w.Write([]byte("Write A...."))
        err := rc.Flush()
    if err != nil {
        log.Println(err)
        return
    }

    time.Sleep(time.Second)

    w.Write([]byte("Write B...."))
    err = rc.Flush()
    if err != nil {
        log.Println(err)
    }
}

If you want, you can run this and try making requests to the /normal and /flushed endpoints:

$ curl http://localhost:3000/normal
OK

$ curl --no-buffer http://localhost:3000/flushed
Write A....Write B....

You should see the response from the flushedHandler in two parts, first the Write A... part, then followed a second later by the Write B... part.

And you should see that the statusResponseWriter and logResponse middleware have successfully written log messages, including the correct HTTP status code for each response.

$ go run main.go 
2023/03/06 21:41:21 Listening...
2023/03/06 21:41:32 GET /normal: status 418
2023/03/06 21:41:44 GET /flushed: status 200