Getting started¶
By the end of this you will have a resolver that builds a value once, shares it between concurrent callers, and — unlike every "do this once" helper in the standard library — retries after a failure instead of remembering it forever.
You need Go 1.25 or later. Nothing else: this module has no dependencies.
1. Wrap something expensive¶
A Builder[T] is any function that produces a value and might fail. Here is one
that pretends to be a slow credential resolution:
package main
import (
"context"
"fmt"
"time"
"gitlab.com/phpboyscout/go/clientlifecycle"
)
func main() {
built := 0
build := func(ctx context.Context) (string, error) {
built++
time.Sleep(200 * time.Millisecond) // pretend this talks to a metadata service
return "credential-abc", nil
}
src := clientlifecycle.Memoised(build)
fmt.Println(src.Get(context.Background()))
fmt.Println(src.Get(context.Background()))
fmt.Println("builds:", built)
}
Two Get calls, one build. Note also what did not happen: constructing the
resolver did no work at all. Memoised returns immediately and takes no lock —
the first Get starts the first attempt. That is what lets you build a resolver
in init, or in a constructor with no context to hand.
2. Watch concurrent callers collapse into one attempt¶
Replace the body of main with a wave of callers:
var wg sync.WaitGroup
for range 50 {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = src.Get(context.Background())
}()
}
wg.Wait()
fmt.Println("builds:", built)
Fifty callers, one build. The other forty-nine waited on it rather than starting their own — which is the point when the build is a credential resolution against a metadata service that rate-limits.
3. Fail, and try again¶
This is the behaviour that matters, and it is where clientlifecycle differs
from sync.OnceValues. Make the first attempt fail:
attempts := 0
build := func(ctx context.Context) (string, error) {
attempts++
if attempts == 1 {
return "", errors.New("metadata service not ready")
}
return "credential-abc", nil
}
src := clientlifecycle.Memoised(build)
fmt.Println(src.Get(context.Background())) // "" , error
fmt.Println(src.Get(context.Background())) // credential-abc , <nil>
The failure was reported to the caller and then forgotten. The next Get
tried again and succeeded.
Had this been sync.OnceValues, the second call would have returned the same
error — and so would every call for the rest of the process's life. A credential
chain that was not ready when your tool started would require a restart rather
than a retry. That is the single decision this module exists to make, and
why a failure is never cached explains
what it costs.
4. Bound an attempt¶
Every attempt runs under a timeout — DefaultBuildTimeout, 30 seconds — so a
builder that hangs cannot hang your process forever:
It is a bound per attempt, not a total budget: a failure caches nothing, so a later call gets a fresh allowance.
One caveat worth knowing now rather than discovering later: the timeout is cooperative. It cancels the context handed to your builder. A builder that ignores its context will run to completion regardless, and callers will wait for it — Go cannot preempt a running function. If you write a builder, respect the context you are given.
Next¶
- Your value is not a credential that refreshes itself? Read choosing a strategy.
- Shutting down cleanly?
WithLifetimeContextscopes attempts to the life of whatever owns the resolver.