A client you can still use when you configured it wrong
DEV Community

A client you can still use when you configured it wrong

There's a question that turns up every time you wire a chat client together, and it's this. You set a temperature. The model you picked doesn't do temperature. Now what? It's a fair question, and Go has a very firm opinion about it. I disagree with Go.

Go wants a yes or a no

The constructor convention is about as settled as conventions get: return (T, error), and on a non-nil error the T is unusable so don't touch it. Most people know it, most follow it, and it's a good rule. It's good because it assumes construction is one thing that either happened or didn't: open a file, dial a socket, parse a document. Binary outcome, binary contract, no argument.

A chat client isn't one thing. It's a provider, a model, credentials, a timeout, an endpoint, sampling controls, streaming, tool support, a fallback chain, and a fistful of knobs that only exist on some models. Building one is a dozen small decisions, and the interesting failures are always partial. Eleven of them worked. The twelfth (usually the one you cared about least) doesn't apply here. The binary contract has no way to say that, so it forces the library into one of two bad answers, and worse, it forces the choice once, globally, by whoever wrote the constructor, on behalf of every caller and every combination they'll ever try. Bin a working client over a setting nobody would call essential, or keep quiet and let them think it applied.

So give them a receipt

I broke it, deliberately. Build a client with an invalid combination and you get a client: the best working one I can assemble, fully usable, minus the bits that couldn't apply, and with it an itemised list of what didn't make it on. Here's your client, and here's the receipt: two true things instead of one lie.

What makes that a design, rather than me being soft about it, is the exception. There's a hard line between a setting that got dropped and a construction that's impossible, and that line isn't up for negotiation at runtime. Temperature on a model with no temperature is a degradation, so you get the client plus a line on the receipt; a missing credential is fatal, so you get nothing and the error says so through a sentinel called, without much ceremony, ErrUnableToConstruct. Without that line the whole idea collapses into a library deciding your mistakes don't matter much.

A receipt nobody can read is just litter

This pattern lives or dies on the quality of one error value, which is a nerve-wracking place to put a design. "Some settings were not applied" is worse than a hard failure: it tells you something's wrong, hands you no way to act on it, and takes away the crash that would at least have pointed at a line number. So the line item has to carry enough to actually fix the thing:

// DroppedSetting names a Config field that could not be applied, and why.
type DroppedSetting struct {
    // Fields names the Config fields that were not applied, e.g. "Temperature".
    Fields [] string
    // Capability is the capability the fields required.
    Capability Capability
    // Reason is a short human-readable explanation.
    Reason string
}

Which field, what capability it needed, and why it didn't happen.

There's a fix in the history that exists purely because my first go at this wasn't good enough: fix(chat): name the default model in a dropped-setting error. The original said a setting had been dropped without saying which model it had been reasoning about, so if you hadn't picked a model and were leaning on the default, the error told you something was wrong about a thing it then declined to name. That's the very failure I'd spent two weeks describing as worse than crashing... shipped by me, in the first cut of the feature designed to prevent it! It lasted about twelve hours.

The whole receipt carries the same obligation, and itemises everything rather than the first thing it trips over: Construction reports every problem rather than the first, so a caller fixing three mistakes learns all three from one call instead of one round-trip at a time. It also does Go's multi-error unwrapping, so errors.Is reaches any member sentinel whether it was the only problem or one of four. Worth proving rather than assuming, because the project layers cockroachdb/errors over the standard library and I wanted to watch errors.Join and the wrapped values get along before building an API on top of them.

A stack trace is not an error message is the same instinct pointed at logs. This is it pointed at a return value.

Where this falls down

There is a decent case against. A caller who ignores that receipt is holding a client that isn't doing what they configured, which is the failure the strict contract exists to prevent. Plenty of people will write client, _ := and get on with their day (I've done it myself, more than once); the strict version would have stopped them and mine won't. But the information was given. They got a complete, specific, actionable account of what didn't apply, and they made a choice, which is a different thing entirely from a library that swallows the setting and says nothing, even if from the outside you can't tell them apart.

I'd be overselling it if I claimed the trade goes away. It doesn't. I've moved a decision from the library to the caller, and some callers won't make it. Still, the strict contract was never protecting them anyway. It protects you from a whole broken client, and this was never a whole broken client. It was a perfectly good one with a temperature setting that went nowhere, and the old rules had one way of mentioning it: bin the lot, and hope you read the message on the way past. So somewhere out there, right now, there's a client, _ := running along quite happily with a temperature that never happened. The receipt was in the bag all along. Whether anyone looks in the bag is a different matter.

Originally published at phpboyscout.uk on 17 September 2026.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.