💾 Archived View for capsule.adrianhesketh.com › 2021 › 07 › 04 › cancelling-go-network-requests captured on 2023-04-26 at 13:10:18. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2021-11-30)
-=-=-=-=-=-=-
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:
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)
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())
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() }()
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) }
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
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))
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".
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() }
Using AWS CDK with Go to launch an app with App Runner