Concurrency and Shipping

Context, errgroup, and Cancellation

LuettavaEsikatselu

Katselet ilmaista esikatseluoppituntia.

Goal

By the end of this lesson, tasker import reads task lists from files and URLs at the same time. Every download respects a time limit, a --strict mode stops all work at the first error, and pressing Ctrl+C cancels everything cleanly. You replace your hand-written worker pool with errgroup, and you test HTTP code with a fake server.

What you will learn

  • What context.Context is, and why almost every backend function takes one
  • How to set deadlines with context.WithTimeout
  • How to cancel work when the user presses Ctrl+C, with signal.NotifyContext
  • How to run a group of goroutines with a limit using errgroup
  • How to stop all goroutines when one fails, with errgroup.WithContext
  • How to make HTTP requests that can be canceled
  • How to test HTTP code with httptest.NewServer

Why this matters

Work that waits on the network can take a long time, or never finish. A server must not wait forever for a slow database. When a user closes the browser, the server should stop the work for that request. When you deploy a new version, the old one should finish its current work and stop cleanly.

Go solves all of these with one tool: context. In Part 2, every HTTP handler, every database query, and every outgoing request receives a context. This lesson teaches the pattern with a command-line tool, where you can see each effect directly.

Step 1: What is a context?

A context.Context is a value you pass down through function calls. It carries a cancellation signal: a way for the caller to say "stop, the result is no longer needed".

func doWork(ctx context.Context) error

By convention:

  • ctx is the first parameter of a function, and it is always named ctx.
  • You never store a context in a struct. You pass it to each call.
  • You never pass nil. If you have no context, use context.Background().

You create contexts from other contexts. Each new one is a child of its parent:

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

The child is canceled when either its own timeout expires or its parent is canceled. Always call cancel (with defer) when you are done, to release the timer early.

Code that does slow work checks the context:

  • ctx.Done() returns a channel that is closed when the context is canceled. You can use it in a select, like the timeout in Lesson 6.2.
  • ctx.Err() returns nil while the context is active, and then context.Canceled or context.DeadlineExceeded.

Most standard library functions that can be slow, such as HTTP requests and database queries, accept a context and stop by themselves when it is canceled.

Step 2: Add errgroup

go get golang.org/x/sync@latest

golang.org/x/... packages are maintained by the Go team, but outside the standard library. errgroup is the most popular one: it is a WaitGroup that also handles errors, cancellation, and a limit on how many goroutines run at once.

If the newest version of a library needs a newer Go version than yours, go get can download that Go version for you automatically and update the go line in go.mod. Keep your Go installation up to date to avoid surprises.

Step 3: Rewrite the importer with context and errgroup

Replace internal/importer/importer.go:

// Package importer reads task lists from files and URLs at the same time.
package importer

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"

	"golang.org/x/sync/errgroup"
)

// maxSize is the largest import source Tasker accepts: 1 MB.
const maxSize = 1 << 20

// Item is one task in an import source.
type Item struct {
	Title string   `json:"title"`
	Tags  []string `json:"tags"`
}

// Result is the outcome of reading one source.
type Result struct {
	Source string
	Items  []Item
	Err    error
}

// Options control how Read works.
type Options struct {
	Workers int          // how many sources to read at the same time
	Strict  bool         // stop everything at the first error
	Client  *http.Client // the HTTP client for URLs
}

// Read reads all sources, with at most opts.Workers at the same time.
// A source is a file path or an http:// or https:// URL.
// The results are in the same order as sources.
// In strict mode, Read cancels the remaining work at the first error and returns it.
func Read(ctx context.Context, sources []string, opts Options) ([]Result, error) {
	results := make([]Result, len(sources))

	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(opts.Workers)

	for i, src := range sources {
		g.Go(func() error {
			res := readSource(ctx, src, opts.Client)
			results[i] = res
			if opts.Strict && res.Err != nil {
				return res.Err
			}
			return nil
		})
	}

	err := g.Wait()
	return results, err
}

