Skip to content

Why a failure is never cached

Every "do this once" helper in Go caches the first outcome. sync.Once, sync.OnceValues, and every hand-rolled if done { return cached } do the same thing: they remember what happened the first time, whatever it was.

This module deliberately does not. A success is held; a failure is reported to the caller and forgotten, and the next Get tries again.

What the alternative costs

Consider a tool that resolves cloud credentials from an instance metadata service, started by a supervisor a moment before the network is ready.

With a first-outcome cache, that one badly-timed failure is the process's answer for the rest of its life. Every later call returns the same error. The tool does not recover when the network arrives — it recovers when somebody restarts it.

With this rule, the failure is a failure. The next reload resolves, and nobody notices there was a problem.

That asymmetry — a transient condition converted into a permanent one — is the entire argument, and it is why sync.OnceValues is explicitly not an acceptable implementation of this contract anywhere in the estate.

What it costs in the other direction

Honestly: repeated failures are repeatedly paid for. If your provider is down for an hour, a resolver retried every reload will attempt the resolution every reload, rather than failing instantly from cache.

That is the correct trade for configuration and credentials, where the failures are overwhelmingly transient — not ready yet, briefly unreachable, still starting — and where the cost of not recovering is a restart. It would be the wrong trade for something whose failures are permanent and whose retries are expensive; that is not what this module is for.

Two things bound the cost:

  • Concurrent callers still collapse into one attempt. A wave of fifty retrying callers is one attempt, not fifty.
  • Each attempt is bounded by DefaultBuildTimeout (30 seconds), or whatever WithBuildTimeout sets. Per attempt, not a total budget — precisely because a failure caches nothing, so a later call deserves a fresh allowance.

Success is published exactly once

The other half of the contract. Concurrent first callers do not each build: whichever arrives first starts the attempt, the rest wait on it, and all of them receive the same value. There is never a moment where a caller observes "no value and no attempt" while an attempt is running, and never two attempts racing to publish into the same generation.

That is one mutex covering the published value, whether there is one, the generation, and the attempt in flight — one rather than several, because the invariant that matters spans all four.

Cancellation belongs to the caller

A caller whose own context is cancelled returns promptly with that context's error. The shared attempt continues, because it is not theirs to cancel — other callers are waiting on it, and one caller giving up is not a reason to fail everybody.

The separate, longer-lived scope is WithLifetimeContext, which says "this whole source is finished" and does abandon attempts.

Where this is specified

Decision D-4 of org spec 0002, implemented once here as P-4 of org spec 0003.