PolarSPARC

Golang - Standard Library context Package


Bhaskar S 10/31/2021


Overview

Have you ever been curious to look at one of the interesting packages in Golang's standard library called context ???

It exposes an interface of type Context, which encapsulates a request timeout (or deadline), a cancellation signal, and request-scoped values, that can transfer across API boundaries and between goroutines.

Not clear and sounds confusing ???

Before we proceed further - one may have a confusion between a timeout and a deadline. A timeout is an absolute value - after the specified duration, the activity needs to time out. A deadline, on the other hand, is a period from the current time, the activity must complete by or is consider breached (exceeded).

Now we can moved on !!!

There are two aspects to the Context type - one is related to the timeout/deadline/cancellation and the other is related to the data values.

We will unravel the two aspects with simple examples and tie them back to the actual use-cases. Here we go:

Hope the above examples help understand the use of the Context type.

Timeout/Deadline/Cancellation

In this section, we will unravel the mystery around the use of the Context type from cancellation.

The following example makes a request to a dummy service http://httpbin.org/delay/5 that is exposed on the Internet for testing purposes. This service endpoint returns back to the caller after a delay of 5 seconds:


Listing.1
package main

/*
    @Author: Bhaskar S
    @Blog:   https://www.polarsparc.com
    @Date:   30 Oct 2021
*/

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

func main() {
  ctx := context.Background()

  req, err := http.NewRequestWithContext(ctx, "GET", "http://httpbin.org/delay/5", nil)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  go func() {
    time.Sleep(3 * time.Second)
    log.Println("Slept for 3 seconds...")
  }()

  res, err := http.DefaultClient.Do(req)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  log.Printf("HTTP Status code: %d", res.StatusCode)
}

In Listing.1 above, the method call context.Background() creates an empty instance of the Context type. This context is passed to the HTTP request we make to the external service. Given the context is empty, there is no impact on the HTTP service and completes with the specified delay.

Executing the program from Listing.1 will generate the following output:

Output.1

2021/10/30 21:39:29 Slept for 3 seconds...
2021/10/30 21:39:31 HTTP Status code: 200

One may wonder what was the purpose of the above code. Just hang in there and we will build on this basic building block.

We will modify the code in Listing.1 above to create an instance of context that can be cancelled. In the following example, we make a request to the same dummy service http://httpbin.org/delay/5. If we let it run without any interruption, the service endpoint will return to the caller after a delay of 5 seconds. However, if we press the ENTER key before the service endpoint completes, we see the request being cancelled and an error returned to the caller:


Listing.2
package main

/*
    @Author: Bhaskar S
    @Blog:   https://www.polarsparc.com
    @Date:   30 Oct 2021
*/

import (
  "bufio"
  "context"
  "log"
  "net/http"
  "os"
)

func main() {
  parent := context.Background()
  ctx, cancel := context.WithCancel(parent)

  req, err := http.NewRequestWithContext(ctx, "GET", "http://httpbin.org/delay/5", nil)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  go func() {
    reader := bufio.NewReader(os.Stdin)
    reader.ReadLine()
    log.Println("Ready to cancel request...")
    cancel()
  }()

  res, err := http.DefaultClient.Do(req)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  log.Printf("HTTP Status code: %d", res.StatusCode)
}

In Listing.2 above, the method call context.WithCancel(parent) creates cancellable instance of the Context type. It returns a new context that wraps the empty parent context and a cancel function. The new cancellable context is passed to the HTTP request we make to the external service.

Executing the program from Listing.2 (without any user interruption) will generate the following output:

Output.2

2021/10/30 21:47:05 HTTP Status code: 200

Let us re-execute the program from Listing.2, but this time press the ENTER key after a second. This will generate the following output:

Output.3

2021/10/30 21:47:16 Ready to cancel request...
2021/10/30 21:47:16 Get http://httpbin.org/delay/5: context canceled
exit status 1

See the intereting behavior ??? When we pressed the ENTER key the function literal (anonymous function) invoked the method cancel(). This allowed the client request http.DefaultClient.Do(req) to be cancelled and return an error.