Compare this with ReadFiles from Lesson 6.2. The jobs channel, the results channel, the producer, the closer, and the WaitGroup are all gone:

  • errgroup.WithContext(ctx) creates a group and a new child context. When any goroutine in the group returns an error, the group cancels this context, so all other goroutines are told to stop.
  • g.SetLimit(opts.Workers) allows at most that many goroutines at the same time. When the limit is reached, g.Go waits until one finishes. This replaces the worker pool.
  • g.Go(func() error {...}) starts a goroutine.
  • g.Wait() waits for all goroutines and returns the first error, or nil.

Writing to results[i] from many goroutines is safe, because each goroutine writes to a different index, and nobody reads the slice until Wait returns. Different elements of a slice are different memory, so there is no data race. The race detector agrees.

In normal mode, every goroutine returns nil, even when its source fails. The error stays in its Result, and all other sources continue. In strict mode, a failure returns the error, which cancels the context and stops the remaining work.

Step 4: Read files and URLs

Add the rest of the importer:

// readSource reads one source and decodes its list of items.
func readSource(ctx context.Context, src string, client *http.Client) Result {
	res := Result{Source: src}

	var data []byte
	var err error
	if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
		data, err = fetch(ctx, src, client)
	} else {
		data, err = readFile(ctx, src)
	}
	if err != nil {
		res.Err = err
		return res
	}

	if err := json.Unmarshal(data, &res.Items); err != nil {
		res.Err = fmt.Errorf("parse %s: %w", src, err)
	}
	return res
}

// readFile reads a local file, unless ctx is already canceled.
func readFile(ctx context.Context, path string) ([]byte, error) {
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	return os.ReadFile(path)
}

// fetch downloads url. It stops when ctx is canceled.
func fetch(ctx context.Context, url string, client *http.Client) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("get %s: %s", url, resp.Status)
	}

	data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
	if err != nil {
		return nil, fmt.Errorf("read %s: %w", url, err)
	}
	if len(data) > maxSize {
		return nil, fmt.Errorf("get %s: response is larger than 1 MB", url)
	}
	return data, nil
}

http.NewRequestWithContext connects the request to the context. If the context is canceled, while connecting or while reading the response, the request stops immediately with an error. Never use http.Get in code that should be cancelable: it has no context.

defer resp.Body.Close() is required for every response. The body holds an open network connection. If you forget to close it, the program leaks connections.

Check the status code. An HTTP error such as 404 Not Found is not a Go error: client.Do succeeds, because the server did answer. You must check resp.StatusCode yourself.

Limit what you read. A URL can return any amount of data. io.LimitReader reads at most maxSize+1 bytes. If it got more than maxSize, the response is too large. Never read unlimited data from the network into memory. 1 << 20 means 1 shifted left by 20 bits, which is 1,048,576 bytes, or 1 MB.

readFile checks ctx.Err() first. os.ReadFile does not accept a context, and reading a local file is fast anyway. Checking first means that canceled work does not start new reads.

Step 5: Update the command

In internal/cli/import.go, add three flags:

	var (
		workers int
		strict  bool
		timeout time.Duration
	)
	cmd.Flags().IntVarP(&workers, "workers", "w", 4, "number of sources to read at the same time")
	cmd.Flags().BoolVar(&strict, "strict", false, "stop at the first error and import nothing")
	cmd.Flags().DurationVar(&timeout, "timeout", 30*time.Second, "maximum time for the whole import")

DurationVar parses values such as 2s, 500ms, or 1m30s.

