Errors wrap other errors. Reading the chain — and your repo's errorx flavor of it — is daily review work.
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.
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).
| Tool | What it does | Python 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 |
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.
if err != nil {
return nil, fmt.Errorf(
"unexpected GetPnL code: %w", err)
}
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]
errorxSeen 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:
// 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())
// wrap (≈ %w)
return Error.WrapWithNoMessage(err)
return ErrNotFound.WrapWithNoMessage(err)
// match by TYPE (≈ errors.Is)
if errorx.IsOfType(err,
integration.NotFoundError) {
...
}
| stdlib | errorx equivalent | meaning |
|---|---|---|
errors.New("x") | ns.NewType("x") | declare an error kind |
| sentinel value | Type.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 |
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.
dapp_storage/client.goNow 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.
%v instead of %w when the caller later needs errors.Is — breaks the chain silently.== instead of errors.Is — fails the moment the sentinel is wrapped. [rule]"Failed to...") — error strings stay lowercase, no period (L1).errors.Is on an errorx type where the surrounding code uses errorx.IsOfType — match the local convention.Instant feedback. Reps, not grades.
return fmt.Errorf("load dapp: %v", err)A caller upstack does errors.Is(result, dapp.ErrNotFound). What happens?.Retryable field off a custom *RateLimitError somewhere in the chain. Which tool?errorx.IsOfType(err, dapp_storage.ErrNotFound.Type()) is the house equivalent of which stdlib call?GetDAppByID, why map codes.NotFound → ErrNotFound instead of returning the raw gRPC error?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).
Added to your GLOSSARY.md:
%w) — an outer error carrying an inner one, preserving an inspectable chain.errors.Is / errors.As — match a sentinel / extract a typed error from anywhere in the chain.errorx.IsOfType, wrapped with WrapWithNoMessage.