[RFC] errors: introduce structured hints + apply audit-driven rewrites#6959
[RFC] errors: introduce structured hints + apply audit-driven rewrites#6959dvdksn wants to merge 3 commits intodocker:masterfrom
Conversation
Adds a small internal package that attaches actionable user guidance to errors, separated from the error message itself. The hint is exposed via the Hinter interface and is read out of the error chain by the top-level renderer (a follow-up commit wires this up). Keeping the hint out of err.Error() means substring-based test assertions stay stable as hints are added or reworded, and gives the CLI control over rendering (formatting, prefixing, future i18n) at a single point instead of having every call site embed it inline. Internal-only for now; can be promoted to a public API later if other plugins (buildx, compose) want to share the convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: David Karlsson <35727626+dvdksn@users.noreply.github.com>
Hooks the new internal/hint package into the top-level error renderer. After printing the error itself, walks the chain for any error implementing hint.Hinter and prints its hint on its own line. This makes hints a first-class output, separate from the error message, without changing how errors are constructed or wrapped at call sites that don't carry a hint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: David Karlsson <35727626+dvdksn@users.noreply.github.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Applies the rewrites produced by an end-to-end review of CLI error
quality (audit/{inventory,review,rewrites,editorial}.json). 31 messages
across 11 command areas, organised around the patterns that surfaced
most often:
Factual / wrong-noun fixes — messages that misdirected users to the
wrong layer or named things that didn't exist:
- image/list: --show-digest -> --digests (the actual flag name)
- image/load: stdin-state assertion now matches IsTerminal() guard
- inspect: template execution failures no longer mislabelled as
parsing errors
- system/info: blames the actual cause (info collection) not
pretty-printing
- image/save, container/export: -o open failures name the file,
not the operation
- image/build: build-context errors enumerate supported forms
instead of misnaming everything as a missing path
- manifest/annotate: prints the os/arch pair actually validated
- context/options: describes the missing-endpoint state rather
than implying a fetch failure
- container/cp: corrects the misleading 'container source' wording
Partial success surfaced — operations that succeeded with a
follow-up failure now say so, preventing wasted retries:
- container/create: cidfile write failure (container exists)
- image/build: aux-message decode failure (build succeeded)
- image/build: missing image ID for --iidfile (build succeeded)
Refusal reason explained — TTY-refusal messages for binary tar
output now state why instead of using 'cowardly':
- container/export, context/export, image/save
Echo-the-value / name-the-flag — validation errors now include
what the user typed and the expected form:
- container/opts: --pid, --uts, --userns, --cgroupns,
--storage-opt, --device (×2 branches), --log-driver
- network/connect: --driver-opt
- network/create: --subnet (names both overlapping subnets)
- volume/update, container/update, context/create
Internal jargon replaced — code-level field names removed from
user-facing wording:
- container/exec: 'exec ID empty' replaced with daemon-anomaly
framing
Cause preserved — manifest/push: ErrBlobCreated is now wrapped via
%w and the wording describes what actually happened.
Errors that have an actionable hint use internal/hint.Wrap so the
hint is rendered separately from the message at the top-level error
handler. Errors that benefit from a sibling-site fix (image/build's
context detection in image/build/context_detect.go) carry that fix
to keep the same condition producing the same wording regardless of
entry point.
Tests updated to match the new wordings; substring-based assertions
stay stable because hints are no longer part of err.Error().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: David Karlsson <35727626+dvdksn@users.noreply.github.com>
vvoland
left a comment
There was a problem hiding this comment.
I like this!
I think it might be a little bit more complicated to implement this properly though 😿
| return hint.Wrap( | ||
| fmt.Errorf("container %s was created, but writing its ID to %s failed: %w", id, cid.path, err), | ||
| fmt.Sprintf("The container exists on the daemon. Run 'docker rm %s' to remove it, or note the ID for later use.", id), | ||
| ) |
There was a problem hiding this comment.
This will be swallowed by cli.StatusError wrapping at the call site:
cli/cli/command/container/create.go
Line 124 in 3f81d71
There was a problem hiding this comment.
This will also be case for many other operations too
| _, _ = fmt.Fprintln(os.Stderr, err) | ||
| if h := hint.Of(err); h != "" { | ||
| _, _ = fmt.Fprintln(os.Stderr, "\n"+h) | ||
| } |
There was a problem hiding this comment.
Ideally, we'd probably also want the plugins to be able to return hintable errors:
cli/cli-plugins/plugin/plugin.go
Line 110 in 3f81d71
| // Of returns the first non-empty hint in the error chain, or "" if | ||
| // none of the wrapped errors implement [Hinter]. | ||
| func Of(err error) string { | ||
| for err != nil { | ||
| if h, ok := err.(Hinter); ok { | ||
| if msg := h.Hint(); msg != "" { | ||
| return msg | ||
| } | ||
| } | ||
| err = errors.Unwrap(err) | ||
| } | ||
| return "" | ||
| } |
There was a problem hiding this comment.
This could be just:
| // Of returns the first non-empty hint in the error chain, or "" if | |
| // none of the wrapped errors implement [Hinter]. | |
| func Of(err error) string { | |
| for err != nil { | |
| if h, ok := err.(Hinter); ok { | |
| if msg := h.Hint(); msg != "" { | |
| return msg | |
| } | |
| } | |
| err = errors.Unwrap(err) | |
| } | |
| return "" | |
| } | |
| // Of returns the first non-empty hint in the error chain, or "" if | |
| // none of the wrapped errors implement [Hinter]. | |
| func Of(err error) string { | |
| var h Hinter | |
| if errors.As(err, &h) { | |
| return h.Hint() | |
| } | |
| return "" | |
| } |
and perhaps we don't even need this func at all then?
What I did
Two things, packaged together so the abstraction is motivated by concrete improvements rather than argued in the abstract:
Introduces
internal/hint— a small (~50-line) package that attaches actionable user guidance to errors. The hint is exposed via aHinterinterface, walked out of the error chain by the top-level renderer incmd/docker/docker.go, and printed on its own line. Hints are not part oferr.Error(), so substring assertions on error messages stay stable as hints are added or reworded.Applies 31 error-message rewrites flagged by an end-to-end audit of CLI error quality across 11 command areas. The rewrites are organised around the patterns that surfaced most often:
image loadclaiming "stdin is empty" when the actual guard checksIsTerminal();image listreferencing the internal field name--show-digestinstead of the user-facing--digestsflag;inspectcalling template execution failures "parsing errors").container createcidfile write;image buildaux-message decode and missing image ID for--iidfile).--pid/--uts/--userns/--cgroupnsfamily,--device,--storage-opt,--driver-opt,--subnet, etc.).container exec's"exec ID empty"(an internal API field) replaced with daemon-anomaly framing.manifest pushErrBlobCreated now wrapped via%wand described correctly.How I did it
The mechanism. A 50-line internal package + a 4-line addition to the top-level error renderer. Usage:
The
cmd/dockerrenderer walks the chain viaerrors.Asfor anyHinterand prints the hint after the error. Errors without a hint are unaffected — the migration is fully incremental, no flag day.The rewrites. Driven by an audit over the user-facing error inventory (215 errors across container, image, network, volume, context, system, registry, manifest, builder, inspect). Review flagged 32 entries as needing improvement; rewrite produced 31 rewrites + 1 keep-original; an editorial pass approved 29 outright and flagged 3 for cross-entry consistency tweaks (which are folded in).
15 of the rewrites have an explicit hint and use
hint.Wrap; the rest are message-only changes.How to verify
make test— the test suite is updated where assertions referenced the old wordings.err.Error()to substring-based test expectations to confirm hints are not part of the message string (they render on their own line).Why internal-only
Keeps API surface small while we iterate. If buildx/compose/scout adopt the same
Hinterinterface independently, errors flowing through docker CLI's renderer Just Work via duck-typing — they don't need to import the same package, just match the interface shape. Promotion to public is a rename when the convention proves out.Out of scope (intentional)
withHelpincontainer/run.gostill uses\n\nfor its--helpsuggestion. Pre-existing pattern, untouched in this PR — natural follow-up to migrate tohint.Wraponce the mechanism lands.var ErrFoo = ...patterns). The current shape is a strict subset of what a catalog would provide; if it's wanted later,Wrapcan sit underneath without breaking call sites.context create's "no docker endpoint configured: set one with--docker, or copy from an existing context with--from" reads slightly off because--dockertakes a structuredkey=valuevalue rather than a single thing to "set". Happy to refine if reviewers prefer different wording.Related
docker create networkand similar; this PR doesn't close it but addresses the pattern.[RFC]framing in the title follows the convention of [RFC] standardise exit-codes #1683 (standardise exit-codes) — small enough abstraction that splitting RFC-doc and implementation would feel ceremonial, but big enough to want maintainer sign-off on the approach before going through detailed review.Asks of reviewers
Marked as draft intentionally. Before deep-diving on each individual rewrite, I'd appreciate feedback on:
internal/hintthe right shape? Or would you rather see it inlined intocli/error.gonext toStatusError, or a different abstraction entirely?hint.Wrap(err, "hint")vs\n\n<hint>migration boundary — should this PR also migratewithHelp, or is keeping that as a follow-up clearer?manifest/push's ErrBlobCreated wording uses domain terms that may or may not be appropriate).🤖 Generated with Claude Code