The Smoking Duck Blog

On Cancel Propagation in Web Servers

On Cancellation

Consider the common situation of a web server making a call to the database then returning the data to the client .

What would happen if the client cancelled the request in the middle ?This could be due to that it lost connection or just simply if the user cancels the request from the browser while it’s still loading by closing the browser or simply clicking x. The Server and Database layers would still process the request and take resources and all this effort would render no profit since the data would not be sent to the client.

What if the third layer is not a locally running Database . It’s rather a service like firebase authentication or another Api call that could timeout. You would need to cancel the other working services to stop them from consuming resources since the request will never be processed properly.

The context Package

The context package provides a way to propagate that cancel action accross layers .

Incoming requests to the server would create a Context and pass this context to the outgoing call to the proceding service , which in turn passes the same Context or a Child of it to the services it calls. This creates a chain of Contexts through function calls. Canceling one Context would cancel all Child Contexts derived from it.

Creating a child Context happens through the functions WithCancel , WithDeadline and WithTimeout which take a Context as a parameter and returns a Child Context and CancelFunc . Calling the CancelFunc would cancel the Child Context and remove the parent’s reference to the child.

A Practical Example

We are going to build a simple web server that returns the latest news headlines . Upon a request from a client the server would call an Api serivice (newsApi.org) to get the latest headlines and return them to the user.

We are going to use the context package to cancel the request to the newsApi if the user cancels the request or return a timeout message if the call to newsApi takes more than 3 seconds.

1. Get API Token

Head over to NewsApi and get an API token to be able to query the API for the latest Headlines.

2. Create the web server

In our main function we would start a server on port 3000 and pass incoming requests to a handler function.

package main

import (
    "net/http"
    "log"
)

func main(){
    // pass incoming requests to handler function
    http.HandleFunc("/", handler)

    // Start server and on port 3000
    log.Fatal(http.ListenAndServe(":3000", nil))
}

3. Create handle function

Our handler function would do the following

    1. First it will create a context from the incoming request.
    2. Create a child context derived from it with the WithTimeout function.
    3. Call a function to get articles and pass the Child Context and CancelFunc to it.
    4. Get the errors from the child functions.
    5. If there is error return 'Timeout error to the client'.
    6. If there is no errors return what function getArticles returned.

it will be as follows :

func handler(w http.ResponseWriter, r *http.Request) {
    // Create a context from request
    ctx := r.Context()

    // Create child context which times out after two seconds
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	log.Printf("Handler Started")
    defer log.Printf("Handler Ended")

    // Pass ctx and the CanelFunc to getArticles function
    art := getArticles(ctx, cancel)

    // Get the context errors
    err := ctx.Err()
    if err != nil {
        fmt.Fprintf(w, "Time out error \n")
    } else {
        fmt.Fprintf(w, "%v\n", art)
    }
}

4. Create getArticles function

func getArticles(ctx context.Context, cancel context.CancelFunc) []string {

    // Call get news function and pass the context to it
	resp, err := getNews(ctx, "https://newsapi.org/v2/top-headlines?country=us&apiKey=<API_KEY>")
	if err != nil {
        log.Printf("Context Canceled")
        // call the cancel func if getNews returns error
		cancel()
    }
    // parse the response into a struct
	c := new(ResponseCast)
    r := c.parseNews(resp)

    // return only the article titles in slice
    var t []string
    for i, article := range r.Articles {
        t = append(t, fmt.Sprintf("Article  %d  : %s \n", i, article.Title))
    }
    return t
}

5. Create getNews function

func getNews(ctx context.Context, link string) ([]byte, error) {

    // Create a request
	request, err := http.NewRequest(http.MethodGet, link, nil)
	if err != nil {
		return nil, err
    }
    // Mount the context onto that request
    request = request.WithContext(ctx)

    // Perform the request
	resp, err := http.DefaultClient.Do(request)
	if err != nil {
		return nil, err
    }

    // Read response body
	defer resp.Body.Close()
	a, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	return a, nil
}

Full Code with helper functions and structs

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"time"
)

type ResponseCast struct {
	Status       string    `json:"status"`
	TotalResults int       `json:"totalResults"`
	Articles     []Article `json:"articles"`
}

type Article struct {
	Author      string `json:"author"`
	Title       string `json:"title"`
	Description string `json:"description"`
	Url         string `json:"url"`
	ImageUrl    string `json:"urlToImage"`
	PublishedAt string `json:"publishedAt"`
	Content     string `json:"content"`
}

func getNews(ctx context.Context, link string) ([]byte, error) {
	request, err := http.NewRequest(http.MethodGet, link, nil)
	if err != nil {
		return nil, err
	}
	request = request.WithContext(ctx)
	resp, err := http.DefaultClient.Do(request)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	a, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	return a, nil
}
func (c *ResponseCast) parseNews(ba []byte) *ResponseCast {
	json.Unmarshal(ba, c)
	return c
}

func getArticles(ctx context.Context, cancel context.CancelFunc) []string {
	resp, err := getNews(ctx, "https://newsapi.org/v2/top-headlines?country=us&apiKey=<API-KEY>")
	if err != nil {
		log.Printf("Context Canceled")
		cancel()
	}
	c := new(ResponseCast)
	r := c.parseNews(resp)
	var t []string
	for i, article := range r.Articles {
		t = append(t, fmt.Sprintf("Article  %d  : %s \n", i, article.Title))
	}
	return t
}
func main() {
	http.HandleFunc("/", handler)
	log.Fatal(http.ListenAndServe(":3000", nil))
}

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	log.Printf("Handler Started")
	defer log.Printf("Handler Ended")
	art := getArticles(ctx, cancel)
	err := ctx.Err()
	if err != nil {
		fmt.Fprintf(w, "Time out error \n")
	} else {
		fmt.Fprintf(w, "%v\n", art)
	}
}