Skip to content

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.

go get gitlab.com/phpboyscout/go/clientlifecycle

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)
}
credential-abc <nil>
credential-abc <nil>
builds: 1

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)
builds: 1

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>
 metadata service not ready
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:

src := clientlifecycle.Memoised(build, clientlifecycle.WithBuildTimeout(5*time.Second))

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? WithLifetimeContext scopes attempts to the life of whatever owns the resolver.