Skip to content

[RFC] errors: introduce structured hints + apply audit-driven rewrites#6959

Draft
dvdksn wants to merge 3 commits intodocker:masterfrom
dvdksn:error-audit
Draft

[RFC] errors: introduce structured hints + apply audit-driven rewrites#6959
dvdksn wants to merge 3 commits intodocker:masterfrom
dvdksn:error-audit

Conversation

@dvdksn
Copy link
Copy Markdown
Contributor

@dvdksn dvdksn commented Apr 28, 2026

What I did

Two things, packaged together so the abstraction is motivated by concrete improvements rather than argued in the abstract:

  1. Introduces internal/hint — a small (~50-line) package that attaches actionable user guidance to errors. The hint is exposed via a Hinter interface, walked out of the error chain by the top-level renderer in cmd/docker/docker.go, and printed on its own line. Hints are not part of err.Error(), so substring assertions on error messages stay stable as hints are added or reworded.

  2. 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:

    • Factual / wrong-noun fixes — messages that misdirected users to the wrong layer or named things that didn't exist (e.g. image load claiming "stdin is empty" when the actual guard checks IsTerminal(); image list referencing the internal field name --show-digest instead of the user-facing --digests flag; inspect calling template execution failures "parsing errors").
    • Partial success surfaced — operations that succeeded with a follow-up failure now say so, preventing wasted retries (container create cidfile write; image build aux-message decode and missing image ID for --iidfile).
    • Refusal reasons explained — TTY-refusal messages for binary tar output now state why instead of using "cowardly".
    • Echo-the-value / name-the-flag — validation errors include what the user typed and the expected form (the --pid/--uts/--userns/--cgroupns family, --device, --storage-opt, --driver-opt, --subnet, etc.).
    • Internal jargon replacedcontainer exec's "exec ID empty" (an internal API field) replaced with daemon-anomaly framing.
    • Cause preservedmanifest push ErrBlobCreated now wrapped via %w and described correctly.

How I did it

The mechanism. A 50-line internal package + a 4-line addition to the top-level error renderer. Usage:

return hint.Wrap(
    fmt.Errorf("invalid --pid mode %q", val),
    "Valid forms are 'host' or 'container:<name|id>'.",
)

The cmd/docker renderer walks the chain via errors.As for any Hinter and 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.
  • Manually trigger a few of the rewritten errors:
    docker image save alpine               # writes to terminal -> binary-tar refusal with reason + hint
    docker image load                       # stdin is a TTY -> states the actual condition + hint
    docker run --pid=hsot alpine            # echoes the bad value + lists valid forms
    docker network create --subnet=10.0.0.0/24 --subnet=10.0.0.128/25 net  # names both overlapping subnets
    docker volume update some-non-cluster-volume  # names the volume + the constraint
    
  • Compare 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 Hinter interface 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)

  • withHelp in container/run.go still uses \n\n for its --help suggestion. Pre-existing pattern, untouched in this PR — natural follow-up to migrate to hint.Wrap once the mechanism lands.
  • Stderr-write error sites (18 per the inventory). They bypass the central renderer entirely; addressing them needs separate consolidation work.
  • Catalog / identity-based error matching (à la sentinel var ErrFoo = ... patterns). The current shape is a strict subset of what a catalog would provide; if it's wanted later, Wrap can sit underneath without breaking call sites.
  • One known nit deferredcontext create's "no docker endpoint configured: set one with --docker, or copy from an existing context with --from" reads slightly off because --docker takes a structured key=value value rather than a single thing to "set". Happy to refine if reviewers prefer different wording.

Related

Asks of reviewers

Marked as draft intentionally. Before deep-diving on each individual rewrite, I'd appreciate feedback on:

  1. Is internal/hint the right shape? Or would you rather see it inlined into cli/error.go next to StatusError, or a different abstraction entirely?
  2. The hint.Wrap(err, "hint") vs \n\n<hint> migration boundary — should this PR also migrate withHelp, or is keeping that as a follow-up clearer?
  3. Any rewrites I shouldn't have shipped — the audit's bar was "concrete user-visible benefit", but reviewers closer to specific commands may have context I missed (e.g. manifest/push's ErrBlobCreated wording uses domain terms that may or may not be appropriate).

🤖 Generated with Claude Code

dvdksn and others added 2 commits April 28, 2026 12:15
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-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 28, 2026

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>
Copy link
Copy Markdown
Collaborator

@vvoland vvoland left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this!

I think it might be a little bit more complicated to implement this properly though 😿

Comment on lines +192 to +195
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),
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be swallowed by cli.StatusError wrapping at the call site:

Status: withHelp(err, "create").Error(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will also be case for many other operations too

Comment thread cmd/docker/docker.go
Comment on lines 51 to +54
_, _ = fmt.Fprintln(os.Stderr, err)
if h := hint.Of(err); h != "" {
_, _ = fmt.Fprintln(os.Stderr, "\n"+h)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, we'd probably also want the plugins to be able to return hintable errors:

_, _ = fmt.Fprintln(dockerCLI.Err(), err)

Comment thread internal/hint/hint.go
Comment on lines +39 to +51
// 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 ""
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be just:

Suggested change
// 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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants