skip to content

A worker's Config.PollTimeout was left at 0 and reached the client as 'wait forever' — how do you stop that class of bug?

level: seniorimportance: should knowfreq 47%

answer

  1. nobody chose that value
  2. a zero crossed a boundary and changed meaning
  3. three fates: default, pointer, or error
  4. print what actually applied
  5. test New with an empty Config

basics

~20 s

Never let an unresolved zero leave the constructor. Decide per field whether zero means unset, a legal request or an error; never forward a downstream API's sentinel; log the effective settings at start-up and test New with an empty Config.

solid answer

~50 s

The bug is a zero crossing a boundary where its meaning changes: in the Config it meant 'the operator said nothing', and in the client it meant 'no deadline'. The rule is that `New` resolves every field, so no zero flows through unexamined. Each field gets one of three fates written into the constructor: zero means unset and is replaced by a named default; zero is a legal request, so the field becomes a pointer whose nil means unset; or zero is invalid and construction returns an error. I also refuse to inherit someone else's sentinel — if the downstream call reads 0 as 'forever', my package converts its resolved duration explicitly at that call site. Then I make the outcome visible: one start-up line listing the settings actually in force, and a test asserting what `New(Config{})` produces.

code

go · 9 lines
go
func TestNewFillsTheEmptyConfig(t *testing.T) {
	w, err := New(Config{Queue: "jobs"})
	if err != nil {
		t.Fatal(err)
	}
	if w.cfg.PollTimeout != defaultPollTimeout {
		t.Errorf("PollTimeout = %v, want %v", w.cfg.PollTimeout, defaultPollTimeout)
	}
}

go deeper

for a junior

Remember that an omitted struct field is not empty, it is zero, and that zero can mean something drastic further down. Check what a downstream API does with a zero before forwarding one.

for a middle

Explain the three fates a field can be given in the constructor and why a sentinel must be translated at the boundary rather than forwarded. Be able to write the test that pins an empty Config's behaviour.

for a senior

Diagnose from the symptom back to the boundary where the zero changed meaning, then give the full remedy: resolution in New, a start-up echo of the effective settings, a test, and a sweep of the sibling fields.

for a principal

Turn the incident into policy: which classes of field are never allowed a default, what every service must log at start-up, and how that is enforced in review rather than remembered.

## Anatomy of the incident A long-running worker polls a queue. Its `Config` has `PollTimeout time.Duration`. Nobody set it in the deployment that shipped on Friday, so it was `0`. `New` passed the field straight through into the queue client, and that client documents `0` as 'block until a message arrives'. The worker stopped honouring its poll deadline, stopped rotating connections, stopped reporting the metric that the alert was built on — and page fired at 3am to an engineer who had never heard of `PollTimeout` and could not tell from the logs what value was in force. The root cause is not the missing value. It is that a zero was allowed to travel across a boundary where its meaning silently changed. Go makes this easy: every field always has a value, the compiler cannot warn you that one was never written, and `0` reads as an ordinary number all the way down. ## Rule one: the constructor resolves everything Every field in the Config gets a decision in `New`, and there are only three possible decisions: - **Zero means unset.** Replace it with a named default constant. Correct when nobody would ever ask for zero. - **Zero is a legal request.** The field must become a pointer (or carry a companion 'set' flag) so `nil` can mean unset. Now `ptr(0)` and omission are different. - **Zero is invalid.** Return an error naming the field. Correct for anything with no defensible default, and for anything whose wrong value is expensive. What you must not do is leave a fourth case — 'pass it through and hope'. If a reviewer cannot point at the line in `New` that decides a field's fate, that field is the next incident. ## Rule two: do not inherit somebody else's sentinel The deeper defect is that the client's convention ('0 disables the deadline') leaked into a struct the operator fills in. Your `Config` is your API; its meanings are yours. Translate at the boundary: resolve `PollTimeout` to a real duration, and if the downstream call needs a different encoding, produce it explicitly at the call site. The general principle is that sentinel values do not compose — every layer that forwards one adds a chance for the meanings to disagree — and the place to stop the chain is the constructor that owns the surface the human types into. The same reasoning applies to any field where the dangerous value is the unset one: an unlimited queue depth, a retry count of zero meaning 'infinite' in some library, a rate limit of zero meaning 'no limit'. Where the failure mode of the zero is unbounded rather than merely wrong, prefer making the field required. An error at start-up is cheap; a worker that never times out is not. ## Rule three: make the effective configuration visible Emit exactly one structured line at start-up listing the settings actually in force after defaults were applied — not the Config the caller passed, the resolved one. It costs nothing, it is the first thing the on-call engineer greps for, and it answers 'was that value chosen or inherited?' without a source dive. Two practical details: give the resolved struct a `String` method (or log the fields explicitly) so secrets are redacted rather than dumped, and log the same field names the operator sets, so the line can be matched against the deployment. Without this, the diagnosis at 3am is source archaeology across a version you may not have checked out, and the fix is a guess. ## Rule four: test the empty Config A short table test that constructs from `Config{}` — plus one that sets each field explicitly — pins the documented defaults. It fails loudly when someone changes a constant, which is precisely the review conversation you want to have. It also catches the pass-through bug directly: the assertion that `New(Config{}).cfg.PollTimeout` equals `defaultPollTimeout` is exactly the property that was violated. ## Rule five: sweep the rest of the struct An incident on one field is evidence about the others. Fixing `PollTimeout` alone leaves the same trap in every sibling field with the same shape. Walk the struct once, classify each field, and note the decision in its doc comment. That comment is what the next person reads before adding field number twelve. ## What the interviewer is listening for The candidate who has lived through this does not stop at 'add a default'. They separate the three meanings of zero, refuse to propagate a downstream sentinel, make the resolved configuration observable, and pin it with a test — and they say who reads the output and when.

  • How does the on-call engineer find out which defaults applied?
    From one start-up line listing the resolved settings, logged after New has filled everything in and with secrets redacted. Anything less means reading the source of a version they may not have checked out, at 3am, to answer a question the process already knew the answer to.
  • Should New have rejected the zero instead of defaulting it?
    For a field whose wrong value fails unboundedly — no deadline, no limit, unlimited retries — yes. A start-up error is caught by the deploy and fixed by the person who made the change; a silent default fails later, on somebody else's shift, with no evidence that a choice was ever made.
  • Where should the downstream 'zero means no deadline' convention live?
    Only at the call site that talks to that API. Resolve your own field to a real duration first and convert explicitly when you hand it over. A sentinel that is forwarded through layers eventually meets a layer that reads it differently, which is exactly this incident.
  • How do you find other fields with the same trap?
    Walk the whole Config once and classify every field as defaultable, optional-with-a-legal-zero, or required, then check that New contains the corresponding line for each. Record the decision in each field's doc comment so the classification survives the next person adding a field.

saying these in an interview costs you the question

  • Passes an unresolved zero straight into a downstream call
  • Adopts the downstream API's 'zero means unlimited' rule in its own Config
  • Learns the effective configuration only by reading source during an incident
  • Has no test constructing from an empty Config
  • Fixes the one reported field and leaves the same trap on its siblings
  • Adds a default without asking whether the field should be required