adrianhesketh.com

Cancelling Go network requests

I wanted to update my Gemini browser [0] to respond to Ctrl-C to cancel network requests. At the moment, there’s a short timeout to catch when servers are too slow to respond, but I want to increase the timeout and let the user decide when they’ve waited long enough for the server to respond.

When I’m planning a feature, I like to build a minimal example outside of the codebase I’m working in, before merging it back in. This lets me test out the ideas in isolation before thinking about how it will work alongside everything else.

The basic requirements are to:

  • Be able to cancel a request with Ctrl-C
  • Still support having a timeout

Handle the Ctrl-C signal

The os/signal povides the signal.Notify fuction that sends to a channel when an operating system signal is sent.

Go channels are an in-memory queue of messages between goroutines. I think of Go channels as being a pub/sub queue like AWS SQS, except that it can also be blocking (waits for the subscriber to collect the message before proceeding) rather than non-blocking (dumps the message in the queue and carries on).

signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt)

Create a cancellable context

Notifying a channel when Ctrl-C is pressed is useful, but we want to wire this up to cancelling requests.

To cancel network requests, Go has the context.Context type that can be passed around to carry extra information.

… the Context type … carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.

The built-in context.WithCancel function takes in a background context and returns a wrapper for that context and a cancel function that can be used to trigger cancellation.

ctx, cancel := context.WithCancel(context.Background())

Wire up the signal to trigger cancellation

To wire up receipt of the signal to cancellation while allowing execution of the program to continue, we need to use a goroutine.

In Go, all code runs in goroutines, and a small runtime built into the compiled executable decides which goroutine gets to run on the CPU. This runtime detects when goroutines are waiting for IO, and can swap out goroutines to provide a high amount of concurrency. This is why Go doesn’t need async/await.

Since goroutines only run when they have something to do, and they have a small memory footprint, it’s possible to run thousands of them in a single process. Go will take care of using operating system threads automatically.

This code starts an inline function as a goroutine with the go keyword, waits for a signal to be received from the signals channel with <-signals, and then calls the cancel function to cancel the context.

go func() {
	<-signals
	fmt.Println()
	cancel()
}()

Make a test HTTP request

To test timeouts, I need a request that will never complete. Rather than set up a server and firewall etc, a non-routeable IP address will achieve the same goal.

req, err := http.NewRequest("GET", "http://10.255.255.1", nil)
if err != nil {
	fmt.Printf("error creating request: %v\n", err)
	os.Exit(1)
}

Set the timeouts

Go usually has sensible defaults, but the default HTTP client http.DefaultClient has an infinite timeout, so I’d usually advise against using that.

I’ve seen this cause problems (in Java) where a supplier changed their firewall rules and accidentally blocked traffic from an IP address my team were using to connect to their APIs. The Java services acccumulated more and more outbound connections until the limit of outbound network requests was reached, and the whole server was down.

If you’re making downstream calls as part of an API that sits behind AWS API Gateway, you’ll have a maximum 30 second timeout imposed on you, so it often makes sense to set a timeout for API calls that’s lower than the timeout of your API to give your service time to respond with an error message.

It’s easy to customise the timeout. After the timeout elapses, the HTTP client will stop making the request and return an error.

client := http.Client{}
client.Timeout = time.Second * 5

Use the context

The request (req) can be given the cancellable context (ctx) to use and the client can make the call.

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

Deal with errors

In Go, you need to decide what to do with errors that are returned from functions. Despite the verbosity, I prefer this to try/catch, because it makes it clear which functions can cause errors, and doesn’t excessively nest control flow.

The errors.Is function can be used to check if the reason that the network call failed is because the context was cancelled (and therefore Ctrl-C was pressed), while the isTimeoutError checks whether the error is a net.Error timeout error.

if err != nil {
	if errors.Is(err, context.Canceled) {
		fmt.Println("Ctrl-C pressed, request cancelled.")
		os.Exit(0)
	}
	if isTimeoutError(err) {
		fmt.Println("Timed out.")
		os.Exit(1)
	}
	fmt.Printf("error making request: %v\n", err)
	os.Exit(1)
}
func isTimeoutError(err error) bool {
	e, ok := err.(net.Error)
	return ok && e.Timeout()
}

Stylistically, I think Mat Ryer’s talk at Gophercon [2] does a great job of highlighting good Go style. In particular, “line-of-sight”.

Putting it all together

I think this is a challenging area of Go for newcomers, because it uses Go’s unusual/unique features of channels, context and goroutines, but hopefully this post helps explain how they fit together.

Here’s all the code in a single block for ease of copy/paste.

package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"os/signal"
	"time"
)

func main() {
	// Handle Ctrl-C.
	signals := make(chan os.Signal, 1)
	signal.Notify(signals, os.Interrupt)
	// Create a cancellable context and wire it up to signals from Ctrl-C.
	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		<-signals
		fmt.Println()
		cancel()
	}()
	// This IP address is not routable, so will always timeout.
	req, err := http.NewRequest("GET", "http://10.255.255.1", nil)
	if err != nil {
		fmt.Printf("error creating request: %v\n", err)
		os.Exit(1)
	}
	// The http.DefaultClient timeout is infinite. This means that a request to
	// a server that has a firewall blocking traffic will never complete.
	// It often makes more sense to set a timeout that's lower than the timeout of your API.
	client := http.Client{}
	client.Timeout = time.Second * 5
	resp, err := client.Do(req.WithContext(ctx))
	if err != nil {
		if errors.Is(err, context.Canceled) {
			fmt.Println("Ctrl-C pressed, request cancelled.")
			os.Exit(0)
		}
		if isTimeoutError(err) {
			fmt.Println("Timed out.")
			os.Exit(1)
		}
		fmt.Printf("error making request: %v\n", err)
		os.Exit(1)
	}
	// Show the result.
	_, err = io.Copy(os.Stdout, resp.Body)
	if err != nil {
		fmt.Printf("error printing output: %v\n", err)
		os.Exit(1)
	}
}

func isTimeoutError(err error) bool {
	e, ok := err.(net.Error)
	return ok && e.Timeout()
}