Moving on, we will modify the code in Listing.2 above to create an instance of context that sets a timeout of 3 seconds. In the following example, we make a request to the same dummy service http://httpbin.org/delay/5. After the 3 seconds duration, the service endpoint request is automatically cancelled since timeout occurs and an error returned to the caller:


Listing.3
package main

/*
    @Author: Bhaskar S
    @Blog:   https://www.polarsparc.com
    @Date:   30 Oct 2021
*/

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

func main() {
  parent := context.Background()
  ctx, cancel := context.WithTimeout(parent, 3*time.Second)
  defer cancel()

  req, err := http.NewRequestWithContext(ctx, "GET", "http://httpbin.org/delay/5", nil)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  res, err := http.DefaultClient.Do(req)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  log.Printf("HTTP Status code: %d", res.StatusCode)
}

In Listing.3 above, the method call context.WithTimeout(parent, 3*time.Second) creates an instance of the Context type with a 3 second timeout, which under-the-hood automatically calls the cancel() on timeout. It returns a new context that wraps the empty parent context and the cancel function. We need to STILL explicitly call the cancel() via the defer keyword. The new context is passed to the HTTP request we make to the external service.

Executing the program from Listing.3 above will generate the following output:

Output.4

2021/10/30 22:08:12 Get http://httpbin.org/delay/5: context deadline exceeded
exit status 1

Let us now implement a simple HTTP server so we can demonstrate soon how to use the context in the server. The following is a simple HTTP server that will listen on port 8080 and respond with a simple hello message:


Listing.4
package main

/*
    @Author: Bhaskar S
    @Blog:   https://www.polarsparc.com
    @Date:   30 Oct 2021
*/

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

func indexHandler(w http.ResponseWriter, r *http.Request) {
  log.Println("indexHandler - start ...")
  defer log.Println("indexHandler - done !!!")

  fmt.Fprintln(w, "<h3>Hello from Go Server !!!</h3>")
}

func main() {
  log.Println("Ready to start server on *:8080...")

  http.HandleFunc("/", indexHandler)
  http.ListenAndServe(":8080", nil)
}

Executing the program from Listing.4 above will generate the following output:

Output.5

2021/10/30 22:29:20 Ready to start server on *:8080...

The following is a simple HTTP client that will connect to the server on port 8080 to receive the simple hello message:


Listing.5
package main

/*
    @Author: Bhaskar S
    @Blog:   https://www.polarsparc.com
    @Date:   30 Oct 2021
*/

import (
  "io/ioutil"
  "log"
  "net/http"
  "os"
)

func main() {
  req, err := http.NewRequest("GET", "http://localhost:8080/", nil)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  res, err := http.DefaultClient.Do(req)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  defer res.Body.Close()

  data, err := ioutil.ReadAll(res.Body)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  log.Printf("HTTP content: %s", data)
}

Executing the program from Listing.5 above will generate the following output:

Output.6

2021/10/30 22:29:24 HTTP content: <h3>Hello from Go Server !!!</h3>

The server will display the following additional output:

Output.7

2021/10/30 22:29:24 indexHandler - start ...
2021/10/30 22:29:24 indexHandler - done !!!

We will now enhance our simple HTTP server to detect request cancellation (either due to a timeout or the client explicitly cancelling the request). The following is the modified version of the simple HTTP server that will listen on port 8080 and respond with a simple hello message:


Listing.6
package main

/*
    @Author: Bhaskar S
    @Blog:   https://www.polarsparc.com
    @Date:   30 Oct 2021
*/

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

func indexHandler(w http.ResponseWriter, r *http.Request) {
  log.Println("indexHandler - start ...")
  defer log.Println("indexHandler - done !!!")

  ctx := r.Context()

  select {
    case <-ctx.Done():
      log.Println(ctx.Err())
      http.Error(w, ctx.Err().Error(), http.StatusExpectationFailed)
    case <-time.After(3 * time.Second):
      fmt.Fprintln(w, "<h3>Hello from Go Server !!!</h3>")
  }
}