Replace the call to ReadFiles with this:

			ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
			defer cancel()

			results, err := importer.Read(ctx, args, importer.Options{
				Workers: workers,
				Strict:  strict,
				Client:  http.DefaultClient,
			})
			if err != nil {
				return fmt.Errorf("import stopped, nothing was imported: %w", err)
			}

			out := cmd.OutOrStdout()
			failed := 0
			for _, res := range results {

Update the imports (context, net/http, time), the Use text to import <file-or-url>..., and the summary message to say "sources" instead of "files".

cmd.Context() is the context that Cobra received from main. The command creates a child with a timeout. So the import stops when the timeout expires or when the parent is canceled, for example by Ctrl+C.

In strict mode, the command imports nothing if any source fails. That is a useful promise for scripts: either everything is imported or nothing is.

Step 6: Cancel on Ctrl+C

Replace cmd/tasker/main.go:

package main

import (
	"context"
	"fmt"
	"os"
	"os/signal"

	"github.com/yourname/tasker/internal/cli"
)

func main() {
	os.Exit(run())
}

func run() int {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()

	if err := cli.NewRootCmd().ExecuteContext(ctx); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		return 1
	}
	return 0
}
  • When the user presses Ctrl+C, the operating system sends an interrupt signal to the program. Normally, Go stops the program immediately.
  • signal.NotifyContext catches the signal instead and cancels ctx. Every operation that uses this context stops on its own, and the program can finish cleanly, for example by printing which downloads were canceled.
  • ExecuteContext(ctx) passes the context to Cobra, which gives it to every command through cmd.Context().
  • os.Exit runs no deferred functions. So main only calls os.Exit with the result of run. All defer statements are in run, and they run before run returns. This is a common pattern in Go programs.

After stop(), a second Ctrl+C stops the program the normal way.

Step 7: Test HTTP code with a fake server

Testing with real websites would be slow and unreliable. The net/http/httptest package starts a real HTTP server on your computer, just for the test. Replace internal/importer/importer_test.go:

package importer

import (
	"context"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func writeFile(t *testing.T, dir, name, content string) string {
	t.Helper()
	path := filepath.Join(dir, name)
	require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
	return path
}

// newTestServer starts an HTTP server for the test with three paths:
// /tasks returns a task list, /slow waits before it answers, and every other path returns 404.
func newTestServer(t *testing.T) *httptest.Server {
	t.Helper()
	mux := http.NewServeMux()
	mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`[{"title": "From the web", "tags": ["web"]}]`))
	})
	mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-time.After(5 * time.Second):
			w.Write([]byte(`[]`))
		case <-r.Context().Done():
		}
	})
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)
	return srv
}

This is a small web server, written with the standard library. You will learn this code in detail in Lesson 7.1. For now, notice three things:

  • Each HandleFunc answers one path.
  • The /slow handler uses select to wait for 5 seconds or until the request's own context is canceled, whichever comes first. Servers use contexts too.
  • t.Cleanup(srv.Close) registers a function that runs when the test ends. The server stops automatically.

Now the tests:

func TestReadFilesAndURLs(t *testing.T) {
	srv := newTestServer(t)
	dir := t.TempDir()
	file := writeFile(t, dir, "a.json", `[{"title": "Buy milk", "tags": ["shop"]}]`)
	broken := writeFile(t, dir, "broken.json", `[{"title":`)
	sources := []string{file, srv.URL + "/tasks", srv.URL + "/missing", broken}

	results, err := Read(context.Background(), sources, Options{Workers: 2, Client: srv.Client()})

	require.NoError(t, err)
	require.Len(t, results, 4)
	assert.Equal(t, []Item{{Title: "Buy milk", Tags: []string{"shop"}}}, results[0].Items)
	assert.Equal(t, []Item{{Title: "From the web", Tags: []string{"web"}}}, results[1].Items)
	assert.ErrorContains(t, results[2].Err, "404 Not Found")
	assert.ErrorContains(t, results[3].Err, "parse")
}

func TestReadTimeout(t *testing.T) {
	srv := newTestServer(t)
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	start := time.Now()
	results, err := Read(ctx, []string{srv.URL + "/slow"}, Options{Workers: 1, Client: srv.Client()})

	require.NoError(t, err)
	assert.ErrorIs(t, results[0].Err, context.DeadlineExceeded)
	assert.Less(t, time.Since(start), time.Second, "Read must stop when the context ends")
}

func TestReadStrictCancelsOtherWork(t *testing.T) {
	srv := newTestServer(t)
	sources := []string{srv.URL + "/slow", srv.URL + "/missing"}

	start := time.Now()
	results, err := Read(context.Background(), sources, Options{Workers: 2, Strict: true, Client: srv.Client()})

	assert.ErrorContains(t, err, "404 Not Found")
	assert.Error(t, results[0].Err, "the slow download must fail because it was canceled")
	assert.Less(t, time.Since(start), time.Second, "the slow download must be canceled")
}
  • srv.URL is the address of the test server, such as http://127.0.0.1:44313. srv.Client() returns an HTTP client set up for it.
  • TestReadTimeout gives Read a context with a 100 ms deadline. The slow endpoint would take 5 seconds. The test checks that Read returns quickly with context.DeadlineExceeded. errors.Is finds it inside the HTTP error, because the HTTP client wraps it.
  • TestReadStrictCancelsOtherWork starts a slow download and a failing one together. In strict mode, the 404 cancels the group's context, and the slow download stops at once instead of after 5 seconds.

