Sentinel Errors — Tasks¶
Hands-on exercises. Each comes with a problem statement, hints, and a reference solution. Difficulty: easy → hard.
Task 1 (Easy) — Declare a sentinel¶
Define a package-level sentinel ErrEmpty with message "empty input". Write a function First(items []int) (int, error) that returns the first item or ErrEmpty when the slice is empty.
Hints - errors.New at package scope. - Compare length with len(items) == 0.
Solution
package main
import (
"errors"
"fmt"
)
var ErrEmpty = errors.New("empty input")
func First(items []int) (int, error) {
if len(items) == 0 {
return 0, ErrEmpty
}
return items[0], nil
}
func main() {
_, err := First(nil)
fmt.Println(errors.Is(err, ErrEmpty)) // true
}
Task 2 (Easy) — Detect with errors.Is¶
Given a function lookup(key string) error that returns ErrNotFound for missing keys, write a MustLookup(key string) helper that prints "missing" for not-found and "ok" otherwise.
Hints - Use errors.Is, not ==.
Solution
var ErrNotFound = errors.New("not found")
func lookup(key string) error {
if key == "" {
return ErrNotFound
}
return nil
}
func MustLookup(key string) {
err := lookup(key)
switch {
case errors.Is(err, ErrNotFound):
fmt.Println("missing")
case err != nil:
fmt.Println("error:", err)
default:
fmt.Println("ok")
}
}
Task 3 (Easy) — Read until EOF¶
Write ReadAllLines(r io.Reader) ([]string, error) that reads line-delimited text and returns all non-empty lines. End-of-stream (io.EOF) is a normal termination, not an error.
Hints - Use bufio.Scanner for the easy path, or bufio.Reader.ReadString('\n') for the manual loop. - errors.Is(err, io.EOF) to detect end of stream.
Solution
func ReadAllLines(r io.Reader) ([]string, error) {
br := bufio.NewReader(r)
var lines []string
for {
line, err := br.ReadString('\n')
line = strings.TrimRight(line, "\n")
if line != "" {
lines = append(lines, line)
}
if errors.Is(err, io.EOF) {
return lines, nil
}
if err != nil {
return lines, fmt.Errorf("read: %w", err)
}
}
}
Task 4 (Easy → Medium) — Wrapping a sentinel¶
Given:
Write ParseAge(s string) (int, error) that returns the parsed integer if it is in [0, 150], or wraps ErrInvalid with the offending value:
Hints - Use fmt.Errorf("parse age %q: %w", s, ErrInvalid).
Solution
var ErrInvalid = errors.New("invalid")
func ParseAge(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse age %q: %w", s, ErrInvalid)
}
if n < 0 || n > 150 {
return 0, fmt.Errorf("parse age %q: %w", s, ErrInvalid)
}
return n, nil
}
func main() {
_, err := ParseAge("300")
fmt.Println(errors.Is(err, ErrInvalid)) // true
fmt.Println(err) // parse age "300": invalid
}
Task 5 (Medium) — Switching on multiple sentinels¶
Write a single-page HTTP-style handler that maps a domain error to an integer status code:
| Sentinel | Status |
|---|---|
ErrNotFound | 404 |
ErrInvalidInput | 400 |
ErrConflict | 409 |
ErrUnauthorized | 401 |
| anything else | 500 |
Solution
var (
ErrNotFound = errors.New("not found")
ErrInvalidInput = errors.New("invalid input")
ErrConflict = errors.New("conflict")
ErrUnauthorized = errors.New("unauthorized")
)
func StatusFor(err error) int {
switch {
case err == nil:
return 200
case errors.Is(err, ErrNotFound):
return 404
case errors.Is(err, ErrInvalidInput):
return 400
case errors.Is(err, ErrConflict):
return 409
case errors.Is(err, ErrUnauthorized):
return 401
default:
return 500
}
}
Task 6 (Medium) — Sentinel-driven retry¶
Write RetryUntilNotTransient(attempts int, fn func() error) error. Treat the package sentinel ErrTransient (and anything wrapping it) as retryable; everything else is permanent and should return immediately.
Hints - errors.Is(err, ErrTransient). - Stop early on permanent errors.
Solution
var ErrTransient = errors.New("transient")
func RetryUntilNotTransient(attempts int, fn func() error) error {
var last error
for i := 0; i < attempts; i++ {
if err := fn(); err == nil {
return nil
} else {
if !errors.Is(err, ErrTransient) {
return err
}
last = err
}
}
return fmt.Errorf("after %d attempts: %w", attempts, last)
}
Task 7 (Medium) — Custom Is method¶
Create a typed error *HTTPStatusError carrying Status int. Implement an Is method so that errors.Is(err, target) returns true whenever target is also *HTTPStatusError with the same status. Then write a sentinel ErrTooManyRequests = &HTTPStatusError{Status: 429} and verify a wrapped version still matches.
Solution
type HTTPStatusError struct {
Status int
}
func (e *HTTPStatusError) Error() string {
return fmt.Sprintf("http %d", e.Status)
}
func (e *HTTPStatusError) Is(target error) bool {
t, ok := target.(*HTTPStatusError)
return ok && t.Status == e.Status
}
var ErrTooManyRequests = &HTTPStatusError{Status: 429}
func main() {
err := fmt.Errorf("api: %w", &HTTPStatusError{Status: 429})
fmt.Println(errors.Is(err, ErrTooManyRequests)) // true
}
Task 8 (Medium → Hard) — Bridging a typed error to a sentinel¶
You have:
Add a typed error *UserNotFoundError with field ID int and message "user N: not found". Wire its Is method so that errors.Is(err, ErrNotFound) returns true when err is *UserNotFoundError.
Write Lookup(id int) error that returns &UserNotFoundError{ID: id} for id == 0, otherwise nil.
Solution
var ErrNotFound = errors.New("not found")
type UserNotFoundError struct {
ID int
}
func (e *UserNotFoundError) Error() string {
return fmt.Sprintf("user %d: not found", e.ID)
}
func (e *UserNotFoundError) Is(target error) bool {
return target == ErrNotFound
}
func Lookup(id int) error {
if id == 0 {
return &UserNotFoundError{ID: id}
}
return nil
}
func main() {
err := Lookup(0)
fmt.Println(errors.Is(err, ErrNotFound)) // true
var ufe *UserNotFoundError
fmt.Println(errors.As(err, &ufe), ufe.ID) // true 0
}
Task 9 (Hard) — os.ErrNotExist round-trip¶
Write LoadConfig(path string) ([]byte, error) that reads a file and returns its contents. The error must satisfy all of:
errors.Is(err, fs.ErrNotExist)for missing files.errors.As(err, &pathErr)to recover*fs.PathError.- The top-level error message starts with
load config: ....
Solution
import (
"errors"
"fmt"
"io/fs"
"os"
)
func LoadConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
return data, nil
}
func main() {
_, err := LoadConfig("/no-such")
fmt.Println(errors.Is(err, fs.ErrNotExist)) // true
var pe *fs.PathError
fmt.Println(errors.As(err, &pe)) // true
fmt.Println(err) // load config: open /no-such: ...
}
Task 10 (Hard) — Build a domain vocabulary¶
Build a small package inventory with sentinels for:
ErrSKUNotFoundErrOutOfStockErrInvalidQuantity
Write Reserve(sku string, qty int) error that returns:
ErrInvalidQuantityifqty <= 0.ErrSKUNotFound(wrapped with the SKU) if the SKU is unknown.ErrOutOfStock(wrapped with the available count) if the requested quantity exceeds stock.- nil on success.
Provide a small in-memory stock map for testing.
Solution
package inventory
import (
"errors"
"fmt"
)
var (
ErrSKUNotFound = errors.New("sku not found")
ErrOutOfStock = errors.New("out of stock")
ErrInvalidQuantity = errors.New("invalid quantity")
)
var stock = map[string]int{
"apple": 10,
"banana": 0,
"cherry": 3,
}
func Reserve(sku string, qty int) error {
if qty <= 0 {
return fmt.Errorf("Reserve %q qty=%d: %w", sku, qty, ErrInvalidQuantity)
}
available, ok := stock[sku]
if !ok {
return fmt.Errorf("Reserve %q: %w", sku, ErrSKUNotFound)
}
if qty > available {
return fmt.Errorf("Reserve %q qty=%d (have %d): %w", sku, qty, available, ErrOutOfStock)
}
stock[sku] = available - qty
return nil
}
Task 11 (Hard) — Cross-package sentinel translation¶
Define two packages:
reporeturnsrepo.ErrNoRows(its own sentinel).servicere-exposes its ownservice.ErrNotFoundto callers.
Write service.Find(id int) error that calls repo.Get(id) and translates repo.ErrNoRows into service.ErrNotFound (wrapped with context). Other repo errors pass through.
Solution
package repo
var ErrNoRows = errors.New("no rows")
func Get(id int) error {
if id == 0 { return ErrNoRows }
return nil
}
package service
var ErrNotFound = errors.New("not found")
func Find(id int) error {
err := repo.Get(id)
if errors.Is(err, repo.ErrNoRows) {
return fmt.Errorf("Find(%d): %w", id, ErrNotFound)
}
if err != nil {
return fmt.Errorf("Find(%d): %w", id, err)
}
return nil
}
The translation isolates the repo sentinel inside service; callers of service only depend on service.ErrNotFound.
Task 12 (Hard) — Detect a sentinel after errors.Join¶
Write a function RunAll(jobs []func() error) error that runs every job, collects all errors, and returns them via errors.Join. Then write a caller that detects whether any job returned ErrTimeout.
Solution
var ErrTimeout = errors.New("timeout")
func RunAll(jobs []func() error) error {
var errs []error
for _, j := range jobs {
if err := j(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func main() {
err := RunAll([]func() error{
func() error { return ErrTimeout },
func() error { return errors.New("other") },
})
fmt.Println(errors.Is(err, ErrTimeout)) // true
}
errors.Is traverses joined errors via Unwrap() []error (Go 1.20+).
Task 13 (Hard) — Sentinel collision check¶
Write a tiny test that ensures three sentinels in your package are not aliases of each other (i.e., they are three distinct values). Use ==.
Solution
var (
ErrA = errors.New("a")
ErrB = errors.New("b")
ErrC = errors.New("c")
)
func TestSentinelsDistinct(t *testing.T) {
if ErrA == ErrB || ErrB == ErrC || ErrA == ErrC {
t.Fatal("sentinels must be distinct values")
}
}
This test catches accidental copy-paste like var ErrB = ErrA.
Task 14 (Hard) — Sentinel wire codec¶
Build a tiny encoder/decoder for sending sentinels across a wire-format boundary. The wire format is a JSON object {"code": "...", "message": "..."}. The encoder maps known sentinels to codes; the decoder maps codes back to local sentinels.
Solution
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
)
type wireError struct {
Code string `json:"code"`
Message string `json:"message"`
}
var encodeMap = map[error]string{
ErrNotFound: "not_found",
ErrConflict: "conflict",
}
var decodeMap = map[string]error{
"not_found": ErrNotFound,
"conflict": ErrConflict,
}
func Encode(err error) wireError {
for sentinel, code := range encodeMap {
if errors.Is(err, sentinel) {
return wireError{Code: code, Message: err.Error()}
}
}
return wireError{Code: "internal", Message: err.Error()}
}
func Decode(w wireError) error {
if e, ok := decodeMap[w.Code]; ok {
return fmt.Errorf("%s: %w", w.Message, e)
}
return errors.New(w.Message)
}
This is the pattern real RPC frameworks use for cross-process sentinel-like behavior.
Task 15 (Boss-level) — Domain error type with sentinel matching¶
Combine sentinels and a typed error: build domain.Error with fields {Op, Kind, Err}. Implement Error(), Unwrap(), and Is(target error) bool so that:
errors.Is(err, ErrNotFound)matches whenKind == KindNotFound.errors.As(err, &de)extracts the*domain.Error.Unwrapreturns the cause.
Provide a small constructor domain.E(op string, kind Kind, err error) error.
Solution
package domain
import (
"errors"
"fmt"
)
type Kind int
const (
KindOther Kind = iota
KindNotFound
KindInvalid
KindConflict
)
var (
ErrNotFound = &Error{Kind: KindNotFound}
ErrInvalid = &Error{Kind: KindInvalid}
ErrConflict = &Error{Kind: KindConflict}
)
type Error struct {
Op string
Kind Kind
Err error
}
func (e *Error) Error() string {
if e.Op == "" {
return fmt.Sprintf("kind=%d", e.Kind)
}
return fmt.Sprintf("%s: kind=%d: %v", e.Op, e.Kind, e.Err)
}
func (e *Error) Unwrap() error { return e.Err }
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
return ok && t.Kind == e.Kind
}
func E(op string, kind Kind, err error) error {
return &Error{Op: op, Kind: kind, Err: err}
}
// Usage:
//
// err := domain.E("Get", domain.KindNotFound, nil)
// errors.Is(err, domain.ErrNotFound) // true
// var de *domain.Error
// errors.As(err, &de) // de.Op == "Get"
This is the Upspin / cockroachdb pattern compressed into one struct: typed for fields, sentinel-shaped for matching.