func main() {
  log.Println("Ready to start server on *:8080...")

  http.HandleFunc("/", indexHandler)
  http.ListenAndServe(":8080", nil)
}

In Listing.6 above, the method call ctx.Done() returns a channel that is closed and returns if the associated context (on the client) is cancelled either due to a timeout or the client explicitly cancelling the request by invoking the method cancel(). The method call time.After(3 * time.Second) returns a channel and the caller receives the current time (as the message) after the specified elapsed duration. Essentially, the select is waiting for either a cancellation or the request to completed (after a wait time).

Executing the program from Listing.6 above will generate the following output:

Output.8

2021/10/30 22:44:50 Ready to start server on *:8080...

Now, we will enhance our simple HTTP client to use an empty context (which does not have any effect). The following is the modified version of the simple HTTP client that will connect to the server on port 8080 to receive the simple hello message:


Listing.7
package main

/*
    @Author: Bhaskar S
    @Blog:   https://www.polarsparc.com
    @Date:   30 Oct 2021
*/

import (
  "context"
  "io/ioutil"
  "log"
  "net/http"
  "os"
)

func main() {
  ctx := context.Background()

  req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost:8080/", nil)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  res, err := http.DefaultClient.Do(req)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  defer res.Body.Close()

  data, err := ioutil.ReadAll(res.Body)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  log.Printf("HTTP content: %s", data)
}

Executing the program from Listing.7 above will generate the following output:

Output.9

2021/10/30 22:45:03 HTTP content: <h3>Hello from Go Server !!!</h3>

The server will display the following additional output:

Output.10

2021/10/30 22:45:00 indexHandler - start ...
2021/10/30 22:45:03 indexHandler - done !!!

Note that the behavior is similar as in the previous case since we are using an empty context.

Next, we will further enhance our simple HTTP client to set a deadline on the context, which will auto cancel in 2 seconds. The following is the modified version of the simple HTTP client that will connect to the server on port 8080 to receive the simple hello message:


Listing.8
package main

/*
    @Author: Bhaskar S
    @Blog:   https://www.polarsparc.com
    @Date:   30 Oct 2021
*/

import (
  "context"
  "io/ioutil"
  "log"
  "net/http"
  "os"
  "time"
)

func main() {
  ctx := context.Background()
  dur := time.Now().Add(2 * time.Second)
  ctx, cancel := context.WithDeadline(ctx, dur)
  defer cancel()

  req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost:8080/", nil)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  res, err := http.DefaultClient.Do(req)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  defer res.Body.Close()

  data, err := ioutil.ReadAll(res.Body)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  log.Printf("HTTP content: %s", data)
}

Executing the program from Listing.8 above will generate the following output:

Output.11

2021/10/30 22:47:22 Get http://localhost:8080/: context deadline exceeded
exit status 1

The server will display the following additional output:

Output.12

2021/10/30 22:47:20 indexHandler - start ...
2021/10/30 22:47:22 context canceled
2021/10/30 22:47:22 indexHandler - done !!!

Notice how the deadline set on the client is propagating to the server to cancel further processing.

Finally, we will make another tweak to our simple HTTP server demonstrate how a request cancellation from a client cancels all processing on the server. We introduce a dummy database processing step in out HTTP hander. The following is the enhanced version of the simple HTTP server that will listen on port 8080 and respond with a simple hello message:


Listing.9
package main

/*
    @Author: Bhaskar S
    @Blog:   https://www.polarsparc.com
    @Date:   30 Oct 2021
*/

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

func indexHandler(w http.ResponseWriter, r *http.Request) {
  log.Println("indexHandler - start ...")
  defer log.Println("indexHandler - done !!!")

  ctx := r.Context()

  go func() {
    dummyDbHandler(ctx)
  }()

  select {
    case <-ctx.Done():
      log.Printf("indexHandler - %v", ctx.Err())
      http.Error(w, ctx.Err().Error(), http.StatusExpectationFailed)
    case <-time.After(3 * time.Second):
      fmt.Fprintln(w, "<h3>Hello from Go Server !!!</h3>")
  }
}

