Go for Reviewers · Lesson 05

The Error Chain

Errors wrap other errors. Reading the chain — and your repo's errorx flavor of it — is daily review work.

Why this, now: L1 left a thread: how does a caller tell which error came back when it's been passed up through five layers? The answer is wrapping + errors.Is/As. Your repo uses both the stdlib mechanism and a house library, errorx — you'll see both in every PR, often in the same function.

1 · The problem wrapping solves

A sentinel like ErrNotFound (L1) is great until it travels. By the time it reaches the API layer, you've lost where it came from. Python solves this with exception chaining (raise X from e) and a traceback. Go has no traceback — so it wraps: an outer error carries the inner one as a payload, building a chain you can both print (for context) and inspect (to branch on the original cause).

fmt.Errorf("list dapps: %w", err)outer — adds context
→ wraps →
ErrNotFoundinner — the real cause

2 · The three stdlib tools

ToolWhat it doesPython analog
fmt.Errorf("ctx: %w", err)Wrap: build an outer error that carries err. The %w verb is what makes it unwrappable.raise X from e
errors.Is(err, ErrNotFound)Match a sentinel anywhere in the chain. Walks %w links comparing identity.isinstance on cause
errors.As(err, &target)Extract a typed error from the chain into target so you can read its fields.except SomeError as e
Where the Python analogy breaks: there's no automatic traceback and no try/except dispatch. You must explicitly ask errors.Is / errors.As at the point you care. And only %w (not %v or %s) preserves the chain — wrap with %v and the inner error becomes a flat string that errors.Is can no longer find. That's a real review catch.
Wrap with context · real repo style
if err != nil {
    return nil, fmt.Errorf(
        "unexpected GetPnL code: %w", err)
}
Match the cause · real repo style
if errors.Is(err, dapp.ErrNotFound) {
    // treat "not found" as empty,
    // not a failure
}
if errors.Is(err, context.Canceled) {
    return  // caller gave up
}

Both snippets above are lifted from your repo (clients/pnl_storage, services/portfolio). The pattern is: lower layers %w-wrap to add breadcrumbs; higher layers errors.Is against a known sentinel to decide behavior. [Go blog: working with errors]

3 · The house flavor: errorx

Seen all over: helpers/error.go, clients/dapp_storage/client.go, every service's errors.go.

Your team uses joomcode/errorx — a richer error library layered on top of the same wrap/match idea. Don't let the new method names throw you; it's the L1+wrapping model with namespaces and types bolted on. Map it:

Defining errors · errorx
// helpers/error.go — one root namespace
ErrorNamespace = errorx.NewNamespace("*")

// clients/dapp_storage/client.go
ns    = helpers.ErrorNamespace.
          NewSubNamespace("DAppStorageClient")
Error = ns.NewType("*")
ErrNotFound = Error.NewSubtype(
    "dapp not found", errorx.NotFound())
Wrapping + matching · errorx
// wrap (≈ %w)
return Error.WrapWithNoMessage(err)
return ErrNotFound.WrapWithNoMessage(err)

// match by TYPE (≈ errors.Is)
if errorx.IsOfType(err,
        integration.NotFoundError) {
    ...
}
stdliberrorx equivalentmeaning
errors.New("x")ns.NewType("x")declare an error kind
sentinel valueType.NewSubtype(...)a specific named error, optionally tagged (errorx.NotFound())
fmt.Errorf("%w", err)Type.WrapWithNoMessage(err)wrap, preserving the chain
errors.Is(err, Sentinel)errorx.IsOfType(err, Type)match a kind anywhere in the chain
The mental model: errorx matches on a type/namespace ("is this any DAppStorageClient not-found error?") rather than on a single sentinel identity. Richer, but the same job. In this repo, both coexist — you'll see errors.Is(err, dapp.ErrNotFound) and errorx.IsOfType(err, X) in the same package. Use whichever the surrounding code uses.

4 · Reading the bottom of dapp_storage/client.go

Now the block that was opaque after L1 reads cleanly:

func (c *Client) GetDAppByID(ctx context.Context, id string) (*dapp_storage.DApp, error) {
    resp, err := c.client.GetDapp(...)
    if err != nil {
        switch zegrpc.ExtractCode(err) {        // inspect the gRPC status
        case codes.Canceled:
            return nil, Error.WrapWithNoMessage(context.Canceled)
        case codes.NotFound:
            return nil, ErrNotFound.WrapWithNoMessage(err)  // tag as not-found
        default:
            return nil, Error.WrapWithNoMessage(err)        // generic wrap
        }
    }
    return resp.GetDapp(), nil
}

Read as a reviewer: a raw gRPC error comes back; this client translates the transport error into a domain error — mapping codes.NotFound to the typed ErrNotFound, everything else to a generic Error. The caller upstack then does errorx.IsOfType(err, ...NotFound) or errors.Is(err, dapp.ErrNotFound) to branch. This translate-at-the-boundary pattern is the spine of error flow in the whole codebase.

Reviewer's eye — what to flag:

5 · Drill — read the chain

Instant feedback. Reps, not grades.

Q1 %w vs %v
return fmt.Errorf("load dapp: %v", err)
A caller upstack does errors.Is(result, dapp.ErrNotFound). What happens?
Q2 Is vs As
You need to read the .Retryable field off a custom *RateLimitError somewhere in the chain. Which tool?
Q3 Map the errorx call
errorx.IsOfType(err, dapp_storage.ErrNotFound.Type()) is the house equivalent of which stdlib call?
Q4 Spot the boundary pattern
In GetDAppByID, why map codes.NotFoundErrNotFound instead of returning the raw gRPC error?
I'm your teacher — ask me anything.
Want the full errorx ⇄ stdlib mapping as a printable card in reference/? Or curious how errorx stack traces show up in Sentry (the repo wires zapsentry)? Ask. Next lesson moves to concurrency — goroutines, channels, and the leak you saw hinted at in go s.updateDApps(ctx).

Terms introduced

Added to your GLOSSARY.md: