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:
timeout/deadline/cancellation :: Alice wants to remodel her kitchen and she contacts a popular kitchen remodeller Bob. Bob will buy and install the cabinets and the countertop. Alice wants the kitchen work to begin on a certain day. If the cabinets and the countertop that Alice wants are in stock, then all is well and Bob can order the desired material and begin the work. If some of the cabinets or the countertop is not in stock, Bob cannot start the work (deadline exceeded) and Alice will cancel the project and Bob loses the contract.
For the real use-case, this is similar to a client application making an API call to a service (which may in turn make a database call or other API calls). The client can set a deadline for the API call to finish by. Else, the request is cancelled and an error is reported.
data values :: Alice orders a glass scupture from a foreign country. The item is shipped and delivered by Charlie package delivery service. When the item is picked by Charlie, a unique tracking number is assigned for the shipment and as the item travels through various intermediate destinations, updates on the last location of the item is made using the tracking number.
For the real use-case, this is similar to assigning a unique transaction id for an API request call to a service. The invoked service may pass the original transaction id to other API calls it may invoke.
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
2021/10/30 22:29:24 HTTP content: <h3>Hello from Go Server !!!</h3>
The server will display the following additional output:
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:
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:
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:
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:
2021/10/30 22:45:03 HTTP content: <h3>Hello from Go Server !!!</h3>
The server will display the following additional output:
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:
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:
2021/10/30 22:47:22 Get http://localhost:8080/: context deadline exceeded exit status 1
The server will display the following additional output:
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:
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:
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:
2021/10/30 22:51:37 Get http://localhost:8080/: context deadline exceeded exit status 1
The server will display the following additional output:
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:
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:
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:
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:
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:
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