func dummyDbHandler(ctx context.Context) {
  log.Println("dummyDbHandler - start ...")
  defer log.Println("dummyDbHandler - done !!!")

  select {
  case <-ctx.Done():
    log.Printf("dummyDbHandler - %v", ctx.Err())
  case <-time.After(5 * time.Second):
    log.Println("dummyDbHandler - completed DB operation ...")
  }
}

func main() {
  log.Println("Ready to start server on *:8080...")

  http.HandleFunc("/", indexHandler)
  http.ListenAndServe(":8080", nil)
}

In Listing.9 above, notice have we have propagated the context to the database processing step.

Executing the program from Listing.9 above will generate the following output:

Output.13

2021/10/30 22:51:33 Ready to start server on *:8080...

Re-executing the program from Listing.8 above will generate the following output:

Output.14

2021/10/30 22:51:37 Get http://localhost:8080/: context deadline exceeded
exit status 1

The server will display the following additional output:

Output.15

2021/10/30 22:51:35 indexHandler - start ...
2021/10/30 22:51:35 dummyDbHandler - start ...
2021/10/30 22:51:37 indexHandler - context canceled
2021/10/30 22:51:37 indexHandler - done !!!
2021/10/30 22:51:37 dummyDbHandler - context canceled
2021/10/30 22:51:37 dummyDbHandler - done !!!

Notice how the client deadline is cancelling all the processing steps on the server.

Data Values

In this section, we will unravel the mystery around the use of the Context type from passing request scoped data value like a transaction id.

One last time, we will make modifications to our simple HTTP server to extract the transaction id passed from the client via a request header. The server passes the transaction id as a request-scoped value in the context to the dummy database processing step. The following is the modified version of the simple HTTP server that will listen on port 8080 and respond with a simple hello message:


Listing.10
package main

        /*
           @Author: Bhaskar S
           @Blog:   https://www.polarsparc.com
           @Date:   31 Oct 2021
        */
        
        import (
          "context"
          "fmt"
          "log"
          "net/http"
          "time"
        )
        
        func indexHandler(w http.ResponseWriter, r *http.Request) {
          id := r.Header.Get("PS-TXN-ID")
        
          log.Printf("[%s] indexHandler - start ...", id)
          defer log.Printf("[%s] indexHandler - done !!!", id)
        
          ctx := context.WithValue(r.Context(), "PS-TXN-ID", id)
        
          go func() {
            dummyDbHandler(ctx)
          }()
        
          select {
            case <-ctx.Done():
              log.Printf("[%s] indexHandler - %v", id, ctx.Err())
              http.Error(w, ctx.Err().Error(), http.StatusExpectationFailed)
            case <-time.After(time.Second):
              fmt.Fprintln(w, "<h3>Hello from Go Server !!!</h3>")
          }
        }
        
        func dummyDbHandler(ctx context.Context) {
          id := ctx.Value("PS-TXN-ID")
        
          log.Printf("[%s] dummyDbHandler - start ...", id)
          defer log.Printf("[%s] dummyDbHandler - done !!!", id)
        
          select {
          case <-ctx.Done():
            log.Printf("[%s] dummyDbHandler - %v", id, ctx.Err())
          case <-time.After(time.Second):
            log.Printf("[%s] dummyDbHandler - completed DB operation ...", id)
          }
        }
        
        func main() {
          log.Println("Ready to start server on *:8080...")
        
          http.HandleFunc("/", indexHandler)
          http.ListenAndServe(":8080", nil)
        }

In Listing.10 above, notice have we have propagated the context to the database processing step.

Executing the program from Listing.10 above will generate the following output:

Output.16

2021/10/31 13:56:56 Ready to start server on *:8080...

Finally, we will make an enhancement to our simple HTTP client to pass a unique request scoped transaction id via a HTTP request header. The following is the modified version of the simple HTTP client that will connect to the server on port 8080 to receive the simple hello message:


Listing.11
package main

/*
    @Author: Bhaskar S
    @Blog:   https://www.polarsparc.com
    @Date:   31 Oct 2021
*/

