Concurrency and Shipping
Context, errgroup, and Cancellation
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.Contextis, 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:
ctxis the first parameter of a function, and it is always namedctx.- You never store a context in a struct. You pass it to each call.
- You never pass
nil. If you have no context, usecontext.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 aselect, like the timeout in Lesson 6.2.ctx.Err()returnsnilwhile the context is active, and thencontext.Canceledorcontext.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 getcan download that Go version for you automatically and update thegoline ingo.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.Gowaits 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, ornil.
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.NotifyContextcatches the signal instead and cancelsctx. 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 throughcmd.Context().os.Exitruns no deferred functions. Somainonly callsos.Exitwith the result ofrun. Alldeferstatements are inrun, and they run beforerunreturns. 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
HandleFuncanswers one path. - The
/slowhandler usesselectto 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.URLis the address of the test server, such ashttp://127.0.0.1:44313.srv.Client()returns an HTTP client set up for it.TestReadTimeoutgivesReada context with a 100 ms deadline. The slow endpoint would take 5 seconds. The test checks thatReadreturns quickly withcontext.DeadlineExceeded.errors.Isfinds it inside the HTTP error, because the HTTP client wraps it.TestReadStrictCancelsOtherWorkstarts 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
- A timeout per source. Add a
--source-timeoutflag. InreadSource, create a child context with that timeout for each source. Test it with the slow endpoint. - Log the work. Pass a
*zap.LoggerinOptionsand log each source at debug level, with its duration and the number of items. - 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. - Test the size limit. Add a
/bighandler 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. - 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.NotifyContextmakes Ctrl+C cancel work - Use
errgroupwithSetLimitand explain whatWithContextadds - Make a cancelable HTTP request and close its body
- Test HTTP code with
httptest.NewServer
