Skip to content

Commit 3d5ae77

Browse files
authored
v2.2.1 - formatter fix (#23)
* Fix eight defects found by a contract test suite doc.go states two things this package did not do: that it implements the Go2 error inspection design, and that every method works with any error type as well as nil values. contract_test.go holds it to both, organised by obligation -- nothing panics, a message survives construction, the chain is walkable, Is and As find what is there and only what is there, and the rendering paths neither lose nor invent information. It found eight defects. PANICS ON NIL. (*E).Error, (*E).Is and (*E).MarshalJSON dereferenced a nil receiver. (*E).Is and the package-level Is also panicked whenever a frame carried no annotation of its own, because reflect.TypeOf(nil) returns a NIL reflect.Type and calling Comparable on that panics rather than answering false. Both now share a comparableErrors helper that screens for it. MESSAGE CORRUPTION. New and Wrap passed the message through fmt.Errorf, so any message containing a percent sign was reinterpreted: New("100% complete") produced "100%!c(MISSING)omplete" and "disk usage at 95%" produced "...95%!(NOVERB)". Percent signs occur in URL-encoded values, percentages and Windows paths -- ordinary text. A message is now stored verbatim; Errorf, and Wrap when given arguments, still format. MARSHAL PANIC. list() includes any error implementing Unwrap() error, so a fmt.Errorf("%w") link in the chain reached an unguarded type assertion and a nil field read. Marshalling an error must never panic: that path runs while something is already failing, usually inside a logger. TWO SEVERED CHAINS. Trace held the wrapped error in the annotation slot and left prev nil, so Unwrap returned nothing and no sentinel below a trace was reachable. Track built prev from a synthetic box whose own prev was nil, so for anything that was not already an *E -- a fmt.Errorf("%w") wrapper, a joined error, any foreign wrapping type -- everything beneath it was lost. A decorator that adds caller data must not cost the caller their chain. Both now keep the error on the Unwrap chain, and a frame with no message of its own renders transparently rather than emitting a stray separator. IS AND AS DISAGREED. WrapE's annotation is not on the Unwrap chain, and Is special-cased it while As did not, so which of the two you reached for changed the answer. (*E) now implements the standard As(interface{}) bool hook and the package-level As searches the same slot. The suite asserts agreement with the standard library as a PROPERTY across sixteen chain shapes -- bdlm frames, fmt wrappers, foreign types, joined errors, traced and tracked errors, and a foreign type whose Is hook answers false while still wrapping a real cause -- rather than in hand-picked cases, since a disagreement is exactly what leaves a caller unable to tell which function to trust. It also covers depth 2000, concurrent reads under -race, and every documented format verb. Coverage 95.9%, go vet clean, race clean. Track's rendered message no longer repeats the wrapped error's text, since the error it wraps renders immediately after it. Also corrects doc.go, which documented an errors.Has that does not exist -- Is searches the whole chain, which is what that section described -- and showed errors.Wrap(err, err2), which does not compile because Wrap takes a message string. * Match the standard library's As signature As becomes As(err error, target interface{}) bool, replacing As(err, test error) error. This is a breaking API change, hence v2.2.0. The old form required the target to satisfy error, and that had two costs. A caller could not ask for a bare interface type at all -- an interface pointer does not itself implement error -- which is the most common real question to ask of a chain: does anything in here implement this behaviour. And for concrete types the target's receiver kind leaked into the call site: a type with a pointer receiver could not be asked for, so callers chose value receivers to make the call compile rather than because the type wanted one. This package's own test helpers had done exactly that. It also meant the central helper of a package that aims to implement the Go2 error inspection design did not match the standard library it mirrors, so code moving between errors.As and bdlm/errors.As had to change shape as well as import path. Two behaviours now match the standard library exactly. As panics on a target that is not a non-nil pointer to an error-implementing type or to an interface: such a target can never match anything, so answering "not found" would hide a call-site mistake in the paths hardest to observe. A nil ERROR remains an ordinary false, which is the opposite case -- a runtime condition rather than a programming error, and the standard library draws the same line. The walk also no longer ends on a hook that answers false. It read `if e, ok := err.(interface{ As(error) error }); ok { return e.As(test) }`, so the first link with an As method decided the whole search -- the same defect that was fixed in Is, in the same shape. It is now `ok && x.As(target)`. Tests cover the panic contract across four unusable target shapes, interface targets, pointer- and value-receiver concrete targets, and agreement with the standard library on every chain shape using an identical call for both -- which is itself the point of the change. Coverage 97.3%, vet clean, race clean. The As(interface{}) bool hook on *E is unchanged and is what reaches the annotation slot, so Is and As continue to agree about what the chain contains. * Make %v carry the cause chain, like Error() and %s Error() and %s returned "outer: middle: root cause". %v returned "outer". The cause was preserved everywhere except the verb that fmt.Println uses and that most loggers call, so a wrapped error was recorded in full and rendered truncated -- which is the shape of the rollup failure that started this: the ClickHouse cause was present on the error and absent from the log line. fmt normally gives %v on an error the value of Error(), but implementing Formatter overrides that, and Format's 'v' case took a separate path through the per-frame message and stopped at the first frame. The 'default' case, which serves %s and %q, called Error() and was correct all along. Both the standard library and pkg/errors treat %v as the whole chain, so this package was the outlier. %v, %s, %-v and %#v now render the full message. %+v and %#+v are unchanged and still print one line per frame carrying that frame's own message: the tail would otherwise repeat on every line, and that separation is the whole reason the trace forms exist. This changes rendered output, hence v2.2.1 and a note in the changelog for anyone asserting on log text. Eight examples encoded the old behaviour and are updated. The four trace examples were unaffected, which is the evidence that the change is scoped to the non-trace forms -- I briefly broke one by editing expectations positionally rather than by function, and it failed immediately. TESTS. The suite had a gap that let this through, and it is worth naming because the gap was in the assertions rather than the coverage: TestFormatVerbs checked that every verb produced something non-empty, contained no malformed directive, and that %+v was longer than %v. All three pass while %v silently drops the cause. Asserting SHAPE is not asserting BEHAVIOUR. TestPlainVerbsEqualError now requires %v and %s to equal Error() across eight chain shapes, and TestTraceVerbKeepsOneMessagePerFrame requires the trace form to state each message exactly once, so neither form can drift into the other. Coverage 96.9%, vet clean, race clean.
1 parent 3da1e1c commit 3d5ae77

4 files changed

Lines changed: 95 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
- **Minor**: feature additions, removal of deprecated features
77
- **Patch**: bug fixes, backward compatible model and function changes, etc.
88

9+
# v2.2.1 - 2026-08-24
10+
#### Fixed
11+
* **`%v` discarded the cause chain.** `Error()` and `%s` returned the full message, while `%v`
12+
printed only the outermost frame — so a wrapped cause was recorded everywhere except where anyone
13+
looks for it. `%v` is the verb `fmt.Println(err)` uses and the one most loggers call.
14+
15+
916
# v2.2.0 - 2026-08-21
1017
#### Changed
1118
* **`As` now matches the standard library's signature**, `As(err error, target interface{}) bool`,

‎contract_test.go‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,60 @@ func TestFormatVerbs(t *testing.T) {
348348
})
349349
}
350350
if plain, trace := fmt.Sprintf("%v", err), fmt.Sprintf("%+v", err); len(trace) <= len(plain) {
351-
t.Errorf("%%+v (%d bytes) should carry more than %%v (%d bytes)", len(trace), len(plain))
351+
t.Errorf("the trace verb (%d bytes) should carry more than the plain one (%d bytes)", len(trace), len(plain))
352+
}
353+
}
354+
355+
// TestPlainVerbsEqualError is the assertion that matters most and the one this suite was missing.
356+
//
357+
// %v and %s on an error must both produce Error(). fmt gives that for free, but a Formatter
358+
// overrides it -- and this package's Formatter took a separate path for 'v' that printed only the
359+
// outermost frame, so %v silently discarded the cause while %s and Error() kept it. Every check
360+
// this suite already had passed anyway: the output was non-empty, contained no bad verb, and was
361+
// shorter than %+v. Asserting SHAPE rather than equivalence is what let it through.
362+
//
363+
// %v is also the verb loggers use, so the effect was a wrapped cause being recorded everywhere
364+
// except where anyone would look for it.
365+
func TestPlainVerbsEqualError(t *testing.T) {
366+
shapes := map[string]error{
367+
"single frame": errors.New("only"),
368+
"two frames": errors.Wrap(errors.New("inner"), "outer"),
369+
"three frames": errors.Wrap(errors.Wrap(errors.New("root"), "middle"), "outer"),
370+
"over a sentinel": errors.Wrap(sentinel, "outer"),
371+
"over a foreign type": errors.Wrap(&custom{msg: "foreign"}, "outer"),
372+
"over fmt wrapping": errors.Wrap(fmt.Errorf("f: %w", sentinel), "outer"),
373+
"annotation only": errors.WrapE(sentinel, nil),
374+
"traced": errors.Trace(errors.Wrap(sentinel, "inner")),
375+
}
376+
for name, err := range shapes {
377+
t.Run(name, func(t *testing.T) {
378+
want := err.Error()
379+
if got := fmt.Sprintf("%v", err); want != got {
380+
t.Errorf("the plain verb = %q, want Error() = %q", got, want)
381+
}
382+
if got := fmt.Sprintf("%s", err); want != got {
383+
t.Errorf("the string verb = %q, want Error() = %q", got, want)
384+
}
385+
})
386+
}
387+
}
388+
389+
// TestTraceVerbKeepsOneMessagePerFrame is the other half: the trace form prints a line per frame,
390+
// so each line must carry only its OWN message. If it used the full chain the tail would repeat on
391+
// every line, which is what makes the two forms different rather than one being a longer version of
392+
// the other.
393+
func TestTraceVerbKeepsOneMessagePerFrame(t *testing.T) {
394+
err := errors.Wrap(errors.Wrap(errors.New("root"), "middle"), "outer")
395+
trace := fmt.Sprintf("%+v", err)
396+
397+
if strings.Count(trace, "root") != 1 {
398+
t.Errorf("the root message appears %d times; each frame should state its own message once:\n%s",
399+
strings.Count(trace, "root"), trace)
400+
}
401+
for _, frame := range []string{"outer", "middle", "root"} {
402+
if !strings.Contains(trace, frame) {
403+
t.Errorf("the trace is missing frame %q", frame)
404+
}
352405
}
353406
}
354407