import (
  "context"
  "github.com/google/uuid"
  "io/ioutil"
  "log"
  "net/http"
  "os"
  "time"
)

func makeHttpRequest(ch chan bool) {
  ctx := context.Background()
  dur := time.Now().Add(3 * time.Second)
  ctx, cancel := context.WithDeadline(ctx, dur)
  defer cancel()

  req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost:8080/", nil)
  if err != nil {
    log.Println(err)
    os.Exit(1)
  }

  id := uuid.New()
  req.Header.Set("PS-TXN-ID", id.String())

  res, err := http.DefaultClient.Do(req)
  if err != nil {
    log.Printf("[%s] %v", id, err)
    os.Exit(1)
  }

  defer res.Body.Close()

  data, err := ioutil.ReadAll(res.Body)
  if err != nil {
    log.Printf("[%s] %v", id, err)
    os.Exit(1)
  }

  log.Printf("[%s] HTTP content: %s", id, data)

  ch <- true
}

func main() {
  ch := make(chan bool)

  for i := 1; i <= 3; i++ {
    go makeHttpRequest(ch)
  }

  for i := 1; i <= 3; i++ {
    <-ch
  }
}

Executing the program from Listing.11 above will generate the following output:

Output.17

2021/10/31 13:57:00 [1e16038c-d0d3-44bf-8c8d-51b33189c211] HTTP content: <h3>Hello from Go Server !!!</h3>
2021/10/31 13:57:00 [ce181ca1-2ef4-4fa2-a6b0-acbbce67b5ea] HTTP content: <h3>Hello from Go Server !!!</h3>
2021/10/31 13:57:00 [8c3ba7b3-2551-4378-bf57-bcc41e4b3641] HTTP content: <h3>Hello from Go Server !!!</h3>

The server will display the following additional output:

Output.18

2021/10/31 13:56:59 [1e16038c-d0d3-44bf-8c8d-51b33189c211] indexHandler - start ...
2021/10/31 13:56:59 [ce181ca1-2ef4-4fa2-a6b0-acbbce67b5ea] indexHandler - start ...
2021/10/31 13:56:59 [1e16038c-d0d3-44bf-8c8d-51b33189c211] dummyDbHandler - start ...
2021/10/31 13:56:59 [8c3ba7b3-2551-4378-bf57-bcc41e4b3641] indexHandler - start ...
2021/10/31 13:56:59 [ce181ca1-2ef4-4fa2-a6b0-acbbce67b5ea] dummyDbHandler - start ...
2021/10/31 13:56:59 [8c3ba7b3-2551-4378-bf57-bcc41e4b3641] dummyDbHandler - start ...
2021/10/31 13:57:00 [1e16038c-d0d3-44bf-8c8d-51b33189c211] indexHandler - done !!!
2021/10/31 13:57:00 [1e16038c-d0d3-44bf-8c8d-51b33189c211] dummyDbHandler - context canceled
2021/10/31 13:57:00 [1e16038c-d0d3-44bf-8c8d-51b33189c211] dummyDbHandler - done !!!
2021/10/31 13:57:00 [8c3ba7b3-2551-4378-bf57-bcc41e4b3641] dummyDbHandler - completed DB operation ...
2021/10/31 13:57:00 [ce181ca1-2ef4-4fa2-a6b0-acbbce67b5ea] indexHandler - done !!!
2021/10/31 13:57:00 [8c3ba7b3-2551-4378-bf57-bcc41e4b3641] indexHandler - done !!!
2021/10/31 13:57:00 [8c3ba7b3-2551-4378-bf57-bcc41e4b3641] dummyDbHandler - done !!!
2021/10/31 13:57:00 [ce181ca1-2ef4-4fa2-a6b0-acbbce67b5ea] dummyDbHandler - completed DB operation ...
2021/10/31 13:57:00 [ce181ca1-2ef4-4fa2-a6b0-acbbce67b5ea] dummyDbHandler - done !!!

Notice how the unique transaction id set on the client is propagating to all parts of the server.

References

Standard Library Package - context

Source Code - Github



© PolarSPARC