Checking the time is how you test cancellation: if cancellation does not work, the test takes 5 seconds and fails.

Run all tests with the race detector:

go mod tidy
go test -race ./...

Step 8: Try it

You need a local web server with a JSON file. If you have Python installed, the quickest way is:

mkdir www
echo '[{"title": "Read Go blog", "tags": ["learn"]}]' > www/reading.json
python3 -m http.server 8099 --directory www

In a second terminal:

echo '[{"title": "Buy milk", "tags": ["shop"]}]' > home.json
go build ./cmd/tasker
./tasker import home.json http://127.0.0.1:8099/reading.json http://127.0.0.1:8099/nope.json
Imported 1 tasks from home.json
Imported 1 tasks from http://127.0.0.1:8099/reading.json
error: get http://127.0.0.1:8099/nope.json: 404 File not found
error: 1 of 3 sources could not be imported

To see timeouts and Ctrl+C, you need a slow URL. One option is https://httpbin.org/delay/10, a public test service that waits 10 seconds before answering. It does not return a task list, but it is perfect for this test:

./tasker import --timeout 2s https://httpbin.org/delay/10
error: Get "https://httpbin.org/delay/10": context deadline exceeded
error: 1 of 1 sources could not be imported

Run it again without --timeout, and press Ctrl+C after a second:

error: Get "https://httpbin.org/delay/10": context canceled
error: 1 of 1 sources could not be imported

The program did not die abruptly. The context was canceled, the request stopped, and Tasker reported what happened and exited normally with code 1.

Try strict mode with a slow URL and a missing file:

./tasker import --strict https://httpbin.org/delay/10 nope.json
error: import stopped, nothing was imported: open nope.json: no such file or directory

It returns immediately. The missing file failed first, and the group canceled the slow download.

Why it works: cancellation flows down, errors flow up

Look at the chain of contexts in Tasker:

signal.NotifyContext        (canceled by Ctrl+C)
└── cmd.Context()
    └── context.WithTimeout   (canceled after --timeout)
        └── errgroup context  (canceled at the first error in strict mode)
            └── each HTTP request

Canceling any level cancels everything below it, and no level needs to know about the others. The HTTP request does not know about Ctrl+C. It only knows its context.

Errors travel the other way: from the request, through Result or g.Wait(), up to the command, and to main.

In Part 2, the chain looks the same: the server's shutdown context, the request context from Fiber, a timeout for each database query. You now know the pattern that holds every Go backend together.

Exercises

  1. A timeout per source. Add a --source-timeout flag. In readSource, create a child context with that timeout for each source. Test it with the slow endpoint.
  2. Log the work. Pass a *zap.Logger in Options and log each source at debug level, with its duration and the number of items.
  3. Respect cancellation in the loop. If the user presses Ctrl+C after the downloads finish but while tasks are being added, the command still adds all tasks. Check ctx.Err() before each source in the add loop, and stop if the context is canceled.
  4. Test the size limit. Add a /big handler to the test server that writes more than 1 MB, and a test that checks for the "larger than 1 MB" error. Hint: strings.Repeat.
  5. Context values. Read the documentation of context.WithValue. Why does the Go team recommend using it only for request-scoped data, such as a request ID, and not for optional function parameters?

Checkpoint

You are ready for the next lesson if you can:

  • Explain what a context is and the conventions for passing one
  • Create a context with a timeout and explain why you defer cancel()
  • Explain how signal.NotifyContext makes Ctrl+C cancel work
  • Use errgroup with SetLimit and explain what WithContext adds
  • Make a cancelable HTTP request and close its body
  • Test HTTP code with httptest.NewServer