Featured image of post Merged in five days, open in seven hundred

Merged in five days, open in seven hundred

Twice in two days I watched a hint vanish.

The first time was in chat. I found where it had gone, wrote the workaround, made a note in the spec and moved on, because that’s what you do with a small thing at the end of a day. The second time was in forge, the following evening… and a small thing that happens twice is not a small thing any more. So instead of writing the second workaround, I went and read the library.

About four hours later the estate had an errors package of its own, public, with a docs site and a v0.1.0 tag on it. Which is a fair old distance to travel between tea and bedtime, so let me back up a bit.

What a hint is, and where it went

This only lands if you know what a hint is, so bear with me for a paragraph.

Go errors nest. You take a failure, wrap it with a bit of context, wrap that with a bit more, and what comes out the far end is a little chain you can walk back down. cockroachdb/errors, which the whole estate has been built on since it was one framework, lets you hang extra things off that chain. A hint is the useful one: not what went wrong, but what the person at the keyboard should actually do about it. Something fails four layers down, the code that catches it walks the chain, collects the hints, and the user gets told to import their keys rather than just “permission denied”. I wrote about that idea back when the estate first leaned into it, and it’s the one thing I really didn’t want to give up.

Go 1.20 added errors.Join, which bundles several errors together into one. The standard library gave those bundles a shape you can recognise on sight: an Unwrap that hands back a slice of errors instead of a single one. Plural. Go and look in all of them.

cockroachdb/errors is older than that by some years, and its own Join puts a stack-capturing wrapper around the bundle. So from the outside, a bundle looks exactly like any ordinary one-link error. Anything walking the chain arrives, sees a single link, follows it, and stops. Everything below the join is invisible: hints, details, context tags, telemetry keys, the lot.

Which, it turns out, somebody had already gone to the trouble of reporting. Issue #162, raised on the 14th of May, and they put it more precisely than I would have. The standard library lets you do this:

err.(interface{ Unwrap() []error }).Unwrap()

and with cockroachdb/errors you need this:

err.(interface{ Unwrap() error }).Unwrap().(interface{ Unwrap() []error }).Unwrap()

An extra hop, through a wrapper nobody asked for. And it does it even when you join a single error, which is the part that actually bit me… code that behaves itself all day breaks the moment it happens to take the aggregate path with one thing in the bag.

It isn’t a bug, and that’s worse

So I wasn’t the first to find it, which was a small comfort right up until I looked at the date again. Getting on for three months, and the issue is still open. Nobody has replied to it at all.

One stale issue tells you nothing, mind. Every project has those, mine included. So I went and read the rest of the tracker.

#162, the defect aboveopen since May, no reply
#147, a one-character docs typoopen since 3 August 2024
#129, slog.LogValuer supportopen since 2023
#159, a compatibility fix that broke CockroachDB’s own buildmerged in five days

That last row is the whole story. Five days! Because it was breaking something CockroachDB needed.

Which is not neglect, and I want to be careful here, because it would be very easy to write the angry version of this post and the angry version would be wrong. That library is maintained, openly and perfectly rationally, for one consumer’s needs. Things CockroachDB needs move in days. Everything else waits years. That’s a perfectly sensible way to run a library and a poor thing to build fifty modules on top of.

Half the reasons I picked it

I chose cockroachdb/errors for exactly two reasons, and I know that because I wrote them down at the time: the feature set, which was enormous, and the fact that it was well maintained and I could rely on it.

The features held. Every one of them is still there, still working, still better than what I’ve replaced them with, if all you’re counting is features. It is the second reason that quietly stopped being true, and half a premise is not a premise.

I have been here before, and I picked the same way

Errors are the fourth thing go-tool-base has stood on, and if you find yourself choosing an error library for the fourth time you probably ought to be a bit suspicious of yourself.

It started where everything starts, on the standard library. Then go-errors/errors, six years or so back, in the days of gtb’s various predecessors. It was great when I picked it up… then it got old, rather quickly… and then it stopped really being looked after at all. By the time gtb was taking its current shape I could feel it holding me back, so I went hunting for something newer, more mature, better maintained.

That hunt was about eighteen months ago and it came down to two. pkg/errors was the obvious candidate, and I passed on it. Not on features. On its long backlog of open issues. cockroachdb/errors looked like the better-kept project of the pair, so that is where the estate went, and where it has been ever since.

Go back and read that again, because I have, a few times. Eighteen months ago I rejected a library because too many of its issues were sitting unanswered, and picked the one that looked healthier. And I have just left that one for the same reason. Same test. Same instinct. Different answer.

Which isn’t a contradiction, and I’m not embarrassed by it. That’s all a dependency choice ever is: a bet you place on the evidence in front of you, on the day you place it. It was the right call. Things move.