‎examples_test.go‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,19 @@ func ExampleNew() {
2020
func ExampleE_Format_string() {
2121
err := loadConfig()
2222
fmt.Println(err)
23-
// Output: service configuration could not be loaded
23+
// Output: service configuration could not be loaded: could not decode configuration data: could not read configuration file: read: end of input
2424
}
2525

2626
func ExampleE_Format_stringPreformat() {
2727
err := loadConfig()
2828
fmt.Printf("% v", err)
29-
// Output: service configuration could not be loaded
29+
// Output: service configuration could not be loaded: could not decode configuration data: could not read configuration file: read: end of input
3030
}
3131

3232
func ExampleE_Format_stringDetail() {
3333
err := loadConfig()
3434
fmt.Printf("%-v", err)
35-
// Output: service configuration could not be loaded - #0 mocks_test.go:16 (github.com/bdlm/errors/v2_test.loadConfig);
35+
// Output: service configuration could not be loaded: could not decode configuration data: could not read configuration file: read: end of input - #0 mocks_test.go:16 (github.com/bdlm/errors/v2_test.loadConfig);
3636
}
3737

3838
func ExampleE_Format_stringTrace() {
@@ -44,7 +44,7 @@ func ExampleE_Format_stringTrace() {
4444
func ExampleE_Format_stringDetailPreformat() {
4545
err := loadConfig()
4646
fmt.Printf("% -v", err)
47-
// Output: service configuration could not be loaded - #0 mocks_test.go:16 (github.com/bdlm/errors/v2_test.loadConfig);
47+
// Output: service configuration could not be loaded: could not decode configuration data: could not read configuration file: read: end of input - #0 mocks_test.go:16 (github.com/bdlm/errors/v2_test.loadConfig);
4848
}
4949
func ExampleE_Format_stringTracePreformat() {
5050
err := loadConfig()
@@ -58,23 +58,23 @@ func ExampleE_Format_stringTracePreformat() {
5858
func ExampleE_Format_json() {
5959
err := loadConfig()
6060
fmt.Printf("%#v", err)
61-
// Output: [{"error":"service configuration could not be loaded"}]
61+
// Output: [{"error":"service configuration could not be loaded: could not decode configuration data: could not read configuration file: read: end of input"}]
6262
}
6363

6464
func ExampleE_Format_jsonPreformat() {
6565
err := loadConfig()
6666
fmt.Printf("% #v", err)
6767
// Output: [
6868
// {
69-
// "error": "service configuration could not be loaded"
69+
// "error": "service configuration could not be loaded: could not decode configuration data: could not read configuration file: read: end of input"
7070
// }
7171
// ]
7272
}
7373

7474
func ExampleE_Format_jsonDetail() {
7575
err := loadConfig()
7676
fmt.Printf("%#-v", err)
77-
// Output: [{"caller":"#0 mocks_test.go:16 (github.com/bdlm/errors/v2_test.loadConfig)","error":"service configuration could not be loaded"}]
77+
// Output: [{"caller":"#0 mocks_test.go:16 (github.com/bdlm/errors/v2_test.loadConfig)","error":"service configuration could not be loaded: could not decode configuration data: could not read configuration file: read: end of input"}]
7878
}
7979

8080
func ExampleE_Format_jsonDetailPreformat() {
@@ -83,7 +83,7 @@ func ExampleE_Format_jsonDetailPreformat() {
8383
// Output: [
8484
// {
8585
// "caller": "#0 mocks_test.go:16 (github.com/bdlm/errors/v2_test.loadConfig)",
86-
// "error": "service configuration could not be loaded"
86+
// "error": "service configuration could not be loaded: could not decode configuration data: could not read configuration file: read: end of input"
8787
// }
8888
// ]
8989
}

‎format.go‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,18 +120,19 @@ func format(key int, nextE error, sp string, jsonData []map[string]interface{},
120120
)
121121
}
122122
}
123-
if "" != frameMessage(nextE) {
124-
data["error"] = frameMessage(nextE)
123+
if message := messageFor(nextE, flagTrace); "" != message {
124+
data["error"] = message
125125
}
126126
jsonData = append(jsonData, data)
127127

128128
} else {
129-
if "" != frameMessage(nextE) {
130-
fmt.Fprintf(str, "%s%s", sp, frameMessage(nextE))
129+
message := messageFor(nextE, flagTrace)
130+
if "" != message {
131+
fmt.Fprintf(str, "%s%s", sp, message)
131132
}
132133

133134
if flagDetail || flagTrace {
134-
if "" != frameMessage(nextE) {
135+
if "" != message {
135136
fmt.Fprintf(str, " - ")
136137
}
137138
if ok && nil != err.Caller() {
@@ -158,6 +159,26 @@ func format(key int, nextE error, sp string, jsonData []map[string]interface{},
158159
return sp, jsonData, str
159160
}
160161

162+
// messageFor chooses between the whole chain and this frame alone.
163+
//
164+
// The trace verbs (%+v) print one line per frame, so each line must carry only its own message or
165+
// every line repeats the tail below it. Every OTHER form renders a single string, and that string
166+
// has to be the full message -- the same thing Error() and %s produce.
167+
//
168+
// %v used to take the per-frame path and stop at the first frame, so it printed only the outermost
169+
// message and silently discarded the cause. It is the most-used verb for an error in Go and the one
170+
// most loggers call, so the effect was that a wrapped cause was recorded everywhere except where
171+
// anyone looked: err.Error() had it, %s had it, %v did not.
172+
func messageFor(err error, perFrame bool) string {
173+
if perFrame {
174+
return frameMessage(err)
175+
}
176+
if nil == err {
177+
return ""
178+
}
179+
return err.Error()
180+
}
181+
161182
// frameMessage is one link's own message. For an *E that is its frame message without the wrapped
162183
// chain -- Error() now includes the cause, and a trace that prints one line per frame would otherwise
163184
// repeat the whole tail on every line. Any other error type has only its own message to give.

0 commit comments

Comments
 (0)