What has changed is what it costs to be wrong. That migration was done by hand, find-and-replace, no agent to help me. Dull and slow, but gtb was still a single monolithic framework back then, so it was one codebase and I could kinda hold the whole shape of it in my head. This time it’s fifty-plus modules, every last one built on the assumption that cockroachdb/errors is simply what we use. That’s not a job you take on lightly, agent or no agent.

The bar, and whether this clears it

Now… the bit I’m touchiest about.

I cannot stand “Not Invented Here”. I call it out when I see it, probably harder than is entirely fair. And I’m well aware that a bloke with fifty modules to his name, announcing he’s gone and written his own errors package, is on fairly thin ice here.

So there’s a bar, and everything I’ve built has had to get over it before I’d write a line:

  1. It addresses a need nothing out there is meeting.
  2. It brings something genuinely new.
  3. I can realistically expand and maintain it.

chat clears it: a much lighter-weight alternative to langchain, which is a wonderful thing and absolutely enormous. config clears it, on provenance, layering and true writeback. forge clears it, because the forge SDKs are wildly inconsistent where they exist at all. What you don’t see is the pile that didn’t clear it, and there is a pile. Only recently I put proper time into investigating a proposal, wrote the thing up, and then binned it, because it didn’t satisfy a single one of the three.

And in the interests of not marking my own homework too generously: scoutdm exists largely because I wanted it to exist. That’s hubris and I know it. Only time will tell whether I was right.

So. Does an errors package get over it?

The first one, yes, and I went and checked properly rather than assuming, because assuming is exactly how you end up rebuilding something that already exists. Every alternative is a worse maintenance bet than the thing I’m leaving, and not one of them has a hint concept at all. The closest, tozd/go/errors, is genuinely good and I want to be fair to it: it takes pkg/errors, keeps the API, and fixes a long list of things the archived original was never going to get to, because the fixes needed breaking changes. It even keeps a pkg/errors import on purpose, so you can mix the two while you migrate off. There are just no hints in it, and hints are what I came for.

The third one, comfortably. The core is 476 lines, and the whole of its go.mod is this:

module gitlab.com/phpboyscout/go/errors

go 1.26.5

Not “few dependencies”. None. And that’s asserted by a test rather than left to my good intentions, because every module in the estate imports errors, so whatever it drags in, they all drag in. That’s exactly how the library I’m leaving came to contribute forty-six of forge’s three hundred and seventy-six transitive packages, a deprecated gogo/protobuf among them, in return for the ten or so functions I actually call.

The second one is where it wobbles, and I’d rather say so myself than have you spot it. There’s nothing novel in here. It’s a small, dull errors package that does what the estate needs and then stops. Two out of three, and the one it’s short on is the one I’d hold somebody else to.

Where I left the holes

The one thing I did dig my heels in about, before a line was written, was where the gaps go. I’ve paid for this migration twice now and I’d quite like the third to be the last.

Wire serialization was a headline reason I picked cockroachdb in the first place. Nothing I run today needs it, but plenty of what I want to build will, and losing the ability to add it later would make this whole move a downgrade. So it comes later, as a sibling module, and the core is already shaped to take it: every layer carries a stable kind, and nothing anywhere type-switches on a concrete type. Which means a decoder that meets a kind it has never heard of can still carry that layer along without snapping the chain.

OpenTelemetry got the better question, and I asked it against my own proposal: should an error fire an OTEL call at all? An error that reports itself is doing two jobs and holding opinions about your observability stack. Deciding a thing is worth recording belongs where a span is in scope, not where the error got made. So the package ships no RecordError, only the contract you’d build one from. go/observability already owns that relationship and it can keep it.

Not YAGNI, and not building a cathedral for an imaginary congregation either. Somewhere in between: work out which of the things you might want later would force you to tear it up and start again, and leave holes the right shape for those. Those only.

The cutover is running as I write this. Leaves first, working upward, which isn’t the order you’d guess, and is a story for another day.

It lives at gitlab.com/phpboyscout/go/errors, with docs at errors.go.phpboyscout.uk. MIT, no dependencies, and it kept the cockroachdb names on purpose, so if you happen to be in the same boat the move is mostly one import path and a sed. I make no grander claim for it than that it’s what my estate runs on now, which is either a recommendation or a warning depending on how the next year goes.

cockroachdb/errors is still there, and it’s still good. Still maintained exactly as carefully as it was last week, by somebody doing a perfectly reasonable job for the people he’s doing it for. That one-character docs typo is two years old today, to the day, and nobody is going to mark the occasion. Somebody is still waiting on it, and none of that is my problem any more.

I just stopped being one of the people it was for.

Built with Hugo
Theme Stack designed by